From b5b4721456cb9f877e65fbb3bac9763f3908fd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Fri, 24 Jul 2026 09:43:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 10 + .idea/ShiErFeng.iml | 10 + .idea/inspectionProfiles/Project_Default.xml | 25 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + CHANGELOG.md | 547 +++++++ LICENSE | 21 + README.md | 168 +++ shierfeng-fastapi-app/.editorconfig | 12 + shierfeng-fastapi-app/.gitignore | 27 + shierfeng-fastapi-app/.npmrc | 7 + shierfeng-fastapi-app/README.md | 78 + shierfeng-fastapi-app/index.html | 29 + shierfeng-fastapi-app/netlify.toml | 13 + shierfeng-fastapi-app/package.json | 98 ++ shierfeng-fastapi-app/platform.ts | 8 + shierfeng-fastapi-app/postcss.config.ts | 13 + shierfeng-fastapi-app/src/App.vue | 79 + shierfeng-fastapi-app/src/api/login.js | 62 + .../src/api/system/dict/data.js | 52 + .../src/api/system/dict/type.js | 60 + shierfeng-fastapi-app/src/api/system/user.js | 41 + shierfeng-fastapi-app/src/auto-imports.d.ts | 109 ++ shierfeng-fastapi-app/src/config.js | 26 + shierfeng-fastapi-app/src/env.d.ts | 8 + shierfeng-fastapi-app/src/main.ts | 17 + shierfeng-fastapi-app/src/manifest.json | 70 + shierfeng-fastapi-app/src/pages.json | 132 ++ .../src/pages/common/agreement/index.vue | 63 + .../src/pages/common/privacy/index.vue | 87 ++ .../src/pages/common/textview/index.vue | 37 + .../src/pages/common/webview/index.vue | 33 + shierfeng-fastapi-app/src/pages/index.vue | 208 +++ shierfeng-fastapi-app/src/pages/login.vue | 224 +++ .../src/pages/mine/about/index.vue | 68 + .../src/pages/mine/avatar/index.vue | 678 +++++++++ .../src/pages/mine/help/index.vue | 91 ++ .../src/pages/mine/index.vue | 209 +++ .../src/pages/mine/info/edit.vue | 145 ++ .../src/pages/mine/info/index.vue | 121 ++ .../src/pages/mine/pwd/index.vue | 90 ++ .../src/pages/mine/setting/index.vue | 87 ++ shierfeng-fastapi-app/src/pages/register.vue | 206 +++ .../src/pages/work/index.vue | 235 +++ shierfeng-fastapi-app/src/permission.js | 43 + shierfeng-fastapi-app/src/plugins/auth.js | 60 + shierfeng-fastapi-app/src/plugins/index.js | 12 + shierfeng-fastapi-app/src/plugins/modal.js | 78 + shierfeng-fastapi-app/src/plugins/tab.js | 30 + shierfeng-fastapi-app/src/static/favicon.ico | Bin 0 -> 16958 bytes .../src/static/images/banner/banner01.jpg | Bin 0 -> 39640 bytes .../src/static/images/banner/banner02.jpg | Bin 0 -> 37061 bytes .../src/static/images/banner/banner03.jpg | Bin 0 -> 38123 bytes .../src/static/images/profile.jpg | Bin 0 -> 8997 bytes .../src/static/images/tabbar/home.png | Bin 0 -> 3265 bytes .../src/static/images/tabbar/home_.png | Bin 0 -> 3229 bytes .../src/static/images/tabbar/mine.png | Bin 0 -> 4235 bytes .../src/static/images/tabbar/mine_.png | Bin 0 -> 4219 bytes .../src/static/images/tabbar/work.png | Bin 0 -> 4108 bytes .../src/static/images/tabbar/work_.png | Bin 0 -> 5008 bytes shierfeng-fastapi-app/src/static/logo.png | Bin 0 -> 3616 bytes shierfeng-fastapi-app/src/static/logo200.png | Bin 0 -> 7995 bytes shierfeng-fastapi-app/src/store/index.js | 9 + .../src/store/modules/config.js | 13 + .../src/store/modules/dict.js | 56 + .../src/store/modules/user.js | 125 ++ shierfeng-fastapi-app/src/theme.json | 10 + shierfeng-fastapi-app/src/uni.scss | 0 shierfeng-fastapi-app/src/utils/auth.js | 13 + shierfeng-fastapi-app/src/utils/common.js | 58 + shierfeng-fastapi-app/src/utils/constant.js | 9 + shierfeng-fastapi-app/src/utils/dict.js | 30 + shierfeng-fastapi-app/src/utils/errorCode.js | 6 + shierfeng-fastapi-app/src/utils/permission.js | 55 + shierfeng-fastapi-app/src/utils/request.js | 131 ++ shierfeng-fastapi-app/src/utils/storage.js | 38 + .../src/utils/transportCrypto.js | 1236 +++++++++++++++ .../src/utils/transportCryptoPolicy.js | 436 ++++++ .../src/utils/transportForge.js | 214 +++ shierfeng-fastapi-app/src/utils/upload.js | 72 + shierfeng-fastapi-app/src/utils/validate.js | 124 ++ shierfeng-fastapi-app/stylelint.config.mjs | 3 + shierfeng-fastapi-app/tailwind.config.ts | 53 + shierfeng-fastapi-app/tsconfig.json | 22 + shierfeng-fastapi-app/vite.config.ts | 70 + shierfeng-fastapi-backend/.env.dev | 173 +++ shierfeng-fastapi-backend/.env.dockermy | 173 +++ shierfeng-fastapi-backend/.env.dockerpg | 173 +++ shierfeng-fastapi-backend/.env.prod | 173 +++ shierfeng-fastapi-backend/Dockerfile.my | 14 + shierfeng-fastapi-backend/Dockerfile.pg | 14 + shierfeng-fastapi-backend/alembic.ini | 153 ++ shierfeng-fastapi-backend/alembic/README | 1 + shierfeng-fastapi-backend/alembic/env.py | 135 ++ .../alembic/script.py.mako | 28 + shierfeng-fastapi-backend/app.py | 18 + .../assets/font/Arial.ttf | Bin 0 -> 154600 bytes shierfeng-fastapi-backend/cli/__init__.py | 0 shierfeng-fastapi-backend/cli/bootstrap.py | 56 + .../cli/completion/__init__.py | 0 .../cli/completion/commands.py | 155 ++ .../cli/completion/controller.py | 116 ++ .../cli/completion/doctor.py | 95 ++ .../cli/completion/installers.py | 465 ++++++ .../cli/completion/presenter.py | 83 + .../cli/completion/providers.py | 1022 +++++++++++++ .../cli/completion/shells.py | 113 ++ shierfeng-fastapi-backend/cli/context.py | 74 + .../cli/core/__init__.py | 51 + .../cli/core/app_builder.py | 363 +++++ .../cli/core/completion_dispatcher.py | 121 ++ .../cli/core/context_factory.py | 239 +++ .../cli/core/execution.py | 137 ++ shierfeng-fastapi-backend/cli/exit_codes.py | 8 + .../cli/groups/__init__.py | 0 .../cli/groups/app/__init__.py | 3 + .../cli/groups/app/command.py | 102 ++ .../cli/groups/app/controller.py | 236 +++ .../cli/groups/app/presenter.py | 210 +++ .../cli/groups/cache/__init__.py | 3 + .../cli/groups/cache/command.py | 163 ++ .../cli/groups/cache/controller.py | 183 +++ .../cli/groups/cache/presenter.py | 150 ++ .../cli/groups/config/__init__.py | 3 + .../cli/groups/config/command.py | 173 +++ .../cli/groups/config/controller.py | 225 +++ .../cli/groups/config/presenter.py | 180 +++ .../cli/groups/crypto/__init__.py | 3 + .../cli/groups/crypto/command.py | 94 ++ .../cli/groups/crypto/controller.py | 125 ++ .../cli/groups/crypto/presenter.py | 79 + .../cli/groups/db/__init__.py | 3 + .../cli/groups/db/command.py | 191 +++ .../cli/groups/db/controller.py | 237 +++ .../cli/groups/db/presenter.py | 71 + .../cli/groups/dev/__init__.py | 3 + .../cli/groups/dev/command.py | 74 + .../cli/groups/dev/controller.py | 103 ++ .../cli/groups/dev/presenter.py | 96 ++ .../cli/groups/gen/__init__.py | 3 + .../cli/groups/gen/command.py | 273 ++++ .../cli/groups/gen/controller.py | 304 ++++ .../cli/groups/gen/presenter.py | 231 +++ .../cli/groups/job/__init__.py | 3 + .../cli/groups/job/command.py | 216 +++ .../cli/groups/job/controller.py | 221 +++ .../cli/groups/job/presenter.py | 197 +++ .../cli/groups/ops/__init__.py | 3 + .../cli/groups/ops/command.py | 89 ++ .../cli/groups/ops/controller.py | 137 ++ .../cli/groups/ops/presenter.py | 177 +++ shierfeng-fastapi-backend/cli/guards.py | 210 +++ shierfeng-fastapi-backend/cli/main.py | 100 ++ .../cli/metadata/__init__.py | 10 + .../cli/metadata/command_specs.py | 98 ++ .../cli/metadata/option_specs.py | 36 + .../cli/metadata/risk_specs.py | 75 + shierfeng-fastapi-backend/cli/output.py | 873 +++++++++++ .../cli/runtime/__init__.py | 3 + .../cli/runtime/app/__init__.py | 3 + .../cli/runtime/app/gateway.py | 29 + .../cli/runtime/app/service.py | 69 + .../cli/runtime/app/support.py | 90 ++ shierfeng-fastapi-backend/cli/runtime/base.py | 72 + .../cli/runtime/cache/__init__.py | 3 + .../cli/runtime/cache/gateway.py | 41 + .../cli/runtime/cache/service.py | 216 +++ .../cli/runtime/cache/support.py | 201 +++ .../cli/runtime/config/__init__.py | 3 + .../cli/runtime/config/gateway.py | 101 ++ .../cli/runtime/config/service.py | 406 +++++ .../cli/runtime/config/support.py | 191 +++ .../cli/runtime/crypto/__init__.py | 3 + .../cli/runtime/crypto/gateway.py | 56 + .../cli/runtime/crypto/service.py | 141 ++ .../cli/runtime/crypto/support.py | 234 +++ .../cli/runtime/db/__init__.py | 3 + .../cli/runtime/db/gateway.py | 56 + .../cli/runtime/db/service.py | 199 +++ .../cli/runtime/db/support.py | 160 ++ .../cli/runtime/dev/__init__.py | 3 + .../cli/runtime/dev/gateway.py | 60 + .../cli/runtime/dev/service.py | 158 ++ .../cli/runtime/dev/support.py | 118 ++ .../cli/runtime/gen/__init__.py | 3 + .../cli/runtime/gen/gateway.py | 110 ++ .../cli/runtime/gen/service.py | 412 +++++ .../cli/runtime/gen/support.py | 191 +++ .../cli/runtime/job/__init__.py | 3 + .../cli/runtime/job/gateway.py | 74 + .../cli/runtime/job/service.py | 246 +++ .../cli/runtime/job/support.py | 173 +++ .../cli/runtime/ops/__init__.py | 3 + .../cli/runtime/ops/gateway.py | 65 + .../cli/runtime/ops/service.py | 132 ++ .../cli/runtime/ops/support.py | 241 +++ shierfeng-fastapi-backend/cli/tui/__init__.py | 3 + .../cli/tui/actions/__init__.py | 10 + .../cli/tui/actions/assembly.py | 103 ++ .../cli/tui/actions/bootstrap.py | 31 + .../cli/tui/actions/builders.py | 329 ++++ .../cli/tui/actions/execution.py | 121 ++ .../cli/tui/actions/factories/__init__.py | 0 .../cli/tui/actions/factories/cache.py | 83 + .../cli/tui/actions/factories/gen.py | 254 ++++ .../cli/tui/actions/factories/jobs.py | 171 +++ .../cli/tui/actions/factories/static.py | 385 +++++ .../cli/tui/actions/models.py | 72 + .../cli/tui/actions/presentation.py | 110 ++ .../cli/tui/actions/registry.py | 103 ++ .../cli/tui/adapters/__init__.py | 98 ++ .../cli/tui/adapters/app.py | 680 +++++++++ .../cli/tui/adapters/base.py | 219 +++ .../cli/tui/adapters/cache.py | 579 +++++++ .../cli/tui/adapters/configs.py | 760 ++++++++++ .../cli/tui/adapters/crypto.py | 372 +++++ .../cli/tui/adapters/database.py | 474 ++++++ .../cli/tui/adapters/gen.py | 807 ++++++++++ .../cli/tui/adapters/health.py | 988 ++++++++++++ .../cli/tui/adapters/jobs.py | 747 +++++++++ .../cli/tui/adapters/models.py | 227 +++ .../cli/tui/adapters/ops.py | 482 ++++++ shierfeng-fastapi-backend/cli/tui/app.py | 898 +++++++++++ .../cli/tui/capabilities.py | 209 +++ shierfeng-fastapi-backend/cli/tui/commands.py | 145 ++ .../cli/tui/copy/__init__.py | 23 + .../cli/tui/copy/actions.py | 298 ++++ .../cli/tui/copy/fragments.py | 339 +++++ .../cli/tui/copy/navigation.py | 133 ++ .../cli/tui/copy/workspace.py | 593 ++++++++ .../cli/tui/diagnostics.py | 581 +++++++ shierfeng-fastapi-backend/cli/tui/keymaps.py | 177 +++ .../cli/tui/screens/__init__.py | 5 + .../cli/tui/screens/browser.py | 840 +++++++++++ .../cli/tui/screens/confirm.py | 128 ++ .../cli/tui/screens/dashboard.py | 253 ++++ .../cli/tui/screens/detail.py | 413 +++++ .../cli/tui/screens/focus.py | 152 ++ .../cli/tui/screens/interactions.py | 331 ++++ .../cli/tui/screens/search.py | 80 + .../cli/tui/screens/summary.py | 59 + shierfeng-fastapi-backend/cli/tui/search.py | 375 +++++ .../cli/tui/widgets/__init__.py | 27 + .../cli/tui/widgets/status_panel.py | 323 ++++ .../cli/tui/widgets/workspace.py | 1262 ++++++++++++++++ shierfeng-fastapi-backend/cli/utils.py | 366 +++++ .../cli/wizard/__init__.py | 0 .../cli/wizard/aggregators.py | 122 ++ shierfeng-fastapi-backend/cli/wizard/base.py | 909 +++++++++++ .../cli/wizard/commands.py | 316 ++++ .../cli/wizard/flows/__init__.py | 0 .../cli/wizard/flows/app_run.py | 152 ++ .../cli/wizard/flows/cache_clear.py | 213 +++ .../cli/wizard/flows/db_upgrade.py | 169 +++ .../cli/wizard/flows/gen_export.py | 239 +++ .../cli/wizard/flows/gen_import.py | 208 +++ .../cli/wizard/flows/prod_check.py | 224 +++ .../cli/wizard/presenters.py | 95 ++ .../cli/wizard/preview.py | 65 + .../cli/wizard/prompts.py | 86 ++ .../common/annotation/cache_annotation.py | 502 +++++++ .../common/annotation/log_annotation.py | 1078 +++++++++++++ .../common/annotation/pydantic_annotation.py | 86 ++ .../annotation/rate_limit_annotation.py | 720 +++++++++ .../common/aspect/data_scope.py | 112 ++ .../common/aspect/db_seesion.py | 12 + .../common/aspect/interface_auth.py | 94 ++ .../common/aspect/pre_auth.py | 146 ++ shierfeng-fastapi-backend/common/constant.py | 856 +++++++++++ shierfeng-fastapi-backend/common/context.py | 96 ++ shierfeng-fastapi-backend/common/enums.py | 79 + shierfeng-fastapi-backend/common/router.py | 393 +++++ shierfeng-fastapi-backend/common/vo.py | 105 ++ shierfeng-fastapi-backend/config/database.py | 109 ++ shierfeng-fastapi-backend/config/env.py | 334 ++++ shierfeng-fastapi-backend/config/get_db.py | 37 + shierfeng-fastapi-backend/config/get_redis.py | 107 ++ .../config/get_scheduler.py | 940 ++++++++++++ shierfeng-fastapi-backend/docs/cli_usage.md | 841 +++++++++++ .../docs/transport_crypto_config.md | 129 ++ .../exceptions/exception.py | 58 + .../exceptions/handle.py | 72 + .../api_response_header_middleware.py | 28 + .../middlewares/context_middleware.py | 29 + .../middlewares/cors_middleware.py | 28 + .../middlewares/demo_mode_middleware.py | 63 + .../middlewares/gzip_middleware.py | 12 + .../middlewares/handle.py | 31 + .../middlewares/trace_middleware/__init__.py | 18 + .../middlewares/trace_middleware/ctx.py | 64 + .../middlewares/trace_middleware/middle.py | 46 + .../middlewares/trace_middleware/span.py | 54 + .../transport_crypto_middleware.py | 757 ++++++++++ .../controller/cache_controller.py | 129 ++ .../controller/captcha_controller.py | 46 + .../controller/common_controller.py | 77 + .../controller/config_controller.py | 201 +++ .../controller/dept_controller.py | 175 +++ .../controller/dict_controller.py | 377 +++++ .../module_admin/controller/job_controller.py | 318 ++++ .../module_admin/controller/log_controller.py | 241 +++ .../controller/login_controller.py | 192 +++ .../controller/menu_controller.py | 173 +++ .../controller/notice_controller.py | 135 ++ .../controller/online_controller.py | 63 + .../controller/post_controller.py | 168 +++ .../controller/role_controller.py | 400 +++++ .../controller/server_controller.py | 32 + .../controller/transport_crypto_controller.py | 78 + .../controller/user_controller.py | 550 +++++++ .../module_admin/dao/config_dao.py | 124 ++ .../module_admin/dao/dept_dao.py | 315 ++++ .../module_admin/dao/dict_dao.py | 294 ++++ .../module_admin/dao/job_dao.py | 156 ++ .../module_admin/dao/job_log_dao.py | 87 ++ .../module_admin/dao/log_dao.py | 183 +++ .../module_admin/dao/login_dao.py | 29 + .../module_admin/dao/menu_dao.py | 228 +++ .../module_admin/dao/notice_dao.py | 125 ++ .../module_admin/dao/post_dao.py | 151 ++ .../module_admin/dao/role_dao.py | 345 +++++ .../module_admin/dao/user_dao.py | 587 ++++++++ .../module_admin/entity/do/config_do.py | 32 + .../module_admin/entity/do/dept_do.py | 46 + .../module_admin/entity/do/dict_do.py | 70 + .../module_admin/entity/do/job_do.py | 58 + .../module_admin/entity/do/log_do.py | 60 + .../module_admin/entity/do/menu_do.py | 52 + .../module_admin/entity/do/notice_do.py | 38 + .../module_admin/entity/do/post_do.py | 32 + .../module_admin/entity/do/role_do.py | 76 + .../module_admin/entity/do/user_do.py | 71 + .../module_admin/entity/vo/cache_vo.py | 29 + .../module_admin/entity/vo/common_vo.py | 15 + .../module_admin/entity/vo/config_vo.py | 73 + .../module_admin/entity/vo/dept_vo.py | 87 ++ .../module_admin/entity/vo/dict_vo.py | 147 ++ .../module_admin/entity/vo/job_vo.py | 132 ++ .../module_admin/entity/vo/log_vo.py | 127 ++ .../module_admin/entity/vo/login_vo.py | 93 ++ .../module_admin/entity/vo/menu_vo.py | 100 ++ .../module_admin/entity/vo/notice_vo.py | 62 + .../module_admin/entity/vo/online_vo.py | 51 + .../module_admin/entity/vo/post_vo.py | 72 + .../module_admin/entity/vo/role_vo.py | 157 ++ .../module_admin/entity/vo/server_vo.py | 66 + .../entity/vo/transport_crypto_vo.py | 99 ++ .../module_admin/entity/vo/user_vo.py | 299 ++++ .../module_admin/service/cache_service.py | 126 ++ .../module_admin/service/captcha_service.py | 54 + .../module_admin/service/common_service.py | 99 ++ .../module_admin/service/config_service.py | 234 +++ .../module_admin/service/dept_service.py | 285 ++++ .../module_admin/service/dict_service.py | 464 ++++++ .../module_admin/service/job_log_service.py | 144 ++ .../module_admin/service/job_service.py | 273 ++++ .../module_admin/service/log_service.py | 508 +++++++ .../module_admin/service/login_service.py | 656 ++++++++ .../module_admin/service/menu_service.py | 217 +++ .../module_admin/service/notice_service.py | 126 ++ .../module_admin/service/online_service.py | 78 + .../module_admin/service/post_service.py | 180 +++ .../module_admin/service/role_service.py | 368 +++++ .../module_admin/service/server_service.py | 103 ++ .../service/transport_crypto_service.py | 44 + .../module_admin/service/user_service.py | 679 +++++++++ .../controller/ai_chat_controller.py | 164 ++ .../controller/ai_model_controller.py | 188 +++ .../module_ai/dao/ai_chat_dao.py | 52 + .../module_ai/dao/ai_model_dao.py | 96 ++ .../module_ai/entity/do/ai_chat_do.py | 26 + .../module_ai/entity/do/ai_model_do.py | 62 + .../module_ai/entity/vo/ai_chat_vo.py | 159 ++ .../module_ai/entity/vo/ai_model_vo.py | 64 + .../module_ai/service/ai_chat_service.py | 483 ++++++ .../module_ai/service/ai_model_service.py | 155 ++ .../controller/gen_controller.py | 297 ++++ .../module_generator/dao/gen_dao.py | 404 +++++ .../module_generator/entity/do/gen_do.py | 106 ++ .../module_generator/entity/vo/gen_vo.py | 294 ++++ .../module_generator/service/gen_service.py | 506 +++++++ .../templates/js/api.js.jinja2 | 44 + .../templates/python/controller.py.jinja2 | 191 +++ .../templates/python/dao.py.jinja2 | 221 +++ .../templates/python/do.py.jinja2 | 43 + .../templates/python/service.py.jinja2 | 210 +++ .../templates/python/vo.py.jinja2 | 172 +++ .../module_generator/templates/sql/sql.jinja2 | 50 + .../templates/vue/index-tree.vue.jinja2 | 496 ++++++ .../templates/vue/index.vue.jinja2 | 591 ++++++++ .../templates/vue/v3/index-tree.vue.jinja2 | 463 ++++++ .../templates/vue/v3/index.vue.jinja2 | 580 +++++++ .../module_task/__init__.py | 1 + .../module_task/scheduler_test.py | 19 + shierfeng-fastapi-backend/pyproject.toml | 15 + shierfeng-fastapi-backend/requirements-pg.txt | 35 + shierfeng-fastapi-backend/requirements.txt | 35 + shierfeng-fastapi-backend/ruff.toml | 68 + shierfeng-fastapi-backend/server.py | 168 +++ .../sql/shierfeng-fastapi-pg.sql | 1180 +++++++++++++++ .../sql/shierfeng-fastapi.sql | 813 ++++++++++ .../sub_applications/handle.py | 11 + .../sub_applications/staticfiles.py | 11 + .../tests/cli/completion/test_installers.py | 112 ++ .../tests/cli/completion/test_providers.py | 442 ++++++ .../tests/cli/core/test_execution_service.py | 74 + .../tests/cli/root/conftest.py | 93 ++ .../tests/cli/root/test_bootstrap.py | 184 +++ .../tests/cli/root/test_contract_app_ops.py | 311 ++++ .../tests/cli/root/test_contract_cli.py | 207 +++ .../tests/cli/root/test_contract_config.py | 40 + .../cli/root/test_contract_db_cache_dev.py | 235 +++ .../tests/cli/root/test_contract_job_gen.py | 368 +++++ .../tests/cli/root/test_guards.py | 69 + .../cli/root/test_json_output_suppression.py | 18 + .../tests/cli/root/test_main.py | 13 + .../tests/cli/root/test_output.py | 181 +++ .../tests/cli/root/test_utils.py | 142 ++ .../tests/cli/runtime/test_app_runtime.py | 151 ++ .../tests/cli/runtime/test_base.py | 44 + .../tests/cli/runtime/test_cache.py | 164 ++ .../tests/cli/runtime/test_config.py | 210 +++ .../tests/cli/runtime/test_crypto.py | 149 ++ .../tests/cli/runtime/test_db.py | 144 ++ .../tests/cli/runtime/test_dev.py | 126 ++ .../tests/cli/runtime/test_gen.py | 98 ++ .../tests/cli/runtime/test_job.py | 174 +++ .../tests/cli/runtime/test_ops.py | 167 ++ .../cli/tui/actions/test_action_execution.py | 579 +++++++ .../tests/cli/tui/adapters/conftest.py | 87 ++ .../tui/adapters/test_app_ops_snapshots.py | 345 +++++ .../test_cache_db_crypto_snapshots.py | 346 +++++ .../test_configs_dashboard_snapshots.py | 494 ++++++ .../tui/adapters/test_jobs_gen_snapshots.py | 439 ++++++ .../tests/cli/tui/conftest.py | 64 + .../tests/cli/tui/test_tui_browser_screen.py | 575 +++++++ .../tests/cli/tui/test_tui_command_app.py | 490 ++++++ .../cli/tui/test_tui_dashboard_search.py | 192 +++ .../tests/cli/tui/test_tui_detail_screen.py | 394 +++++ .../tests/cli/wizard/conftest.py | 68 + .../tests/cli/wizard/test_app_run.py | 33 + .../tests/cli/wizard/test_cache_clear.py | 88 ++ .../tests/cli/wizard/test_commands.py | 24 + .../tests/cli/wizard/test_db_upgrade.py | 99 ++ .../tests/cli/wizard/test_gen_export.py | 120 ++ .../tests/cli/wizard/test_gen_import.py | 164 ++ .../tests/cli/wizard/test_prod_check.py | 110 ++ .../tests/test_log_sanitize_util.py | 865 +++++++++++ shierfeng-fastapi-backend/utils/ai_util.py | 172 +++ .../utils/api_annotation_util.py | 102 ++ .../utils/api_response_header_util.py | 25 + .../utils/client_ip_util.py | 60 + .../utils/common_util.py | 403 +++++ shierfeng-fastapi-backend/utils/cron_util.py | 166 ++ .../utils/crypto_util.py | 58 + .../utils/dependency_util.py | 49 + shierfeng-fastapi-backend/utils/excel_util.py | 104 ++ shierfeng-fastapi-backend/utils/gen_util.py | 226 +++ .../utils/import_util.py | 123 ++ shierfeng-fastapi-backend/utils/log_util.py | 675 +++++++++ .../utils/message_util.py | 5 + shierfeng-fastapi-backend/utils/page_util.py | 105 ++ shierfeng-fastapi-backend/utils/pwd_util.py | 30 + .../utils/response_util.py | 315 ++++ .../utils/server_util.py | 496 ++++++ .../utils/string_util.py | 165 ++ .../utils/template_util.py | 473 ++++++ .../utils/time_format_util.py | 142 ++ .../utils/transport_crypto_util.py | 1284 ++++++++++++++++ .../utils/upload_util.py | 108 ++ shierfeng-fastapi-frontend/.env.development | 8 + shierfeng-fastapi-frontend/.env.docker | 11 + shierfeng-fastapi-frontend/.env.production | 11 + shierfeng-fastapi-frontend/.env.staging | 11 + shierfeng-fastapi-frontend/.gitignore | 23 + shierfeng-fastapi-frontend/Dockerfile | 28 + shierfeng-fastapi-frontend/LICENSE | 20 + shierfeng-fastapi-frontend/bin/build.bat | 12 + .../bin/nginx.dockermy.conf | 24 + .../bin/nginx.dockerpg.conf | 24 + shierfeng-fastapi-frontend/bin/package.bat | 12 + shierfeng-fastapi-frontend/bin/run-web.bat | 12 + shierfeng-fastapi-frontend/html/ie.html | 46 + shierfeng-fastapi-frontend/index.html | 215 +++ shierfeng-fastapi-frontend/package.json | 65 + shierfeng-fastapi-frontend/public/favicon.ico | Bin 0 -> 5663 bytes shierfeng-fastapi-frontend/src/App.vue | 15 + shierfeng-fastapi-frontend/src/api/ai/chat.js | 53 + .../src/api/ai/model.js | 52 + shierfeng-fastapi-frontend/src/api/login.js | 61 + shierfeng-fastapi-frontend/src/api/menu.js | 9 + .../src/api/monitor/cache.js | 57 + .../src/api/monitor/job.js | 71 + .../src/api/monitor/jobLog.js | 26 + .../src/api/monitor/logininfor.js | 34 + .../src/api/monitor/online.js | 18 + .../src/api/monitor/operlog.js | 26 + .../src/api/monitor/server.js | 9 + .../src/api/monitor/transportCrypto.js | 9 + .../src/api/system/config.js | 60 + .../src/api/system/dept.js | 52 + .../src/api/system/dict/data.js | 52 + .../src/api/system/dict/type.js | 60 + .../src/api/system/menu.js | 60 + .../src/api/system/notice.js | 44 + .../src/api/system/post.js | 44 + .../src/api/system/role.js | 119 ++ .../src/api/system/user.js | 136 ++ .../src/api/tool/gen.js | 85 ++ .../src/assets/401_images/401.gif | Bin 0 -> 164227 bytes .../src/assets/404_images/404.png | Bin 0 -> 98071 bytes .../src/assets/404_images/404_cloud.png | Bin 0 -> 4766 bytes .../src/assets/icons/svg/404.svg | 1 + .../src/assets/icons/svg/ai-chat.svg | 1 + .../src/assets/icons/svg/ai-manage.svg | 1 + .../src/assets/icons/svg/ai-model.svg | 1 + .../src/assets/icons/svg/bug.svg | 1 + .../src/assets/icons/svg/build.svg | 1 + .../src/assets/icons/svg/button.svg | 1 + .../src/assets/icons/svg/cascader.svg | 1 + .../src/assets/icons/svg/chart.svg | 1 + .../src/assets/icons/svg/checkbox.svg | 1 + .../src/assets/icons/svg/clipboard.svg | 1 + .../src/assets/icons/svg/code.svg | 1 + .../src/assets/icons/svg/color.svg | 1 + .../src/assets/icons/svg/component.svg | 1 + .../src/assets/icons/svg/dashboard.svg | 1 + .../src/assets/icons/svg/date-range.svg | 1 + .../src/assets/icons/svg/date.svg | 1 + .../src/assets/icons/svg/deepthink.svg | 1 + .../src/assets/icons/svg/dict.svg | 1 + .../src/assets/icons/svg/documentation.svg | 1 + .../src/assets/icons/svg/download.svg | 1 + .../src/assets/icons/svg/drag.svg | 1 + .../src/assets/icons/svg/druid.svg | 1 + .../src/assets/icons/svg/edit.svg | 1 + .../src/assets/icons/svg/education.svg | 1 + .../src/assets/icons/svg/email.svg | 1 + .../src/assets/icons/svg/enter.svg | 1 + .../src/assets/icons/svg/example.svg | 1 + .../src/assets/icons/svg/excel.svg | 1 + .../src/assets/icons/svg/exit-fullscreen.svg | 1 + .../src/assets/icons/svg/eye-open.svg | 1 + .../src/assets/icons/svg/eye.svg | 1 + .../src/assets/icons/svg/form.svg | 1 + .../src/assets/icons/svg/fullscreen.svg | 1 + .../src/assets/icons/svg/github.svg | 1 + .../src/assets/icons/svg/guide.svg | 1 + .../src/assets/icons/svg/icon.svg | 1 + .../src/assets/icons/svg/input.svg | 1 + .../src/assets/icons/svg/international.svg | 1 + .../src/assets/icons/svg/job.svg | 1 + .../src/assets/icons/svg/language.svg | 1 + .../src/assets/icons/svg/link.svg | 1 + .../src/assets/icons/svg/list.svg | 1 + .../src/assets/icons/svg/lock.svg | 1 + .../src/assets/icons/svg/log.svg | 1 + .../src/assets/icons/svg/logininfor.svg | 1 + .../src/assets/icons/svg/message.svg | 1 + .../src/assets/icons/svg/money.svg | 1 + .../src/assets/icons/svg/monitor.svg | 2 + .../src/assets/icons/svg/moon.svg | 1 + .../src/assets/icons/svg/more-up.svg | 1 + .../src/assets/icons/svg/nested.svg | 1 + .../src/assets/icons/svg/number.svg | 1 + .../src/assets/icons/svg/online.svg | 1 + .../src/assets/icons/svg/password.svg | 1 + .../src/assets/icons/svg/pdf.svg | 1 + .../src/assets/icons/svg/people.svg | 1 + .../src/assets/icons/svg/peoples.svg | 1 + .../src/assets/icons/svg/phone.svg | 1 + .../src/assets/icons/svg/post.svg | 1 + .../src/assets/icons/svg/qq.svg | 1 + .../src/assets/icons/svg/question.svg | 1 + .../src/assets/icons/svg/radio.svg | 1 + .../src/assets/icons/svg/rate.svg | 1 + .../src/assets/icons/svg/redis-list.svg | 2 + .../src/assets/icons/svg/redis.svg | 1 + .../src/assets/icons/svg/row.svg | 1 + .../src/assets/icons/svg/search.svg | 1 + .../src/assets/icons/svg/select.svg | 1 + .../src/assets/icons/svg/server.svg | 1 + .../src/assets/icons/svg/shopping.svg | 1 + .../src/assets/icons/svg/size.svg | 1 + .../src/assets/icons/svg/skill.svg | 1 + .../src/assets/icons/svg/slider.svg | 1 + .../src/assets/icons/svg/star.svg | 1 + .../src/assets/icons/svg/sunny.svg | 1 + .../src/assets/icons/svg/swagger.svg | 1 + .../src/assets/icons/svg/switch.svg | 1 + .../src/assets/icons/svg/system.svg | 2 + .../src/assets/icons/svg/tab.svg | 1 + .../src/assets/icons/svg/table.svg | 1 + .../src/assets/icons/svg/textarea.svg | 1 + .../src/assets/icons/svg/theme.svg | 1 + .../src/assets/icons/svg/time-range.svg | 1 + .../src/assets/icons/svg/time.svg | 1 + .../src/assets/icons/svg/tool.svg | 1 + .../src/assets/icons/svg/tree-table.svg | 1 + .../src/assets/icons/svg/tree.svg | 1 + .../src/assets/icons/svg/upload.svg | 1 + .../src/assets/icons/svg/user.svg | 1 + .../src/assets/icons/svg/validCode.svg | 1 + .../src/assets/icons/svg/wechat.svg | 1 + .../src/assets/icons/svg/zip.svg | 1 + .../src/assets/images/dark.svg | 39 + .../src/assets/images/light.svg | 39 + .../src/assets/images/login-background.jpg | Bin 0 -> 521275 bytes .../src/assets/images/profile.jpg | Bin 0 -> 8997 bytes .../src/assets/logo/logo.png | Bin 0 -> 5663 bytes .../src/assets/styles/btn.scss | 99 ++ .../src/assets/styles/element-ui.scss | 96 ++ .../src/assets/styles/index.scss | 179 +++ .../src/assets/styles/mixin.scss | 66 + .../src/assets/styles/shierfeng.scss | 308 ++++ .../src/assets/styles/sidebar.scss | 238 +++ .../src/assets/styles/transition.scss | 80 + .../src/assets/styles/variables.module.scss | 271 ++++ .../src/components/Breadcrumb/index.vue | 97 ++ .../src/components/Crontab/day.vue | 174 +++ .../src/components/Crontab/hour.vue | 133 ++ .../src/components/Crontab/index.vue | 313 ++++ .../src/components/Crontab/min.vue | 126 ++ .../src/components/Crontab/month.vue | 141 ++ .../src/components/Crontab/result.vue | 540 +++++++ .../src/components/Crontab/second.vue | 128 ++ .../src/components/Crontab/week.vue | 197 +++ .../src/components/Crontab/year.vue | 143 ++ .../src/components/DictTag/index.vue | 87 ++ .../src/components/Editor/index.vue | 276 ++++ .../src/components/FileUpload/index.vue | 257 ++++ .../src/components/Hamburger/index.vue | 42 + .../src/components/HeaderSearch/index.vue | 252 ++++ .../src/components/IconSelect/index.vue | 111 ++ .../src/components/IconSelect/requireIcons.js | 8 + .../src/components/ImagePreview/index.vue | 92 ++ .../src/components/ImageUpload/index.vue | 258 ++++ .../src/components/Pagination/index.vue | 104 ++ .../src/components/ParentView/index.vue | 3 + .../src/components/RightToolbar/index.vue | 181 +++ .../src/components/Screenfull/index.vue | 22 + .../src/components/ShiErFeng/Doc/index.vue | 13 + .../src/components/ShiErFeng/Git/index.vue | 13 + .../src/components/SizeSelect/index.vue | 45 + .../src/components/SvgIcon/index.vue | 53 + .../src/components/SvgIcon/svgicon.js | 10 + .../src/components/TopNav/index.vue | 214 +++ .../src/components/iFrame/index.vue | 31 + .../src/directive/common/copyText.js | 65 + .../src/directive/index.js | 9 + .../src/directive/permission/hasPermi.js | 27 + .../src/directive/permission/hasRole.js | 27 + .../src/layout/components/AppMain.vue | 124 ++ .../src/layout/components/Copyright/index.vue | 31 + .../layout/components/IframeToggle/index.vue | 25 + .../src/layout/components/InnerLink/index.vue | 35 + .../src/layout/components/Navbar.vue | 292 ++++ .../src/layout/components/Settings/index.vue | 315 ++++ .../src/layout/components/Sidebar/Link.vue | 40 + .../src/layout/components/Sidebar/Logo.vue | 102 ++ .../layout/components/Sidebar/SidebarItem.vue | 100 ++ .../src/layout/components/Sidebar/index.vue | 104 ++ .../layout/components/TagsView/ScrollPane.vue | 105 ++ .../src/layout/components/TagsView/index.vue | 350 +++++ .../src/layout/components/TopBar/index.vue | 99 ++ .../src/layout/components/index.js | 4 + .../src/layout/index.vue | 117 ++ shierfeng-fastapi-frontend/src/main.js | 84 ++ shierfeng-fastapi-frontend/src/permission.js | 69 + .../src/plugins/auth.js | 60 + .../src/plugins/cache.js | 79 + .../src/plugins/download.js | 79 + .../src/plugins/index.js | 18 + .../src/plugins/modal.js | 82 + shierfeng-fastapi-frontend/src/plugins/tab.js | 71 + .../src/router/index.js | 174 +++ shierfeng-fastapi-frontend/src/settings.js | 56 + shierfeng-fastapi-frontend/src/store/index.js | 3 + .../src/store/modules/app.js | 46 + .../src/store/modules/dict.js | 57 + .../src/store/modules/permission.js | 127 ++ .../src/store/modules/settings.js | 51 + .../src/store/modules/tagsView.js | 182 +++ .../src/store/modules/user.js | 91 ++ shierfeng-fastapi-frontend/src/utils/auth.js | 15 + shierfeng-fastapi-frontend/src/utils/dict.js | 24 + .../src/utils/dynamicTitle.js | 14 + .../src/utils/errorCode.js | 6 + .../src/utils/generator/config.js | 452 ++++++ .../src/utils/generator/css.js | 18 + .../src/utils/generator/drawingDefault.js | 37 + .../src/utils/generator/html.js | 359 +++++ .../src/utils/generator/icon.json | 1 + .../src/utils/generator/js.js | 370 +++++ .../src/utils/generator/render.js | 156 ++ shierfeng-fastapi-frontend/src/utils/index.js | 390 +++++ .../src/utils/jsencrypt.js | 30 + .../src/utils/permission.js | 51 + .../src/utils/request.js | 206 +++ .../src/utils/scroll-to.js | 58 + .../src/utils/shierfeng.js | 245 +++ shierfeng-fastapi-frontend/src/utils/theme.js | 49 + .../src/utils/transportCrypto.js | 748 +++++++++ .../src/utils/transportCryptoPolicy.js | 368 +++++ .../src/utils/validate.js | 114 ++ .../views/ai/chat/components/AiMessage.vue | 170 +++ .../src/views/ai/chat/index.vue | 1338 +++++++++++++++++ .../src/views/ai/model/index.vue | 473 ++++++ .../views/dashboard/editable-link-group.vue | 68 + .../src/views/dashboard/index.vue | 752 +++++++++ .../src/views/error/401.vue | 82 + .../src/views/error/404.vue | 227 +++ .../src/views/login.vue | 244 +++ .../src/views/monitor/cache/index.vue | 132 ++ .../src/views/monitor/cache/list.vue | 246 +++ .../src/views/monitor/druid/index.vue | 14 + .../src/views/monitor/job/index.vue | 530 +++++++ .../src/views/monitor/job/log.vue | 295 ++++ .../src/views/monitor/logininfor/index.vue | 225 +++ .../src/views/monitor/online/index.vue | 106 ++ .../src/views/monitor/operlog/index.vue | 301 ++++ .../src/views/monitor/server/index.vue | 183 +++ .../views/monitor/transportCrypto/index.vue | 1034 +++++++++++++ .../src/views/redirect/index.vue | 14 + .../src/views/register.vue | 222 +++ .../src/views/system/config/index.vue | 310 ++++ .../src/views/system/dept/index.vue | 274 ++++ .../src/views/system/dict/data.vue | 350 +++++ .../src/views/system/dict/index.vue | 312 ++++ .../src/views/system/menu/index.vue | 441 ++++++ .../src/views/system/notice/index.vue | 283 ++++ .../src/views/system/post/index.vue | 277 ++++ .../src/views/system/role/authUser.vue | 172 +++ .../src/views/system/role/index.vue | 559 +++++++ .../src/views/system/role/selectUser.vue | 138 ++ .../src/views/system/user/authRole.vue | 123 ++ .../src/views/system/user/index.vue | 901 +++++++++++ .../src/views/system/user/profile/index.vue | 94 ++ .../views/system/user/profile/resetPwd.vue | 57 + .../views/system/user/profile/userAvatar.vue | 171 +++ .../views/system/user/profile/userInfo.vue | 67 + .../src/views/tool/build/CodeTypeDialog.vue | 71 + .../src/views/tool/build/DraggableItem.vue | 68 + .../src/views/tool/build/IconsDialog.vue | 115 ++ .../src/views/tool/build/RightPanel.vue | 906 +++++++++++ .../src/views/tool/build/TreeNodeDialog.vue | 93 ++ .../src/views/tool/build/index.vue | 660 ++++++++ .../src/views/tool/gen/basicInfoForm.vue | 48 + .../src/views/tool/gen/createTable.vue | 46 + .../src/views/tool/gen/editTable.vue | 220 +++ .../src/views/tool/gen/genInfoForm.vue | 306 ++++ .../src/views/tool/gen/importTable.vue | 126 ++ .../src/views/tool/gen/index.vue | 313 ++++ .../src/views/tool/swagger/index.vue | 9 + shierfeng-fastapi-frontend/vite.config.js | 73 + .../vite/plugins/auto-import.js | 12 + .../vite/plugins/compression.js | 28 + .../vite/plugins/index.js | 43 + .../vite/plugins/setup-extend.js | 5 + .../vite/plugins/svg-icon.js | 10 + shierfeng-fastapi-test/README.md | 115 ++ shierfeng-fastapi-test/common/__init__.py | 0 .../common/base_page_test.py | 87 ++ shierfeng-fastapi-test/common/config.py | 3 + shierfeng-fastapi-test/common/login_helper.py | 57 + shierfeng-fastapi-test/disable_captcha.sql | 4 + .../docker-compose.test.my.yml | 77 + .../docker-compose.test.pg.yml | 77 + shierfeng-fastapi-test/monitor/__init__.py | 0 .../monitor/test_cache_list.py | 81 + .../monitor/test_cache_monitor.py | 48 + .../monitor/test_job_management.py | 185 +++ .../monitor/test_online_user.py | 73 + .../monitor/test_server_monitor.py | 46 + shierfeng-fastapi-test/requirements.txt | 5 + shierfeng-fastapi-test/ruff.toml | 68 + shierfeng-fastapi-test/system/__init__.py | 0 .../system/test_config_management.py | 165 ++ .../system/test_dept_management.py | 135 ++ .../system/test_dict_management.py | 201 +++ .../system/test_log_management.py | 138 ++ .../system/test_menu_management.py | 145 ++ .../system/test_notice_management.py | 165 ++ .../system/test_post_management.py | 136 ++ .../system/test_role_management.py | 148 ++ .../system/test_user_management.py | 170 +++ shierfeng-fastapi-test/test_login.py | 202 +++ shierfeng-fastapi-test/test_pages.py | 119 ++ shierfeng-fastapi-test/tool/__init__.py | 0 shierfeng-fastapi-test/tool/test_code_gen.py | 228 +++ shierfeng-fastapi-test/tool/test_swagger.py | 38 + 793 files changed, 116305 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/ShiErFeng.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shierfeng-fastapi-app/.editorconfig create mode 100644 shierfeng-fastapi-app/.gitignore create mode 100644 shierfeng-fastapi-app/.npmrc create mode 100644 shierfeng-fastapi-app/README.md create mode 100644 shierfeng-fastapi-app/index.html create mode 100644 shierfeng-fastapi-app/netlify.toml create mode 100644 shierfeng-fastapi-app/package.json create mode 100644 shierfeng-fastapi-app/platform.ts create mode 100644 shierfeng-fastapi-app/postcss.config.ts create mode 100644 shierfeng-fastapi-app/src/App.vue create mode 100644 shierfeng-fastapi-app/src/api/login.js create mode 100644 shierfeng-fastapi-app/src/api/system/dict/data.js create mode 100644 shierfeng-fastapi-app/src/api/system/dict/type.js create mode 100644 shierfeng-fastapi-app/src/api/system/user.js create mode 100644 shierfeng-fastapi-app/src/auto-imports.d.ts create mode 100644 shierfeng-fastapi-app/src/config.js create mode 100644 shierfeng-fastapi-app/src/env.d.ts create mode 100644 shierfeng-fastapi-app/src/main.ts create mode 100644 shierfeng-fastapi-app/src/manifest.json create mode 100644 shierfeng-fastapi-app/src/pages.json create mode 100644 shierfeng-fastapi-app/src/pages/common/agreement/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/common/privacy/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/common/textview/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/common/webview/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/login.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/about/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/avatar/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/help/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/info/edit.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/info/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/pwd/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/mine/setting/index.vue create mode 100644 shierfeng-fastapi-app/src/pages/register.vue create mode 100644 shierfeng-fastapi-app/src/pages/work/index.vue create mode 100644 shierfeng-fastapi-app/src/permission.js create mode 100644 shierfeng-fastapi-app/src/plugins/auth.js create mode 100644 shierfeng-fastapi-app/src/plugins/index.js create mode 100644 shierfeng-fastapi-app/src/plugins/modal.js create mode 100644 shierfeng-fastapi-app/src/plugins/tab.js create mode 100644 shierfeng-fastapi-app/src/static/favicon.ico create mode 100644 shierfeng-fastapi-app/src/static/images/banner/banner01.jpg create mode 100644 shierfeng-fastapi-app/src/static/images/banner/banner02.jpg create mode 100644 shierfeng-fastapi-app/src/static/images/banner/banner03.jpg create mode 100644 shierfeng-fastapi-app/src/static/images/profile.jpg create mode 100644 shierfeng-fastapi-app/src/static/images/tabbar/home.png create mode 100644 shierfeng-fastapi-app/src/static/images/tabbar/home_.png create mode 100644 shierfeng-fastapi-app/src/static/images/tabbar/mine.png create mode 100644 shierfeng-fastapi-app/src/static/images/tabbar/mine_.png create mode 100644 shierfeng-fastapi-app/src/static/images/tabbar/work.png create mode 100644 shierfeng-fastapi-app/src/static/images/tabbar/work_.png create mode 100644 shierfeng-fastapi-app/src/static/logo.png create mode 100644 shierfeng-fastapi-app/src/static/logo200.png create mode 100644 shierfeng-fastapi-app/src/store/index.js create mode 100644 shierfeng-fastapi-app/src/store/modules/config.js create mode 100644 shierfeng-fastapi-app/src/store/modules/dict.js create mode 100644 shierfeng-fastapi-app/src/store/modules/user.js create mode 100644 shierfeng-fastapi-app/src/theme.json create mode 100644 shierfeng-fastapi-app/src/uni.scss create mode 100644 shierfeng-fastapi-app/src/utils/auth.js create mode 100644 shierfeng-fastapi-app/src/utils/common.js create mode 100644 shierfeng-fastapi-app/src/utils/constant.js create mode 100644 shierfeng-fastapi-app/src/utils/dict.js create mode 100644 shierfeng-fastapi-app/src/utils/errorCode.js create mode 100644 shierfeng-fastapi-app/src/utils/permission.js create mode 100644 shierfeng-fastapi-app/src/utils/request.js create mode 100644 shierfeng-fastapi-app/src/utils/storage.js create mode 100644 shierfeng-fastapi-app/src/utils/transportCrypto.js create mode 100644 shierfeng-fastapi-app/src/utils/transportCryptoPolicy.js create mode 100644 shierfeng-fastapi-app/src/utils/transportForge.js create mode 100644 shierfeng-fastapi-app/src/utils/upload.js create mode 100644 shierfeng-fastapi-app/src/utils/validate.js create mode 100644 shierfeng-fastapi-app/stylelint.config.mjs create mode 100644 shierfeng-fastapi-app/tailwind.config.ts create mode 100644 shierfeng-fastapi-app/tsconfig.json create mode 100644 shierfeng-fastapi-app/vite.config.ts create mode 100644 shierfeng-fastapi-backend/.env.dev create mode 100644 shierfeng-fastapi-backend/.env.dockermy create mode 100644 shierfeng-fastapi-backend/.env.dockerpg create mode 100644 shierfeng-fastapi-backend/.env.prod create mode 100644 shierfeng-fastapi-backend/Dockerfile.my create mode 100644 shierfeng-fastapi-backend/Dockerfile.pg create mode 100644 shierfeng-fastapi-backend/alembic.ini create mode 100644 shierfeng-fastapi-backend/alembic/README create mode 100644 shierfeng-fastapi-backend/alembic/env.py create mode 100644 shierfeng-fastapi-backend/alembic/script.py.mako create mode 100644 shierfeng-fastapi-backend/app.py create mode 100644 shierfeng-fastapi-backend/assets/font/Arial.ttf create mode 100644 shierfeng-fastapi-backend/cli/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/bootstrap.py create mode 100644 shierfeng-fastapi-backend/cli/completion/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/completion/commands.py create mode 100644 shierfeng-fastapi-backend/cli/completion/controller.py create mode 100644 shierfeng-fastapi-backend/cli/completion/doctor.py create mode 100644 shierfeng-fastapi-backend/cli/completion/installers.py create mode 100644 shierfeng-fastapi-backend/cli/completion/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/completion/providers.py create mode 100644 shierfeng-fastapi-backend/cli/completion/shells.py create mode 100644 shierfeng-fastapi-backend/cli/context.py create mode 100644 shierfeng-fastapi-backend/cli/core/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/core/app_builder.py create mode 100644 shierfeng-fastapi-backend/cli/core/completion_dispatcher.py create mode 100644 shierfeng-fastapi-backend/cli/core/context_factory.py create mode 100644 shierfeng-fastapi-backend/cli/core/execution.py create mode 100644 shierfeng-fastapi-backend/cli/exit_codes.py create mode 100644 shierfeng-fastapi-backend/cli/groups/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/app/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/app/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/app/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/app/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/cache/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/cache/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/cache/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/cache/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/config/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/config/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/config/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/config/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/crypto/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/crypto/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/crypto/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/crypto/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/db/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/db/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/db/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/db/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/dev/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/dev/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/dev/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/dev/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/gen/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/gen/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/gen/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/gen/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/job/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/job/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/job/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/job/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/groups/ops/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/groups/ops/command.py create mode 100644 shierfeng-fastapi-backend/cli/groups/ops/controller.py create mode 100644 shierfeng-fastapi-backend/cli/groups/ops/presenter.py create mode 100644 shierfeng-fastapi-backend/cli/guards.py create mode 100644 shierfeng-fastapi-backend/cli/main.py create mode 100644 shierfeng-fastapi-backend/cli/metadata/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/metadata/command_specs.py create mode 100644 shierfeng-fastapi-backend/cli/metadata/option_specs.py create mode 100644 shierfeng-fastapi-backend/cli/metadata/risk_specs.py create mode 100644 shierfeng-fastapi-backend/cli/output.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/app/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/app/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/app/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/app/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/base.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/cache/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/cache/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/cache/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/cache/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/config/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/config/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/config/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/config/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/crypto/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/crypto/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/crypto/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/crypto/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/db/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/db/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/db/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/db/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/dev/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/dev/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/dev/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/dev/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/gen/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/gen/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/gen/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/gen/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/job/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/job/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/job/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/job/support.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/ops/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/ops/gateway.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/ops/service.py create mode 100644 shierfeng-fastapi-backend/cli/runtime/ops/support.py create mode 100644 shierfeng-fastapi-backend/cli/tui/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/assembly.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/bootstrap.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/builders.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/execution.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/factories/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/factories/cache.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/factories/gen.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/factories/jobs.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/factories/static.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/models.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/presentation.py create mode 100644 shierfeng-fastapi-backend/cli/tui/actions/registry.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/app.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/base.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/cache.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/configs.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/crypto.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/database.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/gen.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/health.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/jobs.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/models.py create mode 100644 shierfeng-fastapi-backend/cli/tui/adapters/ops.py create mode 100644 shierfeng-fastapi-backend/cli/tui/app.py create mode 100644 shierfeng-fastapi-backend/cli/tui/capabilities.py create mode 100644 shierfeng-fastapi-backend/cli/tui/commands.py create mode 100644 shierfeng-fastapi-backend/cli/tui/copy/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/copy/actions.py create mode 100644 shierfeng-fastapi-backend/cli/tui/copy/fragments.py create mode 100644 shierfeng-fastapi-backend/cli/tui/copy/navigation.py create mode 100644 shierfeng-fastapi-backend/cli/tui/copy/workspace.py create mode 100644 shierfeng-fastapi-backend/cli/tui/diagnostics.py create mode 100644 shierfeng-fastapi-backend/cli/tui/keymaps.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/browser.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/confirm.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/dashboard.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/detail.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/focus.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/interactions.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/search.py create mode 100644 shierfeng-fastapi-backend/cli/tui/screens/summary.py create mode 100644 shierfeng-fastapi-backend/cli/tui/search.py create mode 100644 shierfeng-fastapi-backend/cli/tui/widgets/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/tui/widgets/status_panel.py create mode 100644 shierfeng-fastapi-backend/cli/tui/widgets/workspace.py create mode 100644 shierfeng-fastapi-backend/cli/utils.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/aggregators.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/base.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/commands.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/__init__.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/app_run.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/cache_clear.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/db_upgrade.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/gen_export.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/gen_import.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/flows/prod_check.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/presenters.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/preview.py create mode 100644 shierfeng-fastapi-backend/cli/wizard/prompts.py create mode 100644 shierfeng-fastapi-backend/common/annotation/cache_annotation.py create mode 100644 shierfeng-fastapi-backend/common/annotation/log_annotation.py create mode 100644 shierfeng-fastapi-backend/common/annotation/pydantic_annotation.py create mode 100644 shierfeng-fastapi-backend/common/annotation/rate_limit_annotation.py create mode 100644 shierfeng-fastapi-backend/common/aspect/data_scope.py create mode 100644 shierfeng-fastapi-backend/common/aspect/db_seesion.py create mode 100644 shierfeng-fastapi-backend/common/aspect/interface_auth.py create mode 100644 shierfeng-fastapi-backend/common/aspect/pre_auth.py create mode 100644 shierfeng-fastapi-backend/common/constant.py create mode 100644 shierfeng-fastapi-backend/common/context.py create mode 100644 shierfeng-fastapi-backend/common/enums.py create mode 100644 shierfeng-fastapi-backend/common/router.py create mode 100644 shierfeng-fastapi-backend/common/vo.py create mode 100644 shierfeng-fastapi-backend/config/database.py create mode 100644 shierfeng-fastapi-backend/config/env.py create mode 100644 shierfeng-fastapi-backend/config/get_db.py create mode 100644 shierfeng-fastapi-backend/config/get_redis.py create mode 100644 shierfeng-fastapi-backend/config/get_scheduler.py create mode 100644 shierfeng-fastapi-backend/docs/cli_usage.md create mode 100644 shierfeng-fastapi-backend/docs/transport_crypto_config.md create mode 100644 shierfeng-fastapi-backend/exceptions/exception.py create mode 100644 shierfeng-fastapi-backend/exceptions/handle.py create mode 100644 shierfeng-fastapi-backend/middlewares/api_response_header_middleware.py create mode 100644 shierfeng-fastapi-backend/middlewares/context_middleware.py create mode 100644 shierfeng-fastapi-backend/middlewares/cors_middleware.py create mode 100644 shierfeng-fastapi-backend/middlewares/demo_mode_middleware.py create mode 100644 shierfeng-fastapi-backend/middlewares/gzip_middleware.py create mode 100644 shierfeng-fastapi-backend/middlewares/handle.py create mode 100644 shierfeng-fastapi-backend/middlewares/trace_middleware/__init__.py create mode 100644 shierfeng-fastapi-backend/middlewares/trace_middleware/ctx.py create mode 100644 shierfeng-fastapi-backend/middlewares/trace_middleware/middle.py create mode 100644 shierfeng-fastapi-backend/middlewares/trace_middleware/span.py create mode 100644 shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/cache_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/captcha_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/common_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/config_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/dept_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/dict_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/job_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/log_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/login_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/menu_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/notice_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/online_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/post_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/role_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/server_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/transport_crypto_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/controller/user_controller.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/config_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/dept_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/dict_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/job_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/job_log_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/log_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/login_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/menu_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/notice_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/post_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/role_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/dao/user_dao.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/config_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/dept_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/dict_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/job_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/log_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/menu_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/notice_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/post_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/role_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/do/user_do.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/cache_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/common_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/config_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/dept_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/dict_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/job_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/log_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/login_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/menu_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/notice_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/online_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/post_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/role_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/server_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/entity/vo/user_vo.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/cache_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/captcha_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/common_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/config_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/dept_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/dict_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/job_log_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/job_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/log_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/login_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/menu_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/notice_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/online_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/post_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/role_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/server_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/transport_crypto_service.py create mode 100644 shierfeng-fastapi-backend/module_admin/service/user_service.py create mode 100644 shierfeng-fastapi-backend/module_ai/controller/ai_chat_controller.py create mode 100644 shierfeng-fastapi-backend/module_ai/controller/ai_model_controller.py create mode 100644 shierfeng-fastapi-backend/module_ai/dao/ai_chat_dao.py create mode 100644 shierfeng-fastapi-backend/module_ai/dao/ai_model_dao.py create mode 100644 shierfeng-fastapi-backend/module_ai/entity/do/ai_chat_do.py create mode 100644 shierfeng-fastapi-backend/module_ai/entity/do/ai_model_do.py create mode 100644 shierfeng-fastapi-backend/module_ai/entity/vo/ai_chat_vo.py create mode 100644 shierfeng-fastapi-backend/module_ai/entity/vo/ai_model_vo.py create mode 100644 shierfeng-fastapi-backend/module_ai/service/ai_chat_service.py create mode 100644 shierfeng-fastapi-backend/module_ai/service/ai_model_service.py create mode 100644 shierfeng-fastapi-backend/module_generator/controller/gen_controller.py create mode 100644 shierfeng-fastapi-backend/module_generator/dao/gen_dao.py create mode 100644 shierfeng-fastapi-backend/module_generator/entity/do/gen_do.py create mode 100644 shierfeng-fastapi-backend/module_generator/entity/vo/gen_vo.py create mode 100644 shierfeng-fastapi-backend/module_generator/service/gen_service.py create mode 100644 shierfeng-fastapi-backend/module_generator/templates/js/api.js.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/python/controller.py.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/python/dao.py.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/python/do.py.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/python/service.py.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/python/vo.py.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/sql/sql.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/vue/index.vue.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/vue/v3/index-tree.vue.jinja2 create mode 100644 shierfeng-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 create mode 100644 shierfeng-fastapi-backend/module_task/__init__.py create mode 100644 shierfeng-fastapi-backend/module_task/scheduler_test.py create mode 100644 shierfeng-fastapi-backend/pyproject.toml create mode 100644 shierfeng-fastapi-backend/requirements-pg.txt create mode 100644 shierfeng-fastapi-backend/requirements.txt create mode 100644 shierfeng-fastapi-backend/ruff.toml create mode 100644 shierfeng-fastapi-backend/server.py create mode 100644 shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql create mode 100644 shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql create mode 100644 shierfeng-fastapi-backend/sub_applications/handle.py create mode 100644 shierfeng-fastapi-backend/sub_applications/staticfiles.py create mode 100644 shierfeng-fastapi-backend/tests/cli/completion/test_installers.py create mode 100644 shierfeng-fastapi-backend/tests/cli/completion/test_providers.py create mode 100644 shierfeng-fastapi-backend/tests/cli/core/test_execution_service.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/conftest.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_bootstrap.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_contract_app_ops.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_contract_cli.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_contract_config.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_contract_job_gen.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_guards.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_json_output_suppression.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_main.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_output.py create mode 100644 shierfeng-fastapi-backend/tests/cli/root/test_utils.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_app_runtime.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_base.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_cache.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_config.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_crypto.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_db.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_dev.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_gen.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_job.py create mode 100644 shierfeng-fastapi-backend/tests/cli/runtime/test_ops.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/actions/test_action_execution.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/adapters/conftest.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/adapters/test_app_ops_snapshots.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/adapters/test_cache_db_crypto_snapshots.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/adapters/test_configs_dashboard_snapshots.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/adapters/test_jobs_gen_snapshots.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/conftest.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/test_tui_browser_screen.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/test_tui_command_app.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/test_tui_dashboard_search.py create mode 100644 shierfeng-fastapi-backend/tests/cli/tui/test_tui_detail_screen.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/conftest.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_app_run.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_cache_clear.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_commands.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_db_upgrade.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_gen_export.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_gen_import.py create mode 100644 shierfeng-fastapi-backend/tests/cli/wizard/test_prod_check.py create mode 100644 shierfeng-fastapi-backend/tests/test_log_sanitize_util.py create mode 100644 shierfeng-fastapi-backend/utils/ai_util.py create mode 100644 shierfeng-fastapi-backend/utils/api_annotation_util.py create mode 100644 shierfeng-fastapi-backend/utils/api_response_header_util.py create mode 100644 shierfeng-fastapi-backend/utils/client_ip_util.py create mode 100644 shierfeng-fastapi-backend/utils/common_util.py create mode 100644 shierfeng-fastapi-backend/utils/cron_util.py create mode 100644 shierfeng-fastapi-backend/utils/crypto_util.py create mode 100644 shierfeng-fastapi-backend/utils/dependency_util.py create mode 100644 shierfeng-fastapi-backend/utils/excel_util.py create mode 100644 shierfeng-fastapi-backend/utils/gen_util.py create mode 100644 shierfeng-fastapi-backend/utils/import_util.py create mode 100644 shierfeng-fastapi-backend/utils/log_util.py create mode 100644 shierfeng-fastapi-backend/utils/message_util.py create mode 100644 shierfeng-fastapi-backend/utils/page_util.py create mode 100644 shierfeng-fastapi-backend/utils/pwd_util.py create mode 100644 shierfeng-fastapi-backend/utils/response_util.py create mode 100644 shierfeng-fastapi-backend/utils/server_util.py create mode 100644 shierfeng-fastapi-backend/utils/string_util.py create mode 100644 shierfeng-fastapi-backend/utils/template_util.py create mode 100644 shierfeng-fastapi-backend/utils/time_format_util.py create mode 100644 shierfeng-fastapi-backend/utils/transport_crypto_util.py create mode 100644 shierfeng-fastapi-backend/utils/upload_util.py create mode 100644 shierfeng-fastapi-frontend/.env.development create mode 100644 shierfeng-fastapi-frontend/.env.docker create mode 100644 shierfeng-fastapi-frontend/.env.production create mode 100644 shierfeng-fastapi-frontend/.env.staging create mode 100644 shierfeng-fastapi-frontend/.gitignore create mode 100644 shierfeng-fastapi-frontend/Dockerfile create mode 100644 shierfeng-fastapi-frontend/LICENSE create mode 100644 shierfeng-fastapi-frontend/bin/build.bat create mode 100644 shierfeng-fastapi-frontend/bin/nginx.dockermy.conf create mode 100644 shierfeng-fastapi-frontend/bin/nginx.dockerpg.conf create mode 100644 shierfeng-fastapi-frontend/bin/package.bat create mode 100644 shierfeng-fastapi-frontend/bin/run-web.bat create mode 100644 shierfeng-fastapi-frontend/html/ie.html create mode 100644 shierfeng-fastapi-frontend/index.html create mode 100644 shierfeng-fastapi-frontend/package.json create mode 100644 shierfeng-fastapi-frontend/public/favicon.ico create mode 100644 shierfeng-fastapi-frontend/src/App.vue create mode 100644 shierfeng-fastapi-frontend/src/api/ai/chat.js create mode 100644 shierfeng-fastapi-frontend/src/api/ai/model.js create mode 100644 shierfeng-fastapi-frontend/src/api/login.js create mode 100644 shierfeng-fastapi-frontend/src/api/menu.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/cache.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/job.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/jobLog.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/logininfor.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/online.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/operlog.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/server.js create mode 100644 shierfeng-fastapi-frontend/src/api/monitor/transportCrypto.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/config.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/dept.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/dict/data.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/dict/type.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/menu.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/notice.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/post.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/role.js create mode 100644 shierfeng-fastapi-frontend/src/api/system/user.js create mode 100644 shierfeng-fastapi-frontend/src/api/tool/gen.js create mode 100644 shierfeng-fastapi-frontend/src/assets/401_images/401.gif create mode 100644 shierfeng-fastapi-frontend/src/assets/404_images/404.png create mode 100644 shierfeng-fastapi-frontend/src/assets/404_images/404_cloud.png create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/404.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/ai-chat.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/ai-manage.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/ai-model.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/bug.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/build.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/button.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/cascader.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/chart.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/checkbox.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/clipboard.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/code.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/color.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/component.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/dashboard.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/date-range.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/date.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/deepthink.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/dict.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/documentation.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/download.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/drag.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/druid.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/edit.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/education.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/email.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/enter.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/example.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/excel.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/exit-fullscreen.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/eye-open.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/eye.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/form.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/fullscreen.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/github.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/guide.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/icon.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/input.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/international.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/job.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/language.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/link.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/list.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/lock.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/log.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/logininfor.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/message.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/money.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/monitor.svg create mode 100755 shierfeng-fastapi-frontend/src/assets/icons/svg/moon.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/more-up.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/nested.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/number.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/online.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/password.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/pdf.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/people.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/peoples.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/phone.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/post.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/qq.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/question.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/radio.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/rate.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/redis-list.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/redis.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/row.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/search.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/select.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/server.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/shopping.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/size.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/skill.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/slider.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/star.svg create mode 100755 shierfeng-fastapi-frontend/src/assets/icons/svg/sunny.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/swagger.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/switch.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/system.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/tab.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/table.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/textarea.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/theme.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/time-range.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/time.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/tool.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/tree-table.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/tree.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/upload.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/user.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/validCode.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/wechat.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/icons/svg/zip.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/images/dark.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/images/light.svg create mode 100644 shierfeng-fastapi-frontend/src/assets/images/login-background.jpg create mode 100644 shierfeng-fastapi-frontend/src/assets/images/profile.jpg create mode 100644 shierfeng-fastapi-frontend/src/assets/logo/logo.png create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/btn.scss create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/element-ui.scss create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/index.scss create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/mixin.scss create mode 100755 shierfeng-fastapi-frontend/src/assets/styles/shierfeng.scss create mode 100755 shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/transition.scss create mode 100755 shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss create mode 100644 shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/day.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/hour.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/min.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/month.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/result.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/second.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/week.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Crontab/year.vue create mode 100644 shierfeng-fastapi-frontend/src/components/DictTag/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Editor/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/FileUpload/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Hamburger/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/HeaderSearch/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/IconSelect/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/IconSelect/requireIcons.js create mode 100644 shierfeng-fastapi-frontend/src/components/ImagePreview/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/ImageUpload/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Pagination/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/ParentView/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/RightToolbar/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/Screenfull/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/ShiErFeng/Doc/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/ShiErFeng/Git/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/SizeSelect/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/SvgIcon/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/SvgIcon/svgicon.js create mode 100644 shierfeng-fastapi-frontend/src/components/TopNav/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/iFrame/index.vue create mode 100644 shierfeng-fastapi-frontend/src/directive/common/copyText.js create mode 100644 shierfeng-fastapi-frontend/src/directive/index.js create mode 100644 shierfeng-fastapi-frontend/src/directive/permission/hasPermi.js create mode 100644 shierfeng-fastapi-frontend/src/directive/permission/hasRole.js create mode 100644 shierfeng-fastapi-frontend/src/layout/components/AppMain.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Copyright/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/IframeToggle/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/InnerLink/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Navbar.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Settings/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Sidebar/Link.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Sidebar/Logo.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Sidebar/SidebarItem.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Sidebar/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/TagsView/ScrollPane.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/TagsView/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/TopBar/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/index.js create mode 100644 shierfeng-fastapi-frontend/src/layout/index.vue create mode 100644 shierfeng-fastapi-frontend/src/main.js create mode 100644 shierfeng-fastapi-frontend/src/permission.js create mode 100644 shierfeng-fastapi-frontend/src/plugins/auth.js create mode 100644 shierfeng-fastapi-frontend/src/plugins/cache.js create mode 100644 shierfeng-fastapi-frontend/src/plugins/download.js create mode 100644 shierfeng-fastapi-frontend/src/plugins/index.js create mode 100644 shierfeng-fastapi-frontend/src/plugins/modal.js create mode 100644 shierfeng-fastapi-frontend/src/plugins/tab.js create mode 100644 shierfeng-fastapi-frontend/src/router/index.js create mode 100644 shierfeng-fastapi-frontend/src/settings.js create mode 100644 shierfeng-fastapi-frontend/src/store/index.js create mode 100644 shierfeng-fastapi-frontend/src/store/modules/app.js create mode 100644 shierfeng-fastapi-frontend/src/store/modules/dict.js create mode 100644 shierfeng-fastapi-frontend/src/store/modules/permission.js create mode 100644 shierfeng-fastapi-frontend/src/store/modules/settings.js create mode 100644 shierfeng-fastapi-frontend/src/store/modules/tagsView.js create mode 100644 shierfeng-fastapi-frontend/src/store/modules/user.js create mode 100644 shierfeng-fastapi-frontend/src/utils/auth.js create mode 100644 shierfeng-fastapi-frontend/src/utils/dict.js create mode 100644 shierfeng-fastapi-frontend/src/utils/dynamicTitle.js create mode 100644 shierfeng-fastapi-frontend/src/utils/errorCode.js create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/config.js create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/css.js create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/drawingDefault.js create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/html.js create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/icon.json create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/js.js create mode 100755 shierfeng-fastapi-frontend/src/utils/generator/render.js create mode 100644 shierfeng-fastapi-frontend/src/utils/index.js create mode 100644 shierfeng-fastapi-frontend/src/utils/jsencrypt.js create mode 100644 shierfeng-fastapi-frontend/src/utils/permission.js create mode 100644 shierfeng-fastapi-frontend/src/utils/request.js create mode 100644 shierfeng-fastapi-frontend/src/utils/scroll-to.js create mode 100644 shierfeng-fastapi-frontend/src/utils/shierfeng.js create mode 100644 shierfeng-fastapi-frontend/src/utils/theme.js create mode 100644 shierfeng-fastapi-frontend/src/utils/transportCrypto.js create mode 100644 shierfeng-fastapi-frontend/src/utils/transportCryptoPolicy.js create mode 100644 shierfeng-fastapi-frontend/src/utils/validate.js create mode 100644 shierfeng-fastapi-frontend/src/views/ai/chat/components/AiMessage.vue create mode 100644 shierfeng-fastapi-frontend/src/views/ai/chat/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/ai/model/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/dashboard/editable-link-group.vue create mode 100644 shierfeng-fastapi-frontend/src/views/dashboard/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/error/401.vue create mode 100644 shierfeng-fastapi-frontend/src/views/error/404.vue create mode 100644 shierfeng-fastapi-frontend/src/views/login.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/cache/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/cache/list.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/druid/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/job/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/job/log.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/logininfor/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/online/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/operlog/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/server/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/monitor/transportCrypto/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/redirect/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/register.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/config/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/dept/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/dict/data.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/dict/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/menu/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/notice/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/post/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/role/authUser.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/role/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/role/selectUser.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/user/authRole.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/user/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/user/profile/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/user/profile/resetPwd.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/user/profile/userAvatar.vue create mode 100644 shierfeng-fastapi-frontend/src/views/system/user/profile/userInfo.vue create mode 100755 shierfeng-fastapi-frontend/src/views/tool/build/CodeTypeDialog.vue create mode 100755 shierfeng-fastapi-frontend/src/views/tool/build/DraggableItem.vue create mode 100755 shierfeng-fastapi-frontend/src/views/tool/build/IconsDialog.vue create mode 100755 shierfeng-fastapi-frontend/src/views/tool/build/RightPanel.vue create mode 100755 shierfeng-fastapi-frontend/src/views/tool/build/TreeNodeDialog.vue create mode 100755 shierfeng-fastapi-frontend/src/views/tool/build/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/gen/basicInfoForm.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/gen/createTable.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/gen/editTable.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/gen/genInfoForm.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/gen/importTable.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/gen/index.vue create mode 100644 shierfeng-fastapi-frontend/src/views/tool/swagger/index.vue create mode 100644 shierfeng-fastapi-frontend/vite.config.js create mode 100644 shierfeng-fastapi-frontend/vite/plugins/auto-import.js create mode 100644 shierfeng-fastapi-frontend/vite/plugins/compression.js create mode 100644 shierfeng-fastapi-frontend/vite/plugins/index.js create mode 100644 shierfeng-fastapi-frontend/vite/plugins/setup-extend.js create mode 100644 shierfeng-fastapi-frontend/vite/plugins/svg-icon.js create mode 100644 shierfeng-fastapi-test/README.md create mode 100644 shierfeng-fastapi-test/common/__init__.py create mode 100644 shierfeng-fastapi-test/common/base_page_test.py create mode 100644 shierfeng-fastapi-test/common/config.py create mode 100644 shierfeng-fastapi-test/common/login_helper.py create mode 100644 shierfeng-fastapi-test/disable_captcha.sql create mode 100644 shierfeng-fastapi-test/docker-compose.test.my.yml create mode 100644 shierfeng-fastapi-test/docker-compose.test.pg.yml create mode 100644 shierfeng-fastapi-test/monitor/__init__.py create mode 100644 shierfeng-fastapi-test/monitor/test_cache_list.py create mode 100644 shierfeng-fastapi-test/monitor/test_cache_monitor.py create mode 100644 shierfeng-fastapi-test/monitor/test_job_management.py create mode 100644 shierfeng-fastapi-test/monitor/test_online_user.py create mode 100644 shierfeng-fastapi-test/monitor/test_server_monitor.py create mode 100644 shierfeng-fastapi-test/requirements.txt create mode 100644 shierfeng-fastapi-test/ruff.toml create mode 100644 shierfeng-fastapi-test/system/__init__.py create mode 100644 shierfeng-fastapi-test/system/test_config_management.py create mode 100644 shierfeng-fastapi-test/system/test_dept_management.py create mode 100644 shierfeng-fastapi-test/system/test_dict_management.py create mode 100644 shierfeng-fastapi-test/system/test_log_management.py create mode 100644 shierfeng-fastapi-test/system/test_menu_management.py create mode 100644 shierfeng-fastapi-test/system/test_notice_management.py create mode 100644 shierfeng-fastapi-test/system/test_post_management.py create mode 100644 shierfeng-fastapi-test/system/test_role_management.py create mode 100644 shierfeng-fastapi-test/system/test_user_management.py create mode 100644 shierfeng-fastapi-test/test_login.py create mode 100644 shierfeng-fastapi-test/test_pages.py create mode 100644 shierfeng-fastapi-test/tool/__init__.py create mode 100644 shierfeng-fastapi-test/tool/test_code_gen.py create mode 100644 shierfeng-fastapi-test/tool/test_swagger.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/ShiErFeng.iml b/.idea/ShiErFeng.iml new file mode 100644 index 0000000..003a080 --- /dev/null +++ b/.idea/ShiErFeng.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..8744b55 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..cfcba93 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..cb84345 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f29546 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,547 @@ +# 更新日志 + +## ShiErFeng v1.9.0 + +### 项目依赖 + +前后端依赖均有升级,请升级依赖或重新创建环境。 + +### 新增功能 + +1.新增AI管理模块 (#69)。 +2.新增移动端模块 (#73)。 +3.新增多worker运行支持 (#76)。 +4.应用新增演示模式 (#78)。 + +### BUG修复 + +1.修复代码生成controller模板删除接口query_db参数异常的问题 (#63)。 +2.修复登录接口response_model声明错误 (#71)。 +3.修复无法直接通过后端地址访问API文档的问题 (#74)。 +4.修复create_app重复执行的问题 (#84)。 + +### 代码重构 + +1.移除对python3.9的支持 (#67)。 + +### 代码优化 + +1.优化alembic处理表模型逻辑,避免无关表影响 (#68)。 +2.优化代码生成后端模板 (#72)。 +3.自动注册路由出错时抛出异常以便于调试 (#79)。 +4.优化部分页面字段tooltip说明 (#80)。 +5.优化项目启动速度 (#82)。 +6.优化暗黑模式切换效果 (#83)。 +7.优化热重载模式或单worker下scheduler的任务状态同步机制 (#85)。 +8.优化防重提交间隔时间可自定义 (#87)。 +9.优化验证码计算结果为非负数 (#88)。 +10.优化ci测试稳定性 (#90)。 + +## ShiErFeng v1.8.1 + +### 新增功能 + +1.新增E2E测试 (#57)。 + +### BUG修复 + +1.修复DictTag组件渲染异常的问题 (#59)。 + +### 代码优化 + +1.优化数据权限依赖 (#55)。 +2.动态导入定时任务函数,移除eval (#56)。 +3.优化pg版本的docker compose配置文件 (#61)。 + +## ShiErFeng v1.8.0 + +### 项目依赖 + +#### 后端 + +1.后端依赖升级到最新版本,请升级依赖或重新创建环境。 + +### 新增功能 + +1.新增请求上下文管理类。 +2.新增`PreAuthDependency`、`CurrentUserDependency`、`DataScopeDependency`、`DBSessionDependency`、`UserInterfaceAuthDependency`和`RoleInterfaceAuthDependency`依赖函数。 +3.新增上下文清理中间件。 +4.新增公共vo模块。 +5.新增配置文档静态资源方法。 +6.新增自动注册路由功能。 +7.新增docker compose部署方式。 +8.菜单导航设置支持纯顶部。 + +### BUG修复 + +1.修复单账号登录模式下强退功能失效的问题 #52。 +2.确保ApschedulerJobs字段类型与apscheduler默认创建的表字段类型一致 #53。 +3.修复磁盘存在异常时服务监控无法正常运行的问题。 +4.移除代码生成表业务表外键,修复无法删除的问题。 +5.修复固定头部时出现的导航栏偏移问题。 +6.修复表单构建移除所有控件后切换路由回来空白问题。 +7.修复代码生成v3模板时间控件between选择后清空报错问题。 + +### 代码重构 + +1.增强ruff规则,完善类型提示。 +2.优化项目结构,新增common模块,原annotation、aspect、constant、enums模块移动至common模块下。 +3.重构app与server设计。 + +### 代码优化 + +1.controller层全部使用新依赖项。 +2.当前用户信息使用上下文变量。 +3.分页模型改为使用公共vo模块的PageModel。 +4.优化API文档的响应模型显示。 +5.操作响应模型改为使用公共vo模块的CrudResponseModel。 +6.优化API文档的接口描述信息。 +7.登录/注册页面底部版权信息修改为读取配置。 +8.优化生成代码下载的zip文件名。 +9.优化表单构建关闭页签销毁复制插件。 +10.行内表单默认设置固定宽度。 +11.优化操作日志详细请求参数显示。 +12.优化index页面标题读取配置。 +13.优化字典组件数字类型值处理逻辑。 +14.优化字典组件值宽松匹配。 +15.默认固定头部。 + +## ShiErFeng v1.7.1 + +### 项目依赖 + +1.后端依赖移除passlib,直接使用bcrypt。 + +### BUG修复 + +1.修复代码生成controller模板编辑接口异常生成字段的问题。 +2.移除passlib直接使用bcrypt修复密码校验异常的问题 #48 #49。 + +### 代码优化 + +1.代码生成do模板补充表描述。 + +## ShiErFeng v1.7.0 + +### 项目依赖 + +1.前后端依赖升级,请升级依赖或重新创建环境。 + +### 新增功能 + +1.新增alembic支持。 +2.文件&图片上传组件支持自定义地址&参数。 +3.新增默认打包配置项。 +4.显隐列组件支持全选/全不选。 +5.添加页签openPage支持传递参数。 +6.外链加载时遮罩信息提示。 +7.上传组件新增拖动排序属性。 +8.图片上传组件新增disabled属性。 +9.代码生成列支持拖动排序。 +10.新增用户默认初始化密码。 +11.新增页签图标显示开关功能。 +12.新增底部版权信息及开关。 +13.用户归属部门新增清除。 +14.用户导入新增验证提示。 +15.菜单搜索支持键盘选择&悬浮主题背景。 +16.新增apscheduler_jobs表对应sqlalchemy模型类。 +17.初始密码支持自定义修改策略。 +18.账号密码支持自定义更新周期。 +19.注册账号设置默认密码最后更新时间。 +20.显示列信息支持对象格式。 + +### BUG修复 + +1.修复logout接口未按照app_same_time_login配置项动态判断的问题 #IBZZ1S。 +2.修复上传组件被多次引用拖动仅对第一个有效的问题。 + +### 代码优化 + +1.优化接口耗时计算。 +2.优化启动信息显示。 +3.优化前端处理路由函数代码。 +4.登录页和注册页表头使用VITE_APP_BASE_API配置值。 +5.优化角色禁用不允许分配。 +6.优化富文本控制台警告异常。 +7.优化checkbox废弃API。 +8.优化导航栏显示昵称&设置。 + +### 代码重构 + +1.重构IP归属区域查询为异步调用。 +2.调整do与sql使其相互适配以支持alembic。 +3.富文本复制粘贴图片上传至url。 + +## ShiErFeng v1.6.2 + +### 新增功能 + +1.文件上传组件新增disabled属性。 +2.文件上传组件新增类型。 + +### BUG修复 + +1.修复日志管理时间查询报错 #27。 +2.修复定时任务状态暂停时执行单次任务会触发cron表达式的问题 #31。 +3.修复修改字典类型时获取dict_code异常的问题。 +4.修复修改字典类型时字典数据更新时间异常的问题。 +5.修复代码生成模板时间查询问题 #28。 +6.修复用户导出缺失部门名称的问题。 + +### 代码优化 + +1.优化代码生成新增和编辑字段显示和渲染。 +2.pagination更换成flex布局。 +3.优化代码生成vue模板 #23。 + +## ShiErFeng v1.6.1 + +### 项目依赖 + +#### 后端 + +1.新增sqlglot依赖 + +```bash +pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple +``` + +### BUG修复 + +1.引入sqlglot修复sql语句解析异常的问题。 +2.修复代码生成字段唯一性校验dao层模板判断异常的问题。 +3.引入泛型修复as_query和as_form装饰模型文档丢失的问题。 +4.修复代码生成主子表vo模板可能缺失NotBlank的问题。 + +## ShiErFeng v1.6.0 + +### 项目依赖 + +1.后端依赖升级到最新版本,请升级依赖或重新创建环境。 + +### 新增功能 + +1.新增代码生成功能,支持配置数据库表信息一键生成和下载前后端代码,需要重新执行sql文件,请先备份数据。 +2.新增表单构建功能。 +3.用户头像新增支持http(s)链接。 +4.新增trace中间件强化日志链路追踪和响应头 [@y1ren](https://gitee.com/y1ren)。 +5.用户管理支持分栏拖动。 +6.菜单面包屑导航支持多层级显示。 +7.白名单支持对通配符路径匹配。 +8.支持开启暗黑模式。 + +### BUG修复 + +1.修复默认关闭Tags-Views时,内链页面打不开。 +2.修复删除当前登录用户拦截失效的问题。 +3.修复定时任务目标字符串规则校验不全的问题。 +4.修复执行单次任务时会覆盖已启用任务的问题 #IBEKD2。 +5.修复个人中心特殊字符密码修改失败问题。 + +### 代码优化 + +1.优化导出方法。 +2.参数键值更换为多行文本。 +3.优化日志中操作方法显示。 +4.优化日志装饰器获取核心参数的方式。 +5.用户管理过滤掉已禁用部门。 +6.优化TopNav内链菜单点击没有高亮。 +7.ResponseUtil补充完整参数。 + +## ShiErFeng v1.5.1 + +### 新增功能 + +1.定时任务新增支持调用异步函数。 + +### 代码优化 + +1.优化字典数组条件判断。 +2.校检文件名是否包含特殊字符。 +3.移除已弃用的log_decorator装饰器。 + +## ShiErFeng v1.5.0 + +### 新增功能 + +1.新增对PostgreSQL数据库的支持。 + +### BUG修复 + +1.修复DictTag组件控制台抛异常的问题 #IAYSVZ。 +2.修复登录日志导出文件名称错误的问题。 + +### 代码回滚 + +1.因fastapi查询参数模型底层存在bug,回滚查询参数模型声明方式为as_query。 + +### 代码优化 + +1.优化CamelCaseUtil和SnakeCaseUtil以兼容更多转换场景。 +2.优化列表查询排序。 +3.优化参数设置页面。 +4.优化上传图片带域名时不增加前缀。 + +## ShiErFeng v1.4.0 + +### 项目依赖 + +#### 后端 + +1.更新fastapi版本为0.115.0 + +```bash +pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple +``` + +### 代码重构 + +1.基于fastapi 0.115.0版本新特性,直接使用pydantic模型接收查询参数和表单数据,移除原有as_query和as_form使用方式。 + +### BUG修复 + +1.修复角色管理service书写错误。 + +### 代码优化 + +1.优化前端登录请求方法。 + +## ShiErFeng v1.3.3 + +### 项目依赖 + +#### 后端 + +1.更新pydantic-validation-decorator版本为0.1.4,修复了一些底层bug。 + +### BUG修复 + +1.修复在线用户模块条件查询无效的问题。 + +### 代码优化 + +1.优化在线用户模块前后端字段描述一致。 +2.日志装饰器异常处理增加logger打印日志。 + +## ShiErFeng v1.3.2 + +### 新增功能 + +1.新增gzip压缩中间件。 + +### BUG修复 + +1.修复分页函数计算has_next错误的问题 #10。 +2.修复定时任务监听函数中事件没有job_id报错的问题。 + +### 代码优化 + +1.优化添加中间件函数注释。 + +## ShiErFeng v1.3.1 + +### BUG修复 + +1.修复1.3.0版本采用新的异常处理机制后日志装饰器无法记录异常日志的问题。 + +### 代码优化 + +1.补充定时任务违规字符串。 + +## ShiErFeng v1.3.0 + +### 项目依赖 + +1.前后端依赖均升级到最新版本,请升级依赖或重新创建环境。 +2.使用`PyJWT`替换`python-jose`以解决一些安全性问题。 + +### 新增功能 + +1.新增字段校验装饰器,支持手动触发校验,已封装为`pydantic-validation-decorator`库。 +2.各模块`service`层新增字段唯一性校验。 +3.全局新增`ServiceException`自定义服务异常和`ServiceWarning`自定义服务警告,无需在接口中写大量的异常捕获。 +4.菜单管理新增路由名称,请执行以下sql为数据库新增字段: + +```sql +ALTER TABLE sys_menu ADD COLUMN route_name varchar(50) DEFAULT ''; +``` + +5.新增`constant`常量配置及`enums`枚举类型配置。 +6.新增`StringUtil`、`CronUtil`工具类。 + +### BUG修复 + +1.修复用户管理、角色管理、部门管理越权漏洞。 +2.修复各模块`dao`层`status`、`del_flag`类型与数据库不一致的问题。 +3.修复移动端左侧菜单无法显示的问题。 +4.修复其他已知BUG。 + +### 代码重构 + +1.重构日志装饰器为`Log`,未来版本将删除`log_decorator`装饰器,请尽快迁移。 +2.重构`RedisInitKeyConfig`为枚举类型,现在可通过以下方式获取对应的`key`和`remark` +`RedisInitKeyConfig.ACCESS_TOKEN.key`、`RedisInitKeyConfig.ACCESS_TOKEN.remark`。 +3.重构数据权限逻辑,底层进行优化,使用方法与之前相同。 + +### 代码优化 + +1.引入`ruff`对后端代码进行格式化及检测修复,优化导入。 +2.各模块基于`ServiceException`自定义服务异常和`ServiceWarning`自定义服务警告优化了异常处理逻辑。 +3.各模块`vo`层使用`Field`声明字段。 +4.优化API文档字段描述显示。 + +## ShiErFeng v1.2.2 + +### BUG修复 + +1.修复删除定时任务时未移除调度中任务的问题。 +2.修复菜单生成路由时组件条件判断错误的问题。 + +## ShiErFeng v1.2.1 + +### BUG修复 + +1.修复各模块新增数据时创建时间记录异常的问题。 +2.修复菜单挂载到根目录时路由加载异常等一系列相关问题。 + +### 代码及性能优化 + +1.修改代理localhost为127.0.0.1以适配部分设备解析localhost异常的问题。 + +## ShiErFeng v1.2.0 + +### 重要说明 + +本次更新为 **_破坏性更新_** ,重构数据库orm为异步,代码改动很大,请谨慎升级。 +1.原有的Session类型声明统一变更为AsyncSession。 +2.service层和dao层的函数修改为异步函数,请使用await调用。 +3.orm查询不再支持query,请使用select、update、delete等语句,具体使用方法请参考[https://docs.sqlalchemy.org/en/20/orm/queryguide/index.html](https://docs.sqlalchemy.org/en/20/orm/queryguide/index.html)。 + +### 项目依赖 + +#### 后端 + +1.增加asyncmy依赖用于支持orm异步操作mysql,请重新安装依赖 + +```bash +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 +``` + +### 新增功能 + +1.新增SnakeCaseUtil工具类,将原CamelCaseUtil工具类的camel_to_snake函数迁移至SnakeCaseUtil工具类。 + +### BUG修复 + +1.修复用户管理模块重置用户密码时会异常重置用户岗位和角色的问题。 +2.修复清空定时任务日志异常的问题。 + +## ShiErFeng v1.1.3 + +### 新增功能 + +1.用户密码新增非法字符验证。 + +### BUG修复 + +1.修复通知公告列表查询前后端字段不一致的问题。 +2.修复个人中心修改基本资料后端异常的问题。 + +## ShiErFeng v1.1.2 + +### 新增功能 + +1.配置文件新增数据库连接池相关配置。 + +### BUG修复 + +1.修复个人中心修改密码后端异常的问题。 + +### 代码及性能优化 + +1.使用@lru_cache缓存ip归属区域查询结果,避免重复调用ip归属区域查询接口以优化性能。 + +## ShiErFeng v1.1.1 + +### BUG修复 + +1.修复编辑定时任务时更新的信息未同步至scheduler的问题 #I9EK56。 +2.修复编辑角色数据权限时后端异常的问题 #I9ENQN。 +3.修复菜单配置路由参数不生效的问题。 +4.修复获取路由信息时菜单排序不生效的问题。 +5.修复添加菜单时是否外链和是否缓存回显异常的问题。 + +## ShiErFeng v1.1.0 + +### 新增功能 + +1.后端配置文件新增sqlalchemy日志开关配置。 +2.后端配置文件新增IP归属区域查询开关配置。 +3.后端配置文件新增账号同时登录开关配置。 + +### BUG修复 + +1.修复token本身过期时退出登录接口异常的问题 #I9CBWT。 +2.修复系统版本号或浏览器版本号无法获取时登录异常的问题 #I9CYNM。 + +## ShiErFeng v1.0.3 + +### 新增功能 + +1.账号密码登录新增IP黑名单校验。 + +### BUG修复 + +1.修复外链菜单无法打开的问题 #I95KBK。 +2.修复添加和编辑菜单页面中是否缓存和是否外链字段回显异常的问题 #I95KBK。 + +## ShiErFeng v1.0.2 + +### 新增功能 + +1.用户接口权限校验增加列表接收参数,实现同一接口支持多个权限标识校验。 +2.新增按角色校验接口权限依赖 + +### BUG修复 + +1.修复用户管理和部门管理模块数据权限异常的问题。 + +### 代码及性能优化 + +1.调整参数设置、部门管理、字典管理、定时任务、日志管理、角色管理、菜单管理模块部分接口权限标识。 + +## ShiErFeng v1.0.1 + +### 项目依赖 + +#### 后端 + +1.更新fastapi版本为0.109.1,修复一些安全性问题,命令: + +```bash +pip install fastapi[all]==0.109.1 -i https://mirrors.aliyun.com/pypi/simple/ +``` + +### 新增功能 + +1.日志管理模块新增字段排序查询。 + +## ShiErFeng v1.0.0 + +ShiErFeng第一个版本发布啦! +此版本功能如下: +1.用户管理:用户是系统操作者,该功能主要完成系统用户配置。 +2.角色管理:角色菜单权限分配。 +3.菜单管理:配置系统菜单,操作权限,按钮权限标识等。 +4.部门管理:配置系统组织机构(公司、部门、小组)。 +5.岗位管理:配置系统用户所属担任职务。 +6.字典管理:对系统中经常使用的一些较为固定的数据进行维护。 +7.参数管理:对系统动态配置常用参数。 +8.通知公告:系统通知公告信息发布维护。 +9.操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。 +10.登录日志:系统登录日志记录查询包含登录异常。 +11.在线用户:当前系统中活跃用户状态监控。 +12.定时任务:在线(添加、修改、删除)任务调度包含执行结果日志。 +13.服务监控:监视当前系统CPU、内存、磁盘、堆栈等相关信息。 +14.缓存监控:对系统的缓存信息查询,命令统计等。 +15.系统接口:根据业务代码自动生成相关的api接口文档。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a3554d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 insistence + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..182656a --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# ShiErFeng + +基于 Vue3 与 FastAPI 的前后端分离快速开发框架。 + +## 平台简介 + +施尔丰是一套全部开源的快速开发平台,供个人及企业免费使用。 + +* 前端采用Vue3、Element Plus。 +* 移动端采用uni-app、Vue3、Vite,内置tailwindcss。 +* 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。 +* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。 +* 支持加载动态权限菜单,多方式轻松权限控制。 +* 纯Python版本: + * Gitte仓库地址: + * GitHub仓库地址: + +## 内置功能 + +1. 用户管理:用户是系统操作者,该功能主要完成系统用户配置。 +2. 角色管理:角色菜单权限分配、设置角色按机构进行数据范围权限划分。 +3. 菜单管理:配置系统菜单,操作权限,按钮权限标识等。 +4. 部门管理:配置系统组织机构(公司、部门、小组)。 +5. 岗位管理:配置系统用户所属担任职务。 +6. 字典管理:对系统中经常使用的一些较为固定的数据进行维护。 +7. 参数管理:对系统动态配置常用参数。 +8. 通知公告:系统通知公告信息发布维护。 +9. 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。 +10. 登录日志:系统登录日志记录查询包含登录异常。 +11. 在线用户:当前系统中活跃用户状态监控。 +12. 定时任务:在线(添加、修改、删除)任务调度包含执行结果日志。 +13. 服务监控:监视当前系统CPU、内存、磁盘、堆栈等相关信息。 +14. 缓存监控:对系统的缓存信息查询,命令统计等。 +15. 传输加密:支持前后端请求加密、响应解密、公钥轮换、运行策略下发与监控统计。 +16. 在线构建器:拖动表单元素生成相应的HTML代码。 +17. 系统接口:根据业务代码自动生成相关的api接口文档。 +18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。 +19. AI管理:提供AI模型管理和AI对话功能。 + +## 在线体验 + +* *账号:admin* +* *密码:admin123* +* 演示地址:vfadmin管理系统 + +## 项目开发及发布相关 + +### 传输层加解密配置说明 + +后端密钥配置与轮换说明:[shierfeng-fastapi-backend/docs/transport_crypto_config.md](./shierfeng-fastapi-backend/docs/transport_crypto_config.md) + +### 开发 + +```bash +# 克隆项目 +git clone https://git.yujiangrubber.cn/niuniu/ShiErFeng.git + +# 进入项目根目录 +cd ShiErFeng +``` + +#### 前端 + +```bash +# 进入前端目录 +cd shierfeng-fastapi-frontend + +# 安装依赖 +npm install 或 yarn --registry=https://registry.npmmirror.com + +# 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题 +npm install --registry=https://registry.npmmirror.com + +# 启动服务 +npm run dev 或 yarn dev +``` + +#### 移动端 + +```bash +# 进入移动端目录 +cd shierfeng-fastapi-app + +# 安装依赖 +npm install -g pnpm +pnpm install + +# 启动 H5 +pnpm dev:h5 + +# 启动微信小程序 +pnpm dev:mp-weixin +``` + +移动端详细文档请参考:[shierfeng-fastapi-app/README.md](./shierfeng-fastapi-app/README.md) + +#### 后端 + +```bash +# 进入后端目录 +cd shierfeng-fastapi-backend + +# 如果使用的是MySQL数据库,请执行以下命令安装项目依赖环境 +pip3 install -r requirements.txt +# 如果使用的是PostgreSQL数据库,请执行以下命令安装项目依赖环境 +pip3 install -r requirements-pg.txt + +# 配置环境 +在.env.dev文件中配置开发环境的数据库和redis + +# 运行sql文件 +1.新建数据库shierfeng-fastapi(默认,可修改) +2.如果使用的是MySQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi.sql;如果使用的是PostgreSQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi-pg.sql + +# 运行后端 +shierfeng app run --env=dev +``` + +后端 CLI 使用说明请参考:[shierfeng-fastapi-backend/docs/cli_usage.md](./shierfeng-fastapi-backend/docs/cli_usage.md) + +#### 访问 + +```bash +# 默认账号密码 +账号:admin +密码:admin123 + +# 浏览器访问 +地址:http://localhost:80 +``` + +### 发布 + +#### 前端 + +```bash +# 构建测试环境 +npm run build:stage 或 yarn build:stage + +# 构建生产环境 +npm run build:prod 或 yarn build:prod +``` + +#### 后端 + +```bash +# 配置环境 +在.env.prod文件中配置生产环境的数据库和redis + +# 运行后端 +shierfeng app run --env=prod +``` + +### Docker Compose部署方式 + +> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化 + +#### MySQL版本 + +```bash +docker compose -f shierfeng-fastapi-test/docker-compose.test.my.yml up -d --build +``` + +#### PostgreSQL版本 + +```bash +docker compose -f shierfeng-fastapi-test/docker-compose.test.pg.yml up -d --build +``` diff --git a/shierfeng-fastapi-app/.editorconfig b/shierfeng-fastapi-app/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/shierfeng-fastapi-app/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/shierfeng-fastapi-app/.gitignore b/shierfeng-fastapi-app/.gitignore new file mode 100644 index 0000000..0f36cd4 --- /dev/null +++ b/shierfeng-fastapi-app/.gitignore @@ -0,0 +1,27 @@ +.DS_Store +node_modules/ +unpackage/ +dist/ + +# local env files +.env.local +.env.*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.project +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw* +# 要提交 .env 来确保 jit v2 的开发 watch mode +!.env + +src/ignore +pnpm-lock.yaml diff --git a/shierfeng-fastapi-app/.npmrc b/shierfeng-fastapi-app/.npmrc new file mode 100644 index 0000000..7bd33f5 --- /dev/null +++ b/shierfeng-fastapi-app/.npmrc @@ -0,0 +1,7 @@ +# 设置国内镜像地址 +registry=http://registry.npmmirror.com/ +# registry=http://registry.npmjs.com/ +# 这个是给 pnpm 用的 +shamefully-hoist=true + +ignore-engines=true diff --git a/shierfeng-fastapi-app/README.md b/shierfeng-fastapi-app/README.md new file mode 100644 index 0000000..a9af3c8 --- /dev/null +++ b/shierfeng-fastapi-app/README.md @@ -0,0 +1,78 @@ +# ShiErFeng 移动端 + +基于 `uni-app` 的 `vite` + `vue3` + `tailwindcss` 开发。 + +## 特性 + +- ⚡️ [Vue 3](https://github.com/vuejs/core), [Vite](https://github.com/vitejs/vite), [pnpm](https://pnpm.io/) - 快 & 稳定 + +- 🎨 [TailwindCSS](https://tailwindcss.com/) - 世界上最流行,生态最好的原子化CSS框架 + +- 😃 [集成 Iconify](https://github.com/egoist/tailwindcss-icons) - [icones.js.org](https://icones.js.org/) 中的所有图标都为你所用 + +- 📥 [API 自动加载](https://github.com/antfu/unplugin-auto-import) - 直接使用 Composition API 无需引入 + +- 🧬 [uni-app 条件编译样式](https://tw.icebreaker.top/docs/quick-start/uni-app-css-macro) - 帮助你在多端更灵活的使用 `TailwindCSS` + +- 🦾 [TypeScript](https://www.typescriptlang.org/) & [ESLint](https://eslint.org/) & [Stylelint](https://stylelint.io/) - 样式,类型,统一的校验与格式化规则,保证你的代码风格和质量 + +## 快速开始 + +> [!IMPORTANT] +> 推荐使用 `"node": "^20.19.0 || >=22.12.0"` 的 Node.js 版本进行开发! +> +> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug + +### vscode + +使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件 + +其他 IDE 请参考: + +### 更换 Appid + +把 `src/manifest.json` 中的 `appid`, 更换为你自己的 `appid`, 比如 `uni-app` / `mp-weixin` 平台。 + +## 升级依赖 + +- `pnpm up:pkg` 升级除了 `uni-app` 相关的其他依赖 +- `pnpm up:uniapp` 升级 `uni-app` 相关的依赖 + +推荐先使用 `pnpm up:pkg` 升级, 再使用 `pnpm up:uniapp` 进行升级,因为 `pnpm up:uniapp` 很有可能会进行版本的降级已达到和 `uni-app` 版本匹配的效果 + +## 切换镜像源 + +默认情况下,走的是淘宝镜像源 : `registry.npmmirror.com` + +假如你需要修改镜像源,请修改目录下的 `.npmrc` 文件,然后重新进行 `pnpm i` 安装包即可 + +## 包管理器 + +本项目默认使用 `pnpm@10` 进行管理,当然你也可以切换到其他包管理器,比如 `yarn`, `npm` + +你只需要把 `pnpm-lock.yaml` 删掉,然后把 `package.json` 中的 `packageManager` 字段去除或者换成你具体的包管理器版本,然后重新安装即可 + +### weapp-ide-cli + +本项目已经集成 `weapp-ide-cli` 可以通过 `cli` 对 `ide` 进行额外操作 + +- `pnpm open:dev` 打开微信开发者工具,引入 `dist/dev/mp-weixin` +- `pnpm open:build` 打开微信开发者工具,引入 `dist/build/mp-weixin` + +[详细信息](https://www.npmjs.com/package/weapp-ide-cli) + +## tailwindcss 生态 + +详见: + +你可以在这里找到许多现成的UI,组件模板。 + +## 单位转换 + +- `rem` -> `rpx` (默认开启, 见 `vite.config.ts` 中 `uvtw` 插件的 `rem2rpx` 选项) +- `px` -> `rpx` (默认不开启,可在 `postcss.config.ts` 中引入 `postcss-pxtransform` 开启配置) + +## Tips + +- 升级 `uni-app` 依赖的方式为 `npx @dcloudio/uvm` 后,选择对应的 `Package Manager` 即可。而升级其他包的方式,可以使用 `pnpm up -Li`,这个是 `pnpm` 自带的方式。 +- 使用 `vscode` 记得安装官方插件 `stylelint`,`tailwindcss`, 已在 `.vscode/extensions.json` 中设置推荐 diff --git a/shierfeng-fastapi-app/index.html b/shierfeng-fastapi-app/index.html new file mode 100644 index 0000000..1afa688 --- /dev/null +++ b/shierfeng-fastapi-app/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + +
+ + + diff --git a/shierfeng-fastapi-app/netlify.toml b/shierfeng-fastapi-app/netlify.toml new file mode 100644 index 0000000..96c3c5d --- /dev/null +++ b/shierfeng-fastapi-app/netlify.toml @@ -0,0 +1,13 @@ +# netlify 部署文件 +# 用于部署此项目的 h5 产物,可删除 +[build] +publish = "dist/build/h5" +command = "pnpm run build:h5" + +[build.environment] +NODE_VERSION = "20" + +[[redirects]] +from = "/*" +to = "/index.html" +status = 200 diff --git a/shierfeng-fastapi-app/package.json b/shierfeng-fastapi-app/package.json new file mode 100644 index 0000000..75c2e4a --- /dev/null +++ b/shierfeng-fastapi-app/package.json @@ -0,0 +1,98 @@ +{ + "name": "shierfeng-fastapi-app", + "version": "1.9.0", + "packageManager": "pnpm@10.28.1", + "scripts": { + "dev": "uni -p mp-weixin", + "build": "npm run build:mp-weixin", + "dev:app": "uni -p app", + "dev:custom": "uni -p", + "dev:h5": "uni --host", + "dev:h5:ssr": "uni --ssr", + "dev:mp-alipay": "uni -p mp-alipay", + "dev:mp-baidu": "uni -p mp-baidu", + "dev:mp-kuaishou": "uni -p mp-kuaishou", + "dev:mp-lark": "uni -p mp-lark", + "dev:mp-qq": "uni -p mp-qq", + "dev:mp-toutiao": "uni -p mp-toutiao", + "dev:mp-weixin": "uni -p mp-weixin", + "dev:quickapp-webview": "uni -p quickapp-webview", + "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei", + "dev:quickapp-webview-union": "uni -p quickapp-webview-union", + "build:app": "uni build -p app", + "build:custom": "uni build -p", + "build:h5": "uni build", + "build:h5:ssr": "uni build --ssr", + "build:mp-alipay": "uni build -p mp-alipay", + "build:mp-baidu": "uni build -p mp-baidu", + "build:mp-kuaishou": "uni build -p mp-kuaishou", + "build:mp-lark": "uni build -p mp-lark", + "build:mp-qq": "uni build -p mp-qq", + "build:mp-toutiao": "uni build -p mp-toutiao", + "build:mp-weixin": "uni build -p mp-weixin", + "build:quickapp-webview": "uni build -p quickapp-webview", + "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", + "build:quickapp-webview-union": "uni build -p quickapp-webview-union", + "open:dev": "weapp open -p dist/dev/mp-weixin", + "open:build": "weapp open -p dist/build/mp-weixin", + "weapp:login": "weapp login", + "upload:dev": "weapp upload -p dist/dev/mp-weixin -v 1.0.0 -d \"dev version\"", + "upload:build": "weapp upload -p dist/build/mp-weixin -v 1.0.0 -d \"release version\"", + "postinstall": "weapp-tw patch", + "up:pkg": "pnpm up -rLi \"!@dcloudio/*\"", + "up:uniapp": "pnpx @dcloudio/uvm@latest" + }, + "dependencies": { + "@dcloudio/uni-app": "3.0.0-4080720251210001", + "@dcloudio/uni-app-harmony": "3.0.0-4080720251210001", + "@dcloudio/uni-app-plus": "3.0.0-4080720251210001", + "@dcloudio/uni-components": "3.0.0-4080720251210001", + "@dcloudio/uni-h5": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-alipay": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-baidu": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-harmony": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-jd": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-kuaishou": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-lark": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-qq": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-toutiao": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001", + "@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001", + "@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001", + "@vue/shared": "3.4.21", + "@weapp-tailwindcss/merge-v3": "^0.1.5", + "node-forge": "^1.4.0", + "pinia": "2.2.4", + "vue": "^3.4.21", + "vue-i18n": "^9.1.9", + "vuex": "^4.0.2" + }, + "devDependencies": { + "@dcloudio/types": "^3.4.8", + "@dcloudio/uni-automator": "3.0.0-4080720251210001", + "@dcloudio/uni-cli-shared": "3.0.0-4080720251210001", + "@dcloudio/uni-stacktracey": "3.0.0-4080720251210001", + "@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001", + "@egoist/tailwindcss-icons": "^1.9.2", + "@icebreakers/stylelint-config": "^1.2.5", + "@iconify-json/mdi": "^1.2.3", + "@iconify-json/svg-spinners": "^1.2.4", + "@types/node": "^24.10.1", + "@vue/runtime-core": "^3.5.27", + "autoprefixer": "^10.4.24", + "postcss": "^8.5.6", + "sass": "^1.97.3", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "unplugin-auto-import": "^20.3.0", + "vite": "5.4.21", + "weapp-ide-cli": "^5.0.1", + "weapp-tailwindcss": "^4.9.8" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "weapp-tailwindcss", + "@weapp-tailwindcss/merge-v3" + ] + } +} diff --git a/shierfeng-fastapi-app/platform.ts b/shierfeng-fastapi-app/platform.ts new file mode 100644 index 0000000..92d71dd --- /dev/null +++ b/shierfeng-fastapi-app/platform.ts @@ -0,0 +1,8 @@ +import process from "node:process"; + +const isH5 = process.env.UNI_PLATFORM === "h5"; +const isApp = process.env.UNI_PLATFORM === "app"; +const WeappTailwindcssDisabled = isH5 || isApp; +const isMp = !isH5 && !isApp; + +export { isApp, isH5, isMp, WeappTailwindcssDisabled }; diff --git a/shierfeng-fastapi-app/postcss.config.ts b/shierfeng-fastapi-app/postcss.config.ts new file mode 100644 index 0000000..791cf15 --- /dev/null +++ b/shierfeng-fastapi-app/postcss.config.ts @@ -0,0 +1,13 @@ +import type { AcceptedPlugin } from "postcss"; +import autoprefixer from "autoprefixer"; +import tailwindcss from "tailwindcss"; +import cssMacro from "weapp-tailwindcss/css-macro/postcss"; + +const plugins: AcceptedPlugin[] = [tailwindcss(), autoprefixer()]; + +// 可以使用 postcss-pxtransform 来进行 px 转 rpx 的功能 +// 详见: https://tw.icebreaker.top/docs/quick-start/css-unit-transform#px-%E8%BD%AC-rpx + +plugins.push(cssMacro); + +export default plugins; diff --git a/shierfeng-fastapi-app/src/App.vue b/shierfeng-fastapi-app/src/App.vue new file mode 100644 index 0000000..1dfc678 --- /dev/null +++ b/shierfeng-fastapi-app/src/App.vue @@ -0,0 +1,79 @@ + + + diff --git a/shierfeng-fastapi-app/src/api/login.js b/shierfeng-fastapi-app/src/api/login.js new file mode 100644 index 0000000..3733b15 --- /dev/null +++ b/shierfeng-fastapi-app/src/api/login.js @@ -0,0 +1,62 @@ +import request from "@/utils/request"; + +// 登录方法 +export function login(username, password, code, uuid) { + const data = { + username, + password, + code, + uuid, + }; + return request({ + url: "/login", + header: { + "Content-Type": "application/x-www-form-urlencoded", + }, + headers: { + isToken: false, + }, + method: "post", + data: data, + }); +} + +// 注册方法 +export function register(data) { + return request({ + url: "/register", + headers: { + isToken: false, + }, + method: "post", + data: data, + }); +} + +// 获取用户详细信息 +export function getInfo() { + return request({ + url: "/getInfo", + method: "get", + }); +} + +// 退出方法 +export function logout() { + return request({ + url: "/logout", + method: "post", + }); +} + +// 获取验证码 +export function getCodeImg() { + return request({ + url: "/captchaImage", + headers: { + isToken: false, + }, + method: "get", + timeout: 20000, + }); +} diff --git a/shierfeng-fastapi-app/src/api/system/dict/data.js b/shierfeng-fastapi-app/src/api/system/dict/data.js new file mode 100644 index 0000000..3fd149f --- /dev/null +++ b/shierfeng-fastapi-app/src/api/system/dict/data.js @@ -0,0 +1,52 @@ +import request from "@/utils/request"; + +// 查询字典数据列表 +export function listData(query) { + return request({ + url: "/system/dict/data/list", + method: "get", + params: query, + }); +} + +// 查询字典数据详细 +export function getData(dictCode) { + return request({ + url: "/system/dict/data/" + dictCode, + method: "get", + }); +} + +// 根据字典类型查询字典数据信息 +export function getDicts(dictType) { + return request({ + url: "/system/dict/data/type/" + dictType, + method: "get", + }); +} + +// 新增字典数据 +export function addData(data) { + return request({ + url: "/system/dict/data", + method: "post", + data: data, + }); +} + +// 修改字典数据 +export function updateData(data) { + return request({ + url: "/system/dict/data", + method: "put", + data: data, + }); +} + +// 删除字典数据 +export function delData(dictCode) { + return request({ + url: "/system/dict/data/" + dictCode, + method: "delete", + }); +} diff --git a/shierfeng-fastapi-app/src/api/system/dict/type.js b/shierfeng-fastapi-app/src/api/system/dict/type.js new file mode 100644 index 0000000..a0c99f6 --- /dev/null +++ b/shierfeng-fastapi-app/src/api/system/dict/type.js @@ -0,0 +1,60 @@ +import request from "@/utils/request"; + +// 查询字典类型列表 +export function listType(query) { + return request({ + url: "/system/dict/type/list", + method: "get", + params: query, + }); +} + +// 查询字典类型详细 +export function getType(dictId) { + return request({ + url: "/system/dict/type/" + dictId, + method: "get", + }); +} + +// 新增字典类型 +export function addType(data) { + return request({ + url: "/system/dict/type", + method: "post", + data: data, + }); +} + +// 修改字典类型 +export function updateType(data) { + return request({ + url: "/system/dict/type", + method: "put", + data: data, + }); +} + +// 删除字典类型 +export function delType(dictId) { + return request({ + url: "/system/dict/type/" + dictId, + method: "delete", + }); +} + +// 刷新字典缓存 +export function refreshCache() { + return request({ + url: "/system/dict/type/refreshCache", + method: "delete", + }); +} + +// 获取字典选择框列表 +export function optionselect() { + return request({ + url: "/system/dict/type/optionselect", + method: "get", + }); +} diff --git a/shierfeng-fastapi-app/src/api/system/user.js b/shierfeng-fastapi-app/src/api/system/user.js new file mode 100644 index 0000000..2daec2d --- /dev/null +++ b/shierfeng-fastapi-app/src/api/system/user.js @@ -0,0 +1,41 @@ +import upload from "@/utils/upload"; +import request from "@/utils/request"; + +// 用户密码重置 +export function updateUserPwd(oldPassword, newPassword) { + const data = { + oldPassword, + newPassword, + }; + return request({ + url: "/system/user/profile/updatePwd", + method: "put", + data: data, + }); +} + +// 查询用户个人信息 +export function getUserProfile() { + return request({ + url: "/system/user/profile", + method: "get", + }); +} + +// 修改用户个人信息 +export function updateUserProfile(data) { + return request({ + url: "/system/user/profile", + method: "put", + data: data, + }); +} + +// 用户头像上传 +export function uploadAvatar(data) { + return upload({ + url: "/system/user/profile/avatar", + name: data.name, + filePath: data.filePath, + }); +} diff --git a/shierfeng-fastapi-app/src/auto-imports.d.ts b/shierfeng-fastapi-app/src/auto-imports.d.ts new file mode 100644 index 0000000..9c97fd2 --- /dev/null +++ b/shierfeng-fastapi-app/src/auto-imports.d.ts @@ -0,0 +1,109 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue').EffectScope + const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate + const computed: typeof import('vue').computed + const createApp: typeof import('vue').createApp + const createPinia: typeof import('pinia').createPinia + const customRef: typeof import('vue').customRef + const defineAsyncComponent: typeof import('vue').defineAsyncComponent + const defineComponent: typeof import('vue').defineComponent + const defineStore: typeof import('pinia').defineStore + const effectScope: typeof import('vue').effectScope + const getActivePinia: typeof import('pinia').getActivePinia + const getCurrentInstance: typeof import('vue').getCurrentInstance + const getCurrentScope: typeof import('vue').getCurrentScope + const getCurrentWatcher: typeof import('vue').getCurrentWatcher + const h: typeof import('vue').h + const inject: typeof import('vue').inject + const isProxy: typeof import('vue').isProxy + const isReactive: typeof import('vue').isReactive + const isReadonly: typeof import('vue').isReadonly + const isRef: typeof import('vue').isRef + const isShallow: typeof import('vue').isShallow + const mapActions: typeof import('pinia').mapActions + const mapGetters: typeof import('pinia').mapGetters + const mapState: typeof import('pinia').mapState + const mapStores: typeof import('pinia').mapStores + const mapWritableState: typeof import('pinia').mapWritableState + const markRaw: typeof import('vue').markRaw + const nextTick: typeof import('vue').nextTick + const onActivated: typeof import('vue').onActivated + const onAddToFavorites: typeof import('@dcloudio/uni-app').onAddToFavorites + const onBackPress: typeof import('@dcloudio/uni-app').onBackPress + const onBeforeMount: typeof import('vue').onBeforeMount + const onBeforeUnmount: typeof import('vue').onBeforeUnmount + const onBeforeUpdate: typeof import('vue').onBeforeUpdate + const onDeactivated: typeof import('vue').onDeactivated + const onError: typeof import('@dcloudio/uni-app').onError + const onErrorCaptured: typeof import('vue').onErrorCaptured + const onHide: typeof import('@dcloudio/uni-app').onHide + const onLaunch: typeof import('@dcloudio/uni-app').onLaunch + const onLoad: typeof import('@dcloudio/uni-app').onLoad + const onMounted: typeof import('vue').onMounted + const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app').onNavigationBarButtonTap + const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app').onNavigationBarSearchInputChanged + const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app').onNavigationBarSearchInputClicked + const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app').onNavigationBarSearchInputConfirmed + const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app').onNavigationBarSearchInputFocusChanged + const onPageNotFound: typeof import('@dcloudio/uni-app').onPageNotFound + const onPageScroll: typeof import('@dcloudio/uni-app').onPageScroll + const onPullDownRefresh: typeof import('@dcloudio/uni-app').onPullDownRefresh + const onReachBottom: typeof import('@dcloudio/uni-app').onReachBottom + const onReady: typeof import('@dcloudio/uni-app').onReady + const onRenderTracked: typeof import('vue').onRenderTracked + const onRenderTriggered: typeof import('vue').onRenderTriggered + const onResize: typeof import('@dcloudio/uni-app').onResize + const onScopeDispose: typeof import('vue').onScopeDispose + const onServerPrefetch: typeof import('vue').onServerPrefetch + const onShareAppMessage: typeof import('@dcloudio/uni-app').onShareAppMessage + const onShareTimeline: typeof import('@dcloudio/uni-app').onShareTimeline + const onShow: typeof import('@dcloudio/uni-app').onShow + const onTabItemTap: typeof import('@dcloudio/uni-app').onTabItemTap + const onThemeChange: typeof import('@dcloudio/uni-app').onThemeChange + const onUnhandledRejection: typeof import('@dcloudio/uni-app').onUnhandledRejection + const onUnload: typeof import('@dcloudio/uni-app').onUnload + const onUnmounted: typeof import('vue').onUnmounted + const onUpdated: typeof import('vue').onUpdated + const onWatcherCleanup: typeof import('vue').onWatcherCleanup + const provide: typeof import('vue').provide + const reactive: typeof import('vue').reactive + const readonly: typeof import('vue').readonly + const ref: typeof import('vue').ref + const resolveComponent: typeof import('vue').resolveComponent + const setActivePinia: typeof import('pinia').setActivePinia + const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix + const shallowReactive: typeof import('vue').shallowReactive + const shallowReadonly: typeof import('vue').shallowReadonly + const shallowRef: typeof import('vue').shallowRef + const storeToRefs: typeof import('pinia').storeToRefs + const toRaw: typeof import('vue').toRaw + const toRef: typeof import('vue').toRef + const toRefs: typeof import('vue').toRefs + const toValue: typeof import('vue').toValue + const triggerRef: typeof import('vue').triggerRef + const unref: typeof import('vue').unref + const useAttrs: typeof import('vue').useAttrs + const useCssModule: typeof import('vue').useCssModule + const useCssVars: typeof import('vue').useCssVars + const useId: typeof import('vue').useId + const useModel: typeof import('vue').useModel + const useSlots: typeof import('vue').useSlots + const useTemplateRef: typeof import('vue').useTemplateRef + const watch: typeof import('vue').watch + const watchEffect: typeof import('vue').watchEffect + const watchPostEffect: typeof import('vue').watchPostEffect + const watchSyncEffect: typeof import('vue').watchSyncEffect +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/shierfeng-fastapi-app/src/config.js b/shierfeng-fastapi-app/src/config.js new file mode 100644 index 0000000..297587f --- /dev/null +++ b/shierfeng-fastapi-app/src/config.js @@ -0,0 +1,26 @@ +// 应用全局配置 +export default { + baseUrl: "http://localhost:9099", + // 应用信息 + appInfo: { + // 应用名称 + name: "ShiErFeng-FastAPI-APP", + // 应用版本 + version: "1.9.0", + // 应用logo + logo: "/static/logo.png", + // 官方网站 + site_url: "https://vfadmin.insistence.tech", + // 政策协议 + agreements: [ + { + title: "隐私政策", + url: "/pages/common/privacy/index", + }, + { + title: "用户服务协议", + url: "/pages/common/agreement/index", + }, + ], + }, +}; diff --git a/shierfeng-fastapi-app/src/env.d.ts b/shierfeng-fastapi-app/src/env.d.ts new file mode 100644 index 0000000..f5d8917 --- /dev/null +++ b/shierfeng-fastapi-app/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + + const component: DefineComponent; + export default component; +} diff --git a/shierfeng-fastapi-app/src/main.ts b/shierfeng-fastapi-app/src/main.ts new file mode 100644 index 0000000..255aa09 --- /dev/null +++ b/shierfeng-fastapi-app/src/main.ts @@ -0,0 +1,17 @@ +// @ts-nocheck +import { createSSRApp } from "vue"; +import App from "./App.vue"; +import store from "./store"; // store +import { install } from "./plugins"; // plugins +import "./permission"; // permission +import { useDict } from "@/utils/dict"; + +export function createApp() { + const app = createSSRApp(App); + app.use(store); + app.config.globalProperties.useDict = useDict; + install(app); + return { + app, + }; +} diff --git a/shierfeng-fastapi-app/src/manifest.json b/shierfeng-fastapi-app/src/manifest.json new file mode 100644 index 0000000..b9f9167 --- /dev/null +++ b/shierfeng-fastapi-app/src/manifest.json @@ -0,0 +1,70 @@ +{ + "name": "ShiErFeng-FastAPI移动端", + "appid": "__UNI__25A9D80", + "description": "", + "versionName": "1.9.0", + "versionCode": "100", + "transformPx": false, + "app-plus": { + "usingComponents": true, + "nvueCompiler": "uni-app", + "splashscreen": { + "alwaysShowBeforeRender": true, + "waiting": true, + "autoclose": true, + "delay": 0 + }, + "modules": {}, + "distribute": { + "android": { + "permissions": [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "ios": { + "dSYMs": false + }, + "sdkConfigs": {} + } + }, + "quickapp": {}, + "mp-weixin": { + "appid": "wxccd7e2a0911b3397", + "setting": { + "urlCheck": false, + "es6": false, + "minified": true, + "postcss": true + }, + "optimization": { + "subPackages": true + }, + "usingComponents": true + }, + "vueVersion": "3", + "h5": { + "devServer": { + "port": 9090, + "https": false + }, + "title": "ShiErFeng-FastAPI-APP", + "router": { + "mode": "hash", + "base": "./" + } + } +} diff --git a/shierfeng-fastapi-app/src/pages.json b/shierfeng-fastapi-app/src/pages.json new file mode 100644 index 0000000..8216113 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages.json @@ -0,0 +1,132 @@ +{ + "pages": [ + { + "path": "pages/login", + "style": { + "navigationBarTitleText": "登录" + } + }, + { + "path": "pages/register", + "style": { + "navigationBarTitleText": "注册" + } + }, + { + "path": "pages/index", + "style": { + "navigationBarTitleText": "ShiErFeng-FastAPI移动端", + "navigationStyle": "custom" + } + }, + { + "path": "pages/work/index", + "style": { + "navigationBarTitleText": "工作台" + } + }, + { + "path": "pages/mine/index", + "style": { + "navigationBarTitleText": "我的" + } + }, + { + "path": "pages/mine/avatar/index", + "style": { + "navigationBarTitleText": "修改头像" + } + }, + { + "path": "pages/mine/info/index", + "style": { + "navigationBarTitleText": "个人信息" + } + }, + { + "path": "pages/mine/info/edit", + "style": { + "navigationBarTitleText": "编辑资料" + } + }, + { + "path": "pages/mine/pwd/index", + "style": { + "navigationBarTitleText": "修改密码" + } + }, + { + "path": "pages/mine/setting/index", + "style": { + "navigationBarTitleText": "应用设置" + } + }, + { + "path": "pages/mine/help/index", + "style": { + "navigationBarTitleText": "常见问题" + } + }, + { + "path": "pages/mine/about/index", + "style": { + "navigationBarTitleText": "关于我们" + } + }, + { + "path": "pages/common/agreement/index", + "style": { + "navigationBarTitleText": "用户协议" + } + }, + { + "path": "pages/common/privacy/index", + "style": { + "navigationBarTitleText": "隐私协议" + } + }, + { + "path": "pages/common/webview/index", + "style": { + "navigationBarTitleText": "浏览网页" + } + }, + { + "path": "pages/common/textview/index", + "style": { + "navigationBarTitleText": "浏览文本" + } + } + ], + "tabBar": { + "color": "#000000", + "selectedColor": "#000000", + "borderStyle": "white", + "backgroundColor": "#ffffff", + "list": [ + { + "pagePath": "pages/index", + "iconPath": "static/images/tabbar/home.png", + "selectedIconPath": "static/images/tabbar/home_.png", + "text": "首页" + }, + { + "pagePath": "pages/work/index", + "iconPath": "static/images/tabbar/work.png", + "selectedIconPath": "static/images/tabbar/work_.png", + "text": "工作台" + }, + { + "pagePath": "pages/mine/index", + "iconPath": "static/images/tabbar/mine.png", + "selectedIconPath": "static/images/tabbar/mine_.png", + "text": "我的" + } + ] + }, + "globalStyle": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "ShiErFeng", + "navigationBarBackgroundColor": "#FFFFFF" + } +} diff --git a/shierfeng-fastapi-app/src/pages/common/agreement/index.vue b/shierfeng-fastapi-app/src/pages/common/agreement/index.vue new file mode 100644 index 0000000..503928b --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/common/agreement/index.vue @@ -0,0 +1,63 @@ + + + diff --git a/shierfeng-fastapi-app/src/pages/common/privacy/index.vue b/shierfeng-fastapi-app/src/pages/common/privacy/index.vue new file mode 100644 index 0000000..13f72c8 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/common/privacy/index.vue @@ -0,0 +1,87 @@ + + + diff --git a/shierfeng-fastapi-app/src/pages/common/textview/index.vue b/shierfeng-fastapi-app/src/pages/common/textview/index.vue new file mode 100644 index 0000000..fa6d984 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/common/textview/index.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/common/webview/index.vue b/shierfeng-fastapi-app/src/pages/common/webview/index.vue new file mode 100644 index 0000000..12087db --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/common/webview/index.vue @@ -0,0 +1,33 @@ + + + diff --git a/shierfeng-fastapi-app/src/pages/index.vue b/shierfeng-fastapi-app/src/pages/index.vue new file mode 100644 index 0000000..5b5f1a4 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/index.vue @@ -0,0 +1,208 @@ + + + diff --git a/shierfeng-fastapi-app/src/pages/login.vue b/shierfeng-fastapi-app/src/pages/login.vue new file mode 100644 index 0000000..4c80816 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/login.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/about/index.vue b/shierfeng-fastapi-app/src/pages/mine/about/index.vue new file mode 100644 index 0000000..5606daf --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/about/index.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/avatar/index.vue b/shierfeng-fastapi-app/src/pages/mine/avatar/index.vue new file mode 100644 index 0000000..4451768 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/avatar/index.vue @@ -0,0 +1,678 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/help/index.vue b/shierfeng-fastapi-app/src/pages/mine/help/index.vue new file mode 100644 index 0000000..01ae6b9 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/help/index.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/index.vue b/shierfeng-fastapi-app/src/pages/mine/index.vue new file mode 100644 index 0000000..cbbca90 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/index.vue @@ -0,0 +1,209 @@ + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/info/edit.vue b/shierfeng-fastapi-app/src/pages/mine/info/edit.vue new file mode 100644 index 0000000..91e009c --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/info/edit.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/info/index.vue b/shierfeng-fastapi-app/src/pages/mine/info/index.vue new file mode 100644 index 0000000..f62fd1e --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/info/index.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/pwd/index.vue b/shierfeng-fastapi-app/src/pages/mine/pwd/index.vue new file mode 100644 index 0000000..97cca3f --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/pwd/index.vue @@ -0,0 +1,90 @@ + + + diff --git a/shierfeng-fastapi-app/src/pages/mine/setting/index.vue b/shierfeng-fastapi-app/src/pages/mine/setting/index.vue new file mode 100644 index 0000000..2000a42 --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/mine/setting/index.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/register.vue b/shierfeng-fastapi-app/src/pages/register.vue new file mode 100644 index 0000000..fbcd29d --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/register.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/shierfeng-fastapi-app/src/pages/work/index.vue b/shierfeng-fastapi-app/src/pages/work/index.vue new file mode 100644 index 0000000..0d8072a --- /dev/null +++ b/shierfeng-fastapi-app/src/pages/work/index.vue @@ -0,0 +1,235 @@ + + + diff --git a/shierfeng-fastapi-app/src/permission.js b/shierfeng-fastapi-app/src/permission.js new file mode 100644 index 0000000..8b45534 --- /dev/null +++ b/shierfeng-fastapi-app/src/permission.js @@ -0,0 +1,43 @@ +import { getToken } from "@/utils/auth"; + +// 登录页面 +const loginPage = "/pages/login"; + +// 页面白名单 +const whiteList = [ + "/pages/login", + "/pages/register", + "/pages/common/webview/index", + "/pages/common/agreement/index", + "/pages/common/privacy/index", +]; + +// 检查地址白名单 +function checkWhite(url) { + const path = url.split("?")[0]; + return whiteList.indexOf(path) !== -1; +} + +// 页面跳转验证拦截器 +let list = ["navigateTo", "redirectTo", "reLaunch", "switchTab"]; +list.forEach((item) => { + uni.addInterceptor(item, { + invoke(to) { + if (getToken()) { + if (to.url === loginPage) { + uni.reLaunch({ url: "/" }); + } + return true; + } else { + if (checkWhite(to.url)) { + return true; + } + uni.reLaunch({ url: loginPage }); + return false; + } + }, + fail(err) { + console.log(err); + }, + }); +}); diff --git a/shierfeng-fastapi-app/src/plugins/auth.js b/shierfeng-fastapi-app/src/plugins/auth.js new file mode 100644 index 0000000..ba7b6d5 --- /dev/null +++ b/shierfeng-fastapi-app/src/plugins/auth.js @@ -0,0 +1,60 @@ +import { useUserStore } from "@/store"; + +function authPermission(permission) { + const all_permission = "*:*:*"; + const permissions = useUserStore().permissions; + if (permission && permission.length > 0) { + return permissions.some((v) => { + return all_permission === v || v === permission; + }); + } else { + return false; + } +} + +function authRole(role) { + const super_admin = "admin"; + const roles = useUserStore().roles; + if (role && role.length > 0) { + return roles.some((v) => { + return super_admin === v || v === role; + }); + } else { + return false; + } +} + +export default { + // 验证用户是否具备某权限 + hasPermi(permission) { + return authPermission(permission); + }, + // 验证用户是否含有指定权限,只需包含其中一个 + hasPermiOr(permissions) { + return permissions.some((item) => { + return authPermission(item); + }); + }, + // 验证用户是否含有指定权限,必须全部拥有 + hasPermiAnd(permissions) { + return permissions.every((item) => { + return authPermission(item); + }); + }, + // 验证用户是否具备某角色 + hasRole(role) { + return authRole(role); + }, + // 验证用户是否含有指定角色,只需包含其中一个 + hasRoleOr(roles) { + return roles.some((item) => { + return authRole(item); + }); + }, + // 验证用户是否含有指定角色,必须全部拥有 + hasRoleAnd(roles) { + return roles.every((item) => { + return authRole(item); + }); + }, +}; diff --git a/shierfeng-fastapi-app/src/plugins/index.js b/shierfeng-fastapi-app/src/plugins/index.js new file mode 100644 index 0000000..886e74a --- /dev/null +++ b/shierfeng-fastapi-app/src/plugins/index.js @@ -0,0 +1,12 @@ +import tab from "./tab"; +import auth from "./auth"; +import modal from "./modal"; + +export function install(app) { + // 页签操作 + app.config.globalProperties.$tab = tab; + // 认证对象 + app.config.globalProperties.$auth = auth; + // 模态框对象 + app.config.globalProperties.$modal = modal; +} diff --git a/shierfeng-fastapi-app/src/plugins/modal.js b/shierfeng-fastapi-app/src/plugins/modal.js new file mode 100644 index 0000000..175d11c --- /dev/null +++ b/shierfeng-fastapi-app/src/plugins/modal.js @@ -0,0 +1,78 @@ +export default { + // 消息提示 + msg(content) { + uni.showToast({ + title: content, + icon: "none", + }); + }, + // 错误消息 + msgError(content) { + uni.showToast({ + title: content, + icon: "error", + }); + }, + // 成功消息 + msgSuccess(content) { + uni.showToast({ + title: content, + icon: "success", + }); + }, + // 隐藏消息 + hideMsg(content) { + uni.hideToast(); + }, + // 弹出提示 + alert(content, title) { + uni.showModal({ + title: title || "系统提示", + content: content, + showCancel: false, + }); + }, + // 确认窗体 + confirm(content, title) { + return new Promise((resolve, reject) => { + uni.showModal({ + title: title || "系统提示", + content: content, + cancelText: "取消", + confirmText: "确定", + success: function (res) { + if (res.confirm) { + resolve(res.confirm); + } + }, + }); + }); + }, + // 提示信息 + showToast(option) { + if (typeof option === "object") { + uni.showToast(option); + } else { + uni.showToast({ + title: option, + icon: "none", + duration: 2500, + }); + } + }, + // 打开遮罩层 + loading(content) { + uni.showLoading({ + title: content, + icon: "none", + }); + }, + // 关闭遮罩层 + closeLoading() { + try { + uni.hideLoading(); + } catch (e) { + console.log(e); + } + }, +}; diff --git a/shierfeng-fastapi-app/src/plugins/tab.js b/shierfeng-fastapi-app/src/plugins/tab.js new file mode 100644 index 0000000..c68475b --- /dev/null +++ b/shierfeng-fastapi-app/src/plugins/tab.js @@ -0,0 +1,30 @@ +export default { + // 关闭所有页面,打开到应用内的某个页面 + reLaunch(url) { + return uni.reLaunch({ + url: url, + }); + }, + // 跳转到tabBar页面,并关闭其他所有非tabBar页面 + switchTab(url) { + return uni.switchTab({ + url: url, + }); + }, + // 关闭当前页面,跳转到应用内的某个页面 + redirectTo(url) { + return uni.redirectTo({ + url: url, + }); + }, + // 保留当前页面,跳转到应用内的某个页面 + navigateTo(url) { + return uni.navigateTo({ + url: url, + }); + }, + // 关闭当前页面,返回上一页面或多级页面 + navigateBack() { + return uni.navigateBack(); + }, +}; diff --git a/shierfeng-fastapi-app/src/static/favicon.ico b/shierfeng-fastapi-app/src/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6f07782abfc7881a73d3f47a00b761c183e06ff9 GIT binary patch literal 16958 zcmeI1Z)lW95XSfJqDezYN}8rYBqt&wBDRQ#NH9S}`awjB6cH&RR+IaarXpfR)DlFb zh=>#s5s}vV!CI~H?vjewAT~$|B0)q%Ob{C+NR$wa5bpYW-n+GzGl`d)Tq3*|=4RjB z*`1kZW@q=k7~|ourpE9+%gkG0Ot~>;9z>Vf52@ez)Fhev?IPeJ;3D86;3D86;3D86 z;3D86;3D86;3D86;3AMK1foH+JQgzNVqw$JTyN&&O7CRrX%3nX@p%uvKY@CnM03cT znQXfzT7Og6l;L{_e_Np!&@ztn|9kuk&0*mkI7e>91WFMsH5xfu5*mkfh9dBF5b3Nj;06R}W zvR`>_rM4$Rk}X%U^tqnEmN0w108jo8jZ-YY|C&!zo^+VDAGbdNd8 z^X;_r)DI@*&`$c=Az`Yug@I(Bw1mx*OSK;#{bkF-oS&cQFN9>|bU)uK3F~%OJ(^qqrLJGAGu z?j}2Kc_ipv#X470UaP|ben>-UnGpzi(a@JbHe*Fn9UzC5+C9XQuiVy1usIW{8{7K@^JlQ0 za@YDTZu9&aHjaoZa=PEQ!L1z~Jim+tOpR<#<)$6ens@PGf%V6cQLX$BBjKQWx;|Yd zvmFh1%A>*Qp=ii=p7nZ&o#LgO&>j-bJE+G+ne*&3;|16`8-55<+!B!HYMn=n-Gpx@ z4ny+X&bJ&pw<71%FSz2M7+k$lo7BzoQONqQaplQrJ71i5?x0V)wQnTT5B-=fGg3Pm z_D*jKez5rBwu;Xqf$6)McNI$JZP;7@oB6}dMP?SHJu5$NLru_m=#*-BOZ}U9p1BUb z>)vu2lI^lv_V=Q*-pb;e-u>jW#zo2j;Zb8{n(B%xqam;Mzv3pD2h_%gzi$0xNS;{v z(KxmjP(O-d4W>9&Z)TIbZ|v9f`I5(vGISQC_h-{)ZPg3a2fOdp{+qL@*v{uU09kx2 z27TbN*UE^;g1*Dx_Bu~kqwlw^GD&djlGBQP5Z_Ar8p0%72vD=@iI+Dvcl^Q zF|M$F!~&j5&U59D&cY7X?urJdowwstjbq;;>{Kqx7v=6#_I-?Z_#t_O|4ULbPVJ*D z|0Ul;{y6tV>)VCeiq*tj7^(&ei|$A;f2AFtN*vpCZ?XGYG3&zDk!sD^G+CP~kJ$q| zGkqc_4j`-jB3!${`6hi+>K&N+qI;qCs^WAXzx#Q=e_iu3eZq&M$fchV!a%jIHB%N} z3#f%>7!w9szZaZ_eV5Ffn~P`e?|PTfd9SlpdE7_Nj(S#Isx7Ta?QQvEC`UTs$WO4T{jx?SYNFF&i-bdhSAob zrdEO5HRNLgrwsXcqj(T5- z&g0no<-4p$O>0d0?9>4A-_sQdO&fAw%4U6R8S%X)oB3mv;Z9SC&UZe8@!kFor2JPN zv{M&`y)%r}#`i%Ue^mpu=jFf7|C`|YUjM&mEV$#Z-i7)h$A9Yj?_-(&zRTjT-t!gz z4(OTgk?*@t+1k(VLHZr}6n^X8=}!t3A6C)>7M z=;z$tsGOJYkn?|mT z>h3?Q;rsvj_PIZii2MH9HIUD6Nztyo9)_0}?Wx|+aJ;C>-&q~^4OUe72mAvigB6t} ziG$Cp2ddzFEPuYIgz1R{{GfQ}U`eBR*I@n)@z!{Lm-vl%{($(wME>ADxIf{Gi&rNc k^WuqwvkX1XGQ;pA^y%ZMhx_%w$M1Y6;?*ghWE1=SFG{UvNdN!< literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/banner/banner01.jpg b/shierfeng-fastapi-app/src/static/images/banner/banner01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c44d84c908b2bcb048e4dbe438d1f3ff48058f01 GIT binary patch literal 39640 zcmeFYWl&sA*ETx12G;<=1A{w*2A2ss3~s@K4(=A*0>L#n3@*Vv5Q1y)1a}P(JU~Lu zko$h#@2#)ibL!Oj_5C0tzdzpr zgmNAh_C5e404gF|902&Uf#PK2?(Qtg&F$#MWoGGQZpCHc1mpHLbLQsd;^78}zwmZ8 zv#_^vr!%**wsn+XIPK_WptH4PRc_}W{DSTekjq!afR z^@cgatlZ7$ykQQGZlc~241a}-BGQl9+zfPoi@4iMFi1bPN~fo)K_~0vYDFi=#m{NM z!^=x2B*eulASfin%R$G-!^_9bgZLBTLPT}9S5Kj^e z{|?H_%Ztm4pUcVBnwwWdMC4HhA0H>81gD#iqq~_mr=uI=e4#5PF6@xSXf?K zjz>UVj+YlC%P%L*FT%qwFC@zY;+6f6jQ^>wfV7N)ppg7yO(9-hc{ynjIaxVDerY~I z1tA$8dEWojRd#f9H*>VG`cJ#Ih<5+2EBpUgS5(&3%FNx#73$>V@E-(d*f_a6x!E{5 z)5&TH(Xr~G4v&abHL|Iog$oS?jjfP%2R;6LLQ zk>TM}kmKhO<`a>V77&nO__wa*|Cc%AMs$Yz5gh*wEdR+uAn-ByU(iQ9{Fm}rIU+R1 z6`>k`P5^j+0S~b3L8u_opCv#T038(-6%7>~4GkR&9r2HYiH?qmgNu!ggN=<#fcy7H zfQyGufRBeuOiV&TOiWEhMMX{fUq2)a42&nZPbdfoD4voMl0N;f7yoY)e|i9fm`HfY z^(aV$0AxZW6hfpweSlE_02vtt3F&_^2mlEg9R(E)0}~06`@g?Iyg(;Flt4u!Fws%5 z&~Y%(5RWJTLR2(*B6MD9Vhl|)7Y4rI#7|Q)5Jr-MI&;?$(hYts%pXbh)3R=%Ok{<< z77a7xXPe2N1>94Zg@h^Opq7I2+Rwvgtx|OmJwpgbM#n-yMn_0}oIPYh6nb7FRB43V zUw77|4JiY7GLqb*UJ1I%HI8 zz-9s+27pirgcJn`{+l59TOJ7jLh7fJ!b3$O!Fv>n3PA{xjsnDBAfp1-ky)uR|2E-_ zM1m_7{8%3lL=l9Es0$$YCyM~hK>YK{|KGL$!)Fb7Z}dTbSum)~hg7Mss6<0%72+Mk z&fo(H((3TvdhgPv^HV!#Ng}pZdp?@Vw=&tcVj$@^-wVMarA*HpmZ-$*tW4cAYohsc z;z@L?*7MP{GvjTIA-V*)sGd+7>udxV{)0UL0&r46n8nlpDO@R31bl)p|II?Ker9$O zhCmg7A;ATJKre`Z4xJ$==pW1$V?F|t0OcR#Nj;($B^V%u$BIedOec@>7vcz{Bg7#@ z_W`6r9}@`ii1G-i|HU^l0=ADrk%0(7bbUytv>iP;5l3@S{LFld;R{-#L&?bDPNE}; z=i_f0Ox{DPn}9w_Od@IpiQ6fe)Z>+u9#PvJuRVW(bGK%i0?Ea>cU=TcIWC)vv0Y2@ zc8=6W2ACpQu0JGo6T}`nmg3e6I0^}!?3Uf;@>lu@S8$klkVOBQ4hEf3V8kFMtnUwi zB;$(XlwSOYb$HvqQx*keB4ps9JAT(Nfm!^|!2eI0uooTOe*p9yZ{}a@{++OY=IbY^ z9MHzUeKBRJ%*6^XKc$*QZ;WNvOQOzOT<-3}wYGNyStXXb)U|jCDvq7SX%AOx zSx)?Bcp*@zCa!?A8T@zN$y^Q%R(ti=Cox=h(m6Lfh6)Iz>7ogPF&wr&T*y3pxjiq&64$?*H&xXcRK*-!KQ*1FC|si(@lHI3i>!5tw3kgpWnm|Hu}UUCq-E*;}|+J&!?5F zNN3SV0}a$6VNs#|nqdbqTtZydD+k#69gG)jX2E|K;Ul?t{qZ}Te;46dcph)IbDoc% zx=e!|73V>Hr3b`BkJy;6BcA0{sLFq8$GA=`kojD~PE@P*{tN4oe)YA`D3!Itf!w8^K__s8~xCovS)Z^iCZ2Nqf0p zFCVWiWhOJ*SN{Z|0p+Ao6t*YyN-kXVVA(Q$SRYX;(l#!Fp_|9*G zBRuh3$I(jxJBXFi-Ei?T9oszoXkZIdBH7V?fs9ZUiW9_21!Mn`8u0YeiqL;5Ooij+ z2KAq$n9I(ZBU3rmEV=*_^7mQ2AZ0ujjDFzQnr-L`yZDrgzBh7W*zc7n1A(TJlo8}x zO80~?ez=g5|JNvZMDwHK01#Y^$U@SI=edyqI3qpcI0(Vwk6cVBC51pa0`3rmz90x1 zssGVFL6{W?7Dws{LRcIK?IB?sxYSuH0@H=`F`z@6^5 z!c$g6JKAybn}L*>0^6%=tysRSh|+B1JMDAC8wQQKCMM4)qL3Kro*;(c9)x-V5J^-_ zy`w5&Lzo!1_Do)?50(KN;hIA@R&1ZQ`f4=JR2pV_^GpQit4XQtlS0+Bh|TGYEy%}n z9WW_#{&vcY-dg%@h1{g~>J7I4jtt1-$#zahD6|wpp#>2N9U$fL${%bM`>4<^=h1`d zo_)tZ(}%8Lrs4f7Z}{UYMQq_s4AAy07j*;AzZ%_W#T-ayAtqM-nd4o+#J?IX==8iv zB3|s$_SZx>XwT9g8sE6e^g#Jnqt|~}2e+Mf{N4SbNeG-M%Oc}_v1q)YC`>>=ffupu zN+AqL1QR0GoesOvC$jw?13^t^fur^$g&-=r>rx zR4a;yXAd~F!M%rU4s&B#jr^h)DUGJ!Gkw8*F9oj}h)?wrE(d+&yct^5t4YN*5fa=Fb~ zg=2J9q{d9DlX}FoPCoVm=kOAdT1<&XoPp|>whQ%C@+UajDvclQk4dAEVt&L1iK5Of z_OJm?)j;n)KXq8sIui9V)U~}(sat&G*`;=bRoP|C3L~+im$#DN33BQ47jU?DsWBY- zp)D*sYIP;>PO^C0@k3F2dDjc@&h^A5m1oTUfXcgqJ~VYS!p0LO!dUAkV+Moh_8oOu z0jrnbiTp33gX)@v%~y855vhYHtR7?aTtGGrHPn07=R&>TtwO9N#yi(5nPtghX6$Dd zYrdJwjGDKtM^YwB1ANw(Me|VDKD*R&U}mC4Ed`Bv zf*5TbI~r9bT0xi_G_IK0JTRxWT(TCqtznVBL5+0C`EX)1#i$iz$h1EBQEe{s1sR{L%04`A`d=I7gl1KP86 zU+kKeozMjzMfpuqd6v^sN*3- z?h}bND#066(+)iQd5d3udCevzMz_JkRH+e3V^dqDBdFsZ`N^KViMtfuO!wcBx*)rr} z2S5s6SKsQ_>KFPM#;CH6VX-4BM`H$UHFIs=x2W(?NmMy$3oDb1c2%^`xRp2SHtJUu zyQoTjTQ1HS^cItjJ4;Hc0G&wps&Nb0DJXpS-wut)wpRV-L{`(z&)kR#!&+^gv0#|{ ztrfE7@AiC6Wp5lk4C(<*|0rM_=D(aEVOB8pzPI!=yOJ+p8%nJ@)|4ngEh`LuqFG+J)Zn>F44&t@G+n_q-bL5Oqi=NGXUM0S&AO+t;_hOfV)_YH9uu0nwSwUG3p}f zIT#y=3^>x9fkWe5mgw?@S#2m;4Cp`XSiKDSI_5K{e` z@Le*r0~&y#LZM;iPHLsyhCMWn@j|v5lh_jdN9~@)ZRuxry#@~lS zagNQ0QsHL5pOX0uc6XtkTs~NLtZSTjtIk@TpJf!qjO&vQ`>&Kg-5S0D$ z&-PI%)L%>s0u7r`4AURn>}`EIZ()(2HWZ$!r*5lPZ7A?yBa2 zLc0qM1Alp}W7G6Y4ukhw0_V7OD*IU2ve7QstRCn#E~s#22{6S!dq?6wdq>6$E=nsy z0pqE+?#^Hhi2Gj~!|U7H1n6aF7yNo0VPd2ZOKYT4BRgtwSo%m*XZsh5k2bS7^?g(< zwfn{8Z92AXS7TsntdPo8k-@9?cjJ((=h59n=Wl4w=|q@<6TNfG^Wu|ab4D3+I*^vK zsarTfA7>(q)V=EFn-~?}X0I=~-u!zvlG>iD?42=0NyTMwDY)`KreHjWrc@t53Sk!m zf-v*V5u11r+tlu}{lD7?5Z`epW49=~p}J-|E3qWAv3lZ3G)a~CdEI84Ff|N}t)U6Q z@DmzijJb|;PnwKAI8I%#`;cbDYJ3R~te9U;ul%(N*IuBSQ!cXlvZT6gwcLbPb0 z@*c^NrWEDdQ%Ba`U242aE?thBI@dn{= z2Nhc+s0Ace%XXAs2zTUM!CcG7z8FO8lr?$W&FQ~tYOwntdBrUMsRWZtd?^M7l+x57 z#$?m^hUGq>OIJj4^j7d|`<@6Jo7@i?|C^sJ88=?;<0z@B;`{Q?Zdrr-4LNBwQM^3|Z5I;Xw7Iu`mvq{}FN)7reM`G<)SY?`FNn1d zIYmY!(0?bjjgaRR6~pG5faMI)=nAh+V>zY4+m#2=^}oN#j8Pl&-8?n=+NNuCiv?#t zTGf`fQneC`?`(SwiIY$nPO)lHHnyd3V%lkW>$7q4ntLT3%qrnFcf~6H^!1#w>~)%y z;Rvk&5nUp1`5s)hNW&?$islIcub!SuG+0 zs>+HsX6fU6 zvNbPeh4ndGKW%=Y**zYZyB0idcX~gHoW0AV3C*kTymdF=kimtsBk})U3FT z>etrenwbJQYM&6&!QuJ|$w0jO`p<5~X`Y{4+WDN(zd@-`|k!?x35+DUV+)B5c#N(q^ox=vHi3 zpQ=8qTADl64a(6qk+PhoC^GT14Dohc*O)Mu8c(>-fJxiw4UjB=%s z%7^Vp*LIw9+>s-pY{LRyfwwsTanW>nG{beov_bOW#D1CI-< zwSyfYZ=B0P<8?yO_o^qqct5W0YEF{)EX8xSh+C|rH_V;0k>ilA&gNG{#AH2tnif0r z%dBCP=0gYNws%YOf8=Z zS@w9ed*Bv#Hu);^_hjc$eYq9ZF}i44br&-3?dVR5<>GoeQ`>szX9&72`j4l~vsPv= zJlj}%RD!)6IlH_BS0^VmRvAzIm{Qp^isGBKhnSBzUchAfVn zw*rdr`bR&F6VV&ot+|GeVL$%aon2SiyNJRd|7~|(^l{Mmzd6v!Tn(>yX%~Bh^9ON` zdVErXw`%2+k}}&Rk)^~{!W1kjEc|Vj4d8C@P66R7nm`77lIK4&eos+1qUren4+m%}EE?!Y#ixHd6{wbb_PO;Et z2#sUQ$A)yv-k8Mqj#L(~RLW06^&&TXIh9GuQ!ky#Z0AfLb-DlO|<>+_*Ok3WFDNb9!r6CHa_7MGcE+ZCb3r1ARM z$VzaMR#L&Y@aEUVIr>fL@o)(^%DO z1RCR#@#FmU&$!*M<%;Re*`yxN99?stGNoZ=t!Zwf#pL^`zM@kx#sOX*ujESI*($9Gs!zeCu-91xuRv z(@u+ETW8qNlN=*Ycb)p7%a#`&3Tbnu;`n?*POGsFp2dFvcNO^8iF1`s2U?+O7c!D9dYU z`~bUj#$oO?@kvD)9GIoyC4_m(J|rr8E6Q2iDep(y<_k%WT-Ea^=`No!nw@Uu?hvgWF;ZP<;UX`>FP*~Cncol8nb$Qf-zV2EKRpq@ z;=^RZ?IQ;m6)*Xu3L>pCT{;+Qbto;gNBinrACKhR(u&%>+*#8!so*BJCa`ByVy8o8 zLJdlR1F4cwi)O<}vOdX}XwxiHOF~|ORc7;g{*g~pneD_6t zfh?pr;b??UiufM*tPDGozxqX$7p(^<4=Tk7T_=)etjf%2d|pWH)phP)X9JGkrMW3x zL<&b!91!~wx}849ssS}~$f+K!W?2B?qOh}=j|Bd>j4JN&N4w|KyMO*h;VV=l7?qNc zaf+mm?tk?9i{ji2O6_8C z{2uO=X99;Zkx72|da(t<)F>=YT zm)~#Gv1(@huTLL7<_hIm{CtsDRaD=l4Ck`MlPGNcnNYwaD#i<&+KX36J5>xzTrYiN zFUgZ$YQZ^gVi}rR`I*xC51{D-&#{(YXX2=r#Q^t*tL9ao-os;4hJ%;-%6dJ4iQAG> zUmGdoxfRnuEEwbpWq#K+y_;1}llW-D2!od-BAa>mt&xw5s+$^&**+>%xMiWg9==!o z0}vABJh7sF#`3=Hyj7jz#`Hs#j=tmk4l#7$v+~KDpdjM}uX>soy(i!3toKcG3#Yga z^)Ro|dV;AxO$=3l0(*4({PoNJRg)Qy()W5o?M187w7FSu97oK6qnKGGF?z}9y zD8Xo{E%c8g*3GnKmN%Mm>a=uPDC%o*=4Ur2*2^N(h~%i)*=U?`_e9RbMza~plFTWr za!Za6{Q13Loz&u#)=&XXCd65qUX~C!ZIqkW@zRXbNb@!h)FTqYMkIL?{^so@wXi2a z8t+NB*^+$#>ju~Pb^54ee#5F^4$ek@u;2`L`(fZYo}}h8<0ayjO;g6{Cf~=;iOwFi zoB}7sB_91BvizcyH|B--YAjycdPh1q)U~I!d~rSb^>Jk%g(BdD)JYO)js* zMYXn(rl`ds0F|jYS#fZ=d4q*KOVEN5DM$^VawSEn1-|Rb^xPnVPmhJf#uN2?A9(sU zKgl+WN@mgoAOoL{2DV$CoNoq}rJ6_a&A1mGT=n<8f)q_iB>esC1$nkMlxxh>OFBlQ zMyzVx!mMr>`V|dGWk>qLs?AGO``v~l=lEKoNN&lXllsX^=Jc_agH;PXpRxh?bh_uu zCs5Xkv6Lcyco4G22h<04dnMXip7#V-*7FSLyhGtbD+f zx^bY>yBnrd?`NVdESv&;cj`tqG%9PX0#}6*sU4{c>fNf^nhF?%ToHyY>|luN8L!vt zGZDoc$M1n#PS5?kzP~({=PyN|fTpAXJS~xo zU8byIXm9(rmYp2MiKgF-~Yx4TT1^-)n|` z+H`jGBIjZQO)<>eBR*5jTnYb{ryYUXul^9eHE{*yb?oMMFA0W}b6)S&=-emB3JZNx z5@!nWb}E0ZtRy$LNk^b|tKipKN^Sg9;MQYU!5v(_>u1tBh5yBM^~!uoXx*f(v(&f* zJe`S=k}8obqV$82O-piUtHrfJO`UPJ+p1EHM?a54*Puxxr1?H>* z(QrxG87sOs9IAMM%m&7!=s%w-?QLao~gO6QF)%M zh8qih7rBYKS@HGID(sBO8+Plye)|4r>OM7(-5PPT! zcH=CXi+49`E}b)XSDwIyI_03`e4ZYkv++b@QkDN0wsL6IZMWm#eJPAqY% z{g=b$*Fc*)XKtgxU4@dW1)FIbAA#%km&u&1S#eEGT$w-RcIaFD-M-b4J~6WK zJn_=3ZtD+uBAYQ8%)LUki z$+mM^aadW3-g)Xj1cBP-zW!QyX&lhcwZ>7?8p9rg4bGKiDyV3D+5VwXNi?y$2n8K( zTMiGs%vU4dJZ@QyhX-1R+Wkf=ch8yJ=9)cc&nzu-*tMN4T||63`zpmCLFH8>2=z|C zo@qQ_jL8xWZQ?s-5SpEWXA(=5tG)zySRiOn1;Ud+0w=}ixiiy*6KlaphB#90%ThiD zy<##*xRPkay<4kxhF;8p9G}tpY40VWxcgPn=ofRMzPVWf)P+~j1 znMDfx88?n@eCw6CL-Z~a*25fs+C7_y`TG*ALEmPPH^ zW$?Fi3Tnsj7;(;$Kh=gT%(Q5$bQ7x;10|KeOC*{-+6`k|i*Z8-SrB8ET+=qm1f7Sx z^5Mo9>_V^~l@}>~Kj=SZ!wJG{XsG`Kfd8T$E9R_R3EBAUfp2K9uoa|t$YA5_saTTD zHHfWD`+{|4Y4T~WNlt9TH;-09lnb*2LVAs_%>0t)!k$r^9d%pyn#afD$5wv7M>pRZ zkgdoBnkU)@z=sM3Cd@7s--BA)`43=5u^Sbfpo?|K64ycRK6xH%?2BU9Rr#dq?}pyF)bwV$f;XLkVVcRt@{Jk#{k}?d{X~FL-G0nT>E)o0#z@vG-b}N>h zUTVwzTl44-;9<@74 zOFmp-MA#wVRTqhMCch3wR+t63k*b$??{?wlbl=wbV5aH1^@-zy@ooGr2XkzvBf3g) z_FAQv&}!MpRgU{WL|4t;u`c#(ecOG%scqwxM8zkY!urmLUfK#XAdnp^(NG1opm}(S z`E}fzKJ7{m;cmX9IEEx3*++|`_f~U9aGFE7U=!OUq^UM4UAx{Kba3eiECF{m%OPyRmvEgUC*OJi4diICKAV=;e)2%{9!+ zuu@aub7d=9i>7=EJyPmRMp!j4i_cPxZ$NM0;CLn^o-udzY+x&gpUtadJmBP1*Rz#7 zTHjzZ$43@P&{1hQB^IzB_a!E#P^8AlEjf8SXN7p|?e05x`-~w)iBCr_!HROZYz**8 zBoWAm+R?N|%=+ocH!Zj~m4l;|U1JGzp6bQwhQ-YFky724d9LN`IPH%lI#Qaz9tS!l zHNPV0b%kpCuKVHzIcC{`TDV09b^k|uzhuF5fyNPFf+4)*6+svpW!bnqm<<{>w&n4j z4F~1DTU+XaZqM|TZ>Fz>)|WL+HMUe4c$rqJCHBPP`!hhfcWFIe+Y=X?1@qITtWSR6OIlX zH%{+HiyPE1?pnJCT88?aY6DMQT54`Ho#&qh&e*p?=mmwoS9*55<+~5=e5(&*l~S`< zO^CMZ4SR2-(n8hZiOp&0>~50(^s$34-w3fnn|>8)_ktUmi@mqoUW#8UEPYrYizDOu z-BxZb>se`7F!3tKX8ftfS%oUZmPW(l0|YRd0q5^|CF2YlU|VpL&TgSyxla)__yc&q zuwu~k;Xr^a7UVfUs|0OnFK1S>mgR6xv1eF4lUI1Rr{z>`S0B9tf%4^- zm`by{U>^_-2!l#PMHq$2wS!v%XBSpvO>e_j%e6i9Wr)suHL|0>5=Hb%ont067na4= ztfmg?i)K&oWQWaG_uME+NR7DUJkdsO;bEhvsyq1u5M?dL%2%_J^J60h+)BTIV!i~@ z`SZ?X1l1NR_22^KUV;FAkvtDT;uvYP-5wGCFF}x6p&=x=9ltuk6|?Q&Br{9)8A;xrt=O)8;Yeh$tlj zTS(IDBqTFMY-+u)}(`uYE{sC~Y zcGr~s0T`71xBWXr2yt4^_OdbDsy_6nNQ29b&o{H5=HF;jS@^&eSB+nwwRQ zAdiTQ1%ZY5^wNqF*EQFGk1QjiV#5Ov4b)M>X*ruV*z;*`xPeQ-lKC$_rT~w`j0n!$=b3WjkEquhE5tEReE1* zkSR8%GVPcq4Cc>uOl>fq{4DBk@p7r}(3@ZW9gm3YpjpysYMoJTzx;J11`1L3_VHvN zr6%|Ps5yWS*}k|slY9%?o9Ei_ePL(X4*J|IGFV@k-@J56wv_Bswu+8IiH zjy$yJ)44<>>_AHujDPsHD&P%zmAPhwY5_4l0;va^^*!{{VoW4}&O_mBvGXlGAni--A$V zZLKIyJ^TV>fTCe9?V$5jCms$Lclk2{$@WZAJ!&v z5S5Y>nopP%eX4g@I#ehRJ2DH1Wv=~Qy+Ds6z!^ZEk#dIp&{teb-wUwCrbC3_u7+p& z#9{(SLOF)TmLOrV8oWG8%&L_u?5F`eD5Fs)mbzW^T&oA@fILDaXbQ6SsYTTJ=uuD^ zXp#C1Y;7KV`H8Sdf6bJf$du zL_<3@F^DItIYVLrB7*Y2(+SHT0O$UjY6P7aA?T$0@i_1XaUA$dC%G2m5{36Ef8+B9V0u^8UZcK#ZuJn;9@Cy9 zDBx6B5dPB$CarwOI2Ajas#Si`;xMt?I4*D}(;R^)0iCO2CfK-*~s=&7A7T5QQVb?PvlhYV+Kiw8F;EhrB@Ev@ST7X5epT3N<`v3j--!uVx{b(I{ixvB9f_&#`>6+W zzoj+lGylZ0k4Kw%NBp;rt zw$Rpy0;^UH13_hC)G(gZ<&;KK1K&aeFJv?uQ@DQB(=63qn`*}MkA2LY`SLYDG185S z#@;*6<>L$QQ%yvWv8?^isSCe9fN`$+rd2m2CZRl@;{)C=F1rFruhYUJ8ykYtO{IcG zgZa=^V@FpTK6pxbV7Rki*U$e0>2je0;BU={& zCStUjKBs?=RY9=NR3hI73(d%)|3g_gb0$Ck&loDX`=ZA0K@6qPIz9#IZi0A3U^=ss zI-ok#b@oM46afp@uXB{+O48gR0BUfCzW}YnX3;m2P%NOD*^i`vChvGlKth#euiw(L z4$CZ^w3nNOaJEGKRYrc&jf-}Jh3(2d0pnA*5nH4bTAO1@R2 zwUnYP1Bm^iDMAhr8XH!Qkz!L;=|zc)%TLb0B=CvQ4}%X3Ohst~;eO_rPN}YorU;Ql zbjL_ZnX@>>PkHRMcy0!El58jtLZU{qoe~llt%h2j+L9!NCDINN`pp+DGyAr&x|LsW zg~hYSeD>Y*!h~Y#3L-{#OMCOb*JhqaA{-Vy#~1{Da%jpsvblJNzdwJtra(p|u#}5h zWJsWsB9vN3TysFWB4_A6Uk$xmjK3O*C-%Fgypq)YN$%8niMcI7j5Ax+p31C@%HEd> zD9&7W=D|SByiqeznxX7C{(OCv%)o&=;WOQ=Zyl~U$8hE=`Ln|VW1FLI%VCyRLT!=? z65_{_0^?d0nGR*-RGKWroy4(r=l1iDA&x-X+2<7o#G)U zGIufI!b@O&e`&wE)N-%JHexoWe)#J~i92IV;wck>Mab^-amuomQzX8@reE5x)qykC zNif_l7kFNL5!refM^kyoHJSCaXXH>;-1$lbqNJoLE&IK;bEZ|Di$PSVBVC*sJTt{V zdBM#Ums%q@V_~Q5nBb%a);To3oalG8f z7ufsVTiMq)9_M~Z|Ez`k&8IXRwF<2oVO5=lKK+q&!>{Ukps0=R`swV#ABr6f7kO>W zIEkm?Z1u-RVtB_2*0v;+u=bEc%LtR-TFKYCAs-|nKswe9u8oDtSKBTJ1Tos!zI+`o zXI|WB>jvg%fqrQ7F$MN^<2rh8-mkDr&ZObX&OF=to%EyovOB%3-=n4F+}>Lwq5|AY zT{;HhdQx1xDH%)!TlRoEUuQ^kYPo96aAd9DbrB9(0zfdJ@6D9L5%yl?o&c6+Kr9504&ferybjc-LGbrl+v7zaUS!HdH8pk-MTMWxM*dPZ)C6YE z(s{L^aKaUddtzYn*W{}T0Cu#9l^{)-@&hDahCeeQX=s@T#w(>}ck)X%NmsAsVWQjP^3MqI#p>^!FGxdD*Va9a4^a6sPn; zbVmO4J`uvU#^VQ?54h;Ax!<##N`20cd8M9fB?HG4n`?DGAtMdH?|fM)7BMLfA=0r` z?nj@w7dXD~T}O}YQEd#vU*x{*LEYyBVEbU;p*NC zM<&oh0WcHVCmS8FSiZbM7aRG!(J3C3CmoD3Q0ua>je3U*A(pLpu81LA{1Y00j$8uS zvRBDgcc&yiX1eO*RN>YF5(7o1riDERlrvg03NsLQPY~DthN!WTxNtwab`=mbp*2JJ zQ8F>iBTIl!=@=56A8(;t20~45)72qa5}@hI>T>963Bm#^EaJ@+m*t{dTLE2CY^})5 zXs6*`Hj_J*Dp}B)U8a)CPG~jSC8SlvD3Ug^RwKFgG0QO}N)|lh*IN$|Ff0*y&qUZv zpCCms`s55HNLkiPu#)KQD%8EowR3aLa$$=DWqVBwCs024tNDqLE|UI#U` z7`JZxjTaT4_N5}c?)>fhR@MIEBzyR1`k|YBt>=A2((B!C@}#?c$r>cvS(QbG%HxS& zT!yym6Z|Tu7?OT#QfWo~?R-gtrsu2Q4vx}Sw2vrcL(YfIxQe;y z#gvHGo|&W?%nb4pqFgo91pSSrpO=B0dNw76h7Eyo=!-WvK<5 zR9A$?sY)%*vcbFvC|YMShf3_h{7B#Tz=5~#)^HFXfE|q%!tZ<;hbpsNpj~)8 zX~ZVoaiZh=rJRn?Uvs$y&?b_Y&2GCIaA*`tp{tIOuhP-Ga>tVV**Om@=EP6dqg5SK zsm)u3+9}MNrSfmIr=93?Iok_bfS>~n{sNi4?E4rUqlP60|GGi+qoEt8UQP*#{qxZe zQ*a!&b*)zW42Nf&H5)7C_W5Y6D#0BBgNtz~CER$aiDRV|e6c1?1H@-sr59H*yJ@7> z5TX^C1R9kCY8FwRYf^DvXU(|gquL1ltofWL%pAYCBXxgfdrlIzF`HKxG~waa6g9gJ zHfw(Qtx&aaVl0xuB@$>{PD;tK-c|G@W5DymvNuDwac@MJpxt#=Sy=u1L(wzq%m9f} z&%r}+?u<>})ss?jEdQ|5(sm)mf~H?Kw>f_RyXpEyEauC!E_D&l%JxM7>oVD=96t^B z_wytp~3G<`EKYOXzK3@6E71Wv(+8wJwlj-b%=8ZDXfEWOk&y< zEVNk@_qiD$FPEA93Jq~(%j>dGfGr&&Xqf(dipjsAnK_)uWwLMU*;xHioZH;xmla#X zL1RChiNa~4v=3aXF^63;aHBH4*1BF*qT+ESW+b;!X57|b-i4=TuG{qmbFSl2kQ%*; zA5Wx2g!lsf4l$G$*IbsL4Fpb)t&r{%}IrKkYt~&(Ileybp zItH-|-fXOoMwyrc3QAF0Tso}Z;QsiMXB!L69ODEv!h zKG!^6WBy~HrXcAo!Z~AWAPA%XVx({1ni9#ICuehiK4Wq)f1P$^Tf9~g$q%tP#8&Ok=ethJ*5ZHBUUduQNzM;dIPN$Ybw3R&*Xoa$g&dS!m2 z`0+uDZC%d)8#8u$Pt`lUN zUE+gZU__Mg@f?eSG-}Q9FjtZJxU+h?Q>`P*OLm!KF>piC{a3Y9HI#hBTBagTUWPy? zD?(2$ZOidha(b`QQLNy&P*yFYeUqc+TeWOjqP3D+vw{Wl*_WOtJFZJ54ox~cHPzve zq;+bACC&G6L%tG$iC+~Tu5*N%6J|K9FjfO zx@G6T0|^VlI(ipnl)P~aI?)TWU3IFf^>JuCkW|(A#&wfrSNXG~9v-f}r~2FjI~}C- zWaQPbp$lP&F4yyPFXBQn2T&Ue*Quu1^W^QT&36a7FLmF@dipxOufoF?sEo{DVYA|u ztfFU6XP9>HlNpbGK9lZMR;{Jy-gTqD$Z@AVGUHfn2P<59}q zvd=Ce+sQ023_D@%ZS)_VD^0#qnlp&ob3fWspP8C_Q2TuH+O^;ZGG$W8r>r`S@p_S+ z){`oiO;1^C<>7;qsV_SRtLnyV4p)uBD-zBv3$c?LMhCNzNnp)Tyt?=1DLfjhE(x)F zZOU^3Qx)^Wtxc?rx2b+YDaPG8b>?%PzTsUuVTDOw$7P`>zy&CZ9T#hg(y%NsRjF%T zmHwpTMCL)(WcbV6j2Y^Nk9JlEr4G7cFbklMTESsoQYsIY20iNmQTCSHo-6MhbfQD2 zYQk5b@ovJnAlGnt|LBnO&ft56p*?y<(^{@A{Sy}d*+l*?=q&EmEG~lj9I6^RmhUxE z$(8pm$rg{she^m{tf`G(W>T0*Tvb>D;ASFEN?$F7xwo-aG=%WDr82ygtj?bi^_`v1 zE_`Anh~nttB-~gn-%K8LtH75)n!fbh`}fo8HswQa#gLyl*WT1FN{QODRsCF_&-ZuEz2|rDe~^=t_xt_I zb39&ETg!bwE&AGaVa@@L*tON>pqvf(=z~P)vZ2q_`^i_Ne4tXE`aZah zFuZP$jq>zY;e7|Cx1?DYGuxCJDrk_1KId642hIK0Bz$*XVF%AXQ?kG32#qBs5q{d4 z@vQH45JxR>newv6gM-?)KqmFW?NEhp#6~MFmvrJE*CvP`ej{e?CGlfsiaJ~-y64F< z|5H+^dy@UG-_zyVeJT87jZmU>Pnoc&h%qaTSjGIKF9$xhT^&|cM9GGg^G3_wuV{QJ z$IEX&qtmnJPAs7EP5nJZq~jub4oGI+UUDGmhKbQFj8|f)gp%GX{abRqB`TmH!gT)r z)A?s;zDm;vf4(=tlVv-$fL={`of^#McDJ}|T|-{~ zvU(l-k|*o34wkEhyZ?(|XlN!3B=VZuEe{m;Q6qtS(LcW!#P={E@BkhJL@0XiRdk)G0A2+Z`X+p7)|TJYn1U|z zCiS4)VUrKF%We{IZ?=U@wRyPGd4Db*h|WX<2TcG+w3o6U<{6h95ph$}ln=XzrZKqj zY4jt>K{9NznL82);DJ+9GHMTsBKl>gh_uvr1XM zlXn_W8Wg>c4ahUI29}#}2d!#90A1-S8z9Qd8b#Ugt&@wD)&|K7ddZV^2^_`z1_-XK zxbG!&D_X)Ik@Yt@)>+JvrVqM>G24D;dz+B1N2wiQk0`9A%_HX=V5nhQUz$z>#M03) z=?v^ROB;PPtHc!zVj`#VNUeH2^QJe=oa84pvguMLQy4cy4+lzuh91$+fS$*mUgRpU zR4HI9w4`H`ZcHC51gD6VE=Md~P4kbU+hBvn2NY8;Loxyr?td1*ir^faR(Ad;uBw5UG zH^hp%^))ui{&L<~oDY_#L#nnxwK){J-2CppKU4iXdom5?x};k#{md<=hV}i17z5&Xx3*==3C75; zBh*H6&s|NKk)C*x6MSRK7dM@5CS)&g0%ZMQ}guUmA;ok)i%%d7%GF@3QMfkLhF#DxLo7HPM zHUP%1t6q6k2znZ%<#@OGI8LGl``{H*EftJ1+zrL*=B-q3sOMonS<_+r}Cam#5ZcjCZ&$>Ww<(Qeco zhWqJY%2(&3LHaY~I)3E=bLP}mu5G9aM=d9&@yJ#GN^IZ>CUMWFIX~}kSvmET!7ws$ zqo}8_*$W`~aALscf{pF&=gYsx0h-*-*;EY>bOM58VA2FvSk+uZsW8=j zkXiz@m3aC_c}e=IO3cWf8(2itHPU_9>DzgxL|7^oToD+w)rNnW?V^ViZ+EQ zVkkmH*c2!`J)s{orHztyO(N_|mwezQCBvv0c;HkC`m~MwN@wDQ`SazxCwjQq_Q&p( zhf`_ysgg&sp7TvHu0Ec zpGT!Q_+U%go8NAFaCJj~E8QXli_--z4fB9uABE>Yu@(D5upbT!%@^p6GDyWKiNN* z)twHyQLd=#s=GjknH_E7;*^NopDs)V%0QfWE`77PS`%O6$2>}de$aCM67OrtD7o`N zVt#RSW4~lUwECILv~_cy(!mxiOyyHdQAi+ZCpf@vo)xitnos%pyfVPtP~_mH_0DvC zYJKW93TgMyph37{3VvTnT|py}vS-o1ZYB-_HF;EoVtHMJPwo2HkVQTJ4-pB46N=u9 zP@ay7vJ!a8b5=RxY?bnBBF_mvIm^uh6gU&~n}ajeL)N$R|MutM)UCs2T(@ zsZ(W6KIQe1it2HHYU`%>Ir`-J0eAIs>|wuF0$bC1V6Z_=#FxdmT?^rA`sj_1* zqFNX{@uCuwF2XSt7f7D}7M{%~C=MsgS$QJ=V9;G4AC}Y%fTi|U3UoSc>LVqPg)CuP z8ly_EYA`tu&M)b2t{*a=djAj6yc>PT+4F4Y(w{FD_qhm|NI2$096-Rb{L8KBSHg`k zxo>ES=IJ$&%Q}^BpHF@F{u-By8&I-X5>)@FY*k^Q7Pe%Q5IWy>tX)J+%c~*F|JLL_ zd-Q->byls8n$(W@FUB5wy$ZCFA@r`+NaN+y-?W;{8*5f5`+J-2)ORnqy#h`N}}g@r@0ra z_C+D|KQi6_iJ^OX^SH_JSm<2`m$%>*o$#Ax)0)FhggUU!r$nw8D*b#mjVWJbq#e1vg+Le_P2G)n@1+dyg8a zVdQ0TRw>4*XhBB99@{XR20)3VB)wuKG{;%n3rP9o;Dy02N(NY}tc>7v_3;9j9Uq&N z6Q)7u=j4@qFN*QC7KoBiYM&+Q7T+ZfGK?uEKQ+8PRuoEWLAtR)<+A+K2>sjcw6GZ*ZAOH6 z#-Do=#R7^=JU%i@9n|i0$-=1D-Cn-A2A*rdc7)ooBhDX6)<5L6=pC=5$1a!cxRU=v zbb0b2F#U&5nb|2VjP|}Pq-+sjW=)o)@>^&pUXU6wm~|{&;yVc<2!5nP9v2vcA{2 zdJ1L^LBz6|+E6xnd3eG>t?D15@V2E2mnTjHgmXC#!b{5uoU^HAZ!!TdzC=3+cTr3x z31Ao;61tg>AX+N63!?*r!cN~&V31UfQNk<7AU9Qg$EsiPoI_{ag+{`W%l6oLE`VoK z!{owrE6=i&+GSFmjB7>P&ga25IesZ0DuvF@^6knI(1szLYJ1qX-og0Xj*AG{OIIEC zJvr*saGx$63{H8pB{!)C$^=}IcUIlu&g z?%SxL?6^k1=nC7dPPn?9^ekuz+=zro$~ z(SFJH3*JM8;rtU|r{+uj8(Zo=>lNS0*tp7ldXDC*6HqUZ&3HFfq4OnqxA-Wp@$#(P zGa}E7E-(A(=f%PAX+rU9Qei3Ij0(>bsFl)ko_0=uOZK}?A-Vx6>)P-abACH(5XdzR;d?Z(?*Pn3AAQO?S8%!a?7#MH ze(i+S$;bpOdB@6?XdcrIDJGZwz1V_ru%)X^fK%pboR7cwsOR|w@r1j}VLQ^)B*$8> zqspfsQ;<)wf%b{rz!(ZUL^H|Z%} z(wN?v#gZIBU#V7aLATC~au=xoiyic&n7n7|K6H6A*EBvVD~mr*9MOvCr^YQ!11&`hg85eHbI3z6#vjhHR)_rK_lHr%UgO2%qM0}o>xZ|q!l;J6y(hCO5HqW|4; zOx9*Y`L%tiHwX2L5U~pEwAfF^zy`<(kUf=GTZczjH!-G9I5xSYmr1}Pt;y+qX}lRC zkM4IiiV>2Y-LJK8pW*Wwq>K@9MTu1a8PR9bX&B2p4NZvpDDO0{D(d6Wfh$!=3^yVX zvOt6VrRB79R|bxL_tO&n96U9IFiR)@n)B_((2c6ll7V_Vsj4a&xr&Nai&pL%@MB_5 zqTY~_R3C7%*3pB5W>#isE-y2--4++8JZ2fW=O(X`uLEZSM}QN0-F&wk%Yrx>H#XMZ zpVxrzmG|kxPfOksGI*wvz9`V7-O*C2oY4|7h-W?pr?kdVB?ortn#BLAUTQ;8?}MtZ zAU|^1P`1L$PN^gJVOsY=x&96wxsWDQ55(hxlUVxW&}J-ffOB!=^>47Vju`*x2U5+R zLYt54SMnN5yFIf-eS-NLhTreQ(WUG^pr60GFWw9R!=}Z9k#Vj>@2Ymw)9fAWOI&0pyS*Ri?Dl5iZy_`+Y&x9$HBooH27Vr@()!|vtgr?BvDxWwJK zqyN|I&%XYD@NpdwF*aKV4h{t~4P08v;v$Lk@!h14I=q8~i0nbHixoM#>_V<$Ru^d= z7WHaLcA8OW$6WQ1JY59)PD=5p<1sH&AA0WjZ-)-xMT+hwr#=WKmd}Cm9hV7wb+?EkYaN6a7ylK=u3)&nPvmb3W(|(Ml~sO6+LL2m z;q69j?&?9r2);X<*Xk1# zC7tvMCw$9)B%(_n-gwVjM$z&~v+V!gDE5e4NKs4W&4(swr_K?-5z?>k0*DPR!LRDS zMX;5hDLj05-TR8*u6=QxvK_*?FqkmVNRs-vURvfADQ75vb*J;eOAsxQ-6N8i@-O5+ z963C8f3FLdEJ)o!?*WS{Ykp`M(Y8C$D@E(F02M%CJUER3*_O`CRaX0Rf%;3exnF3kR7d^pkN^ftvDUAC;yuW$*!3h zVXMU!r;0NfLMsl3Wh8q@Ltu<=2r;>IH>m&tDu@n9O&p6Ov|| zM&NQutXv=d=Q6=Kt!%->x2g3cOD;Da|IcNTHU5`aNaXgXbC(bR-5eE=6>kPKi~!p0 z2?Udjh=_=e@kz%%{FLOu<||{`4b3=H-`M`hD%{)kxN3RqkJU|>)7NE{Pe-qTA~whA zy`2_L-s=UYdQg(7w09!m$5Tf=Ire@aLb_x!)d1>LzAl3_(oXp$^NU^vF~ix|Z|f{#7L=J#zLT)k zF1C;$flGbI)O|ig|7WfdOJMXdFiV`=fvI(5QoqhB4)ASI91*TuvdzOO2lzsF`3+U!Px#qCG*?Dq*9T;PNVx?XnuRWY4FhA#$3v8t?B#v_&U~4b zv28kHsshUOGXj9@NT`_)>J})uG>fL3S@Eto%0T%BNrFd-(p62T?oXQt3}LO)4p%&Y zh^G(O8Th1A_#b`k^0Q^0uU%R3g2kd2bvb_}Q0DDALr1{o>bn4ntUbvLKk}NWW9#9C z28n<-`lz>?*p(qS&xWcEXA?L69eOOB5Jp#7h^*I6YCTiaZ!O(U5o*Gv(wbS;l~`}m zmAB~Cn7DaHSf5{_l?d}%MeN#Jic(4JLiut@ad=8_LYeQmS26}w+3UNRUW|i0$S^O( z93bzbiqK9)XS7%G;-D6daJSxCUAQFX+wTO$sTt4|hhC8mFx)8E;xo|D{emn$Gb9Tj z<$iQiudr`FlO}(O*q-UCmqDd6k8y^7Wm9kc(OvNBppWfKl!)Kq{@+QqeXp#tSH?h! z0=R(-)@rl+7sZzDn#DCr`f6y|!5W{v+l$@}Y84R3fa;;q27`}aEVV!5)&hmSb*U{z za9@A}`qY!T?bFT0ksC%XD7_NASdV*nBTjmY%I;G*R?9<~g6nC!zMy5CX?a~kTF=4& z<5~6-(}{@|n2AkwNujmWYQUfaB(?MnNMGY5mxIe!;M)WjzA??*R2{giXyIAm6t>Wn z7hI&+u&9tdTktvb3m5c~>S{3*xa)ES8~*aMt0wAP~m-*A8d72e!H z4&|jz59yMc#@z_wiWJ7b**Em3Ui9zS?DprZc>?)dFUb`FW3uw>y~4|Pn%=un3dHxM zXt8;il}?&wmo-;K#!eZTSt-;mdui9=wfG(YlDia>vcW6S3;U`-eCQYxHKH`f@sqLC z%c?3|)r;%evIy%8OXajU`9+W!Jlk|E*(BYanmtI}{Nja{h)-MmfGkXU6u#NlA$Q@u zIifzVfPCX%o)YeGQ8dE%577s|^E%oRRf2Qx+Lfvt<)6_cT#LTSagw$vqLw)Ja?pj_ zB$CNy@TX11=RT%@K~s3FjB*Nd&pRPi7UB1Bpq?s%;Rdj&o%h+NuVYhT`43rj*jb4o zJ~ewQA*`&4phl~!Hc0@I`J^TQ1jM;zvEsN zaX5S)yy`L1u}yA^Do-*Cz_qA?@V5azb|g<|9)!KY$RyiJT88IQi&-ozg;l0e{LJQV zKTyh^b)_5h27q)QRNV-x;|7rejfI8MPh|!LQ-g~a7o{*L)>$9(O~FFfJWPh5xVHh6 z78y0;S$5wY&;BWbR@20bbm+D9pUmUn65Frfw8|iddwOt84&zs?vs9SfVR1nc#;HQk z#Q##gy)LA-0L>fw(zyRJ0&nRE_lLc$KRa{&hdt)?{tpx!%rO35ZZIy7i7O$l=y zeb3ckRflH55mIC7iFBT~r*kH~M|ox$>qKB}5*6zaGsKSm9gz7tvUhVXzyQZ=p<5~_ zjevfsDlZ8bOsq|XK%W(9+)o#L%_Hctypn5c*MbRvPQ7#EgH{+HOUw)*l-4T|W|Ogm ziamy^G895nDLF&~pzmHJa@_awOM+Ne+{jPV^3;c9H16+KQ}V<3d=&(nkk;Q>o@4CE zMaEzGi@o#eEVH&7@B&jw!%)fH?52=>K29WrOvBVe5u!a==q}Vu4S|_3&j}1tO>wEg zDeh;#Eh!cji&iDcF;Scs&85jtBFaj+`*(`(FRKTUEQKfU7TiBfzGja2Ueub@nk3SK zdPTbM?!8mNSL&e_Y=yn-M23=}&Yo{!?@|aMtb(L$vnU5+rWO^_Ox4Da}zv*)+6ZM2{g(BY}UU3HWm+trHK}qPZm`-LvP>=lHaa|7I)=P8Yd!}1oz0j zvs~be^)G~Zc6ou45U}Hm-XrQm(i>8$R460c=()3<(v?TqsH#Ai#TLP0BlYN@1LHhSFlLJmdJ(xUBne7bqNdf1L$>(g)5E4y|a7r}-JC_?5& zV|&0Yr^@}2rBhaaC@^77p`ATj;cr%n=&yI$4mS^%Ap49CS;gI&7{2q$3#Ino_kPtd zzI~7#kd>N~9ZL%;EK|I+PiYRlh@7}AilVw`d_yW{0N#|e!H%2f4x;b5zcYRT8hfqM zba#gLWdm$Q^?OyQqh$rz0)5G45Y@2S~@w@$B7_F;#vig=w) zBO1@>&)ITxIaSs6YFS5FfaiFr8p5ul!HHe%OIguf>k>7{`kG_OobW+{E|&6y3h5I} zm#dexOJJ#auhuK*&w$y1I|GJjU5d_OKl!zN6A~uqs6BkZaUcLj809_A^F-jeHGb3T zr4sXBzkE3|OY1IY+}i}L9Nh&N5badH$`7k;_G{}WLCvPrSZbVFi*Xq@mn7(Wc-!-S ziRjJ9Bc4s5T&#x(Aw03Z8KvB*W{ig1Wzox+z@nyN(^ zc>dk9FRj4OSYn(kU|a^BXNL@mN_53kxlIWGLL3i3azNG*fp>kN=b__-)(Cb;*(c?C z?F4)E0`#uQEq?d-oo>(fjJ3ZVp*T&{sp_NnS|@U4n4fRiVRJo~=mXT6#BtT(x$NEX zqShYIloU`VgBpe5bm?ZiC+&%@F``?;vXYi|3}R*J0eV6!ZWyYg7x{^tDF2A3j4~xK zrHt5j;|Zy1v*yvR(23-D)5va}(VgbyTa|8GD~@*r8U`t|G(atNx61@#uW{PLe$cBu zS&RkF7df6zu#mAw+FxiY?cIje)66zUnvngfRWSRNjJ!dM*$S(xIO0j0TSeA=mRnqb zLZ-<`n6&mn!c7bpivu6eoUJTx!0KmwY=8@=amy6T`FTLR~Ey zZcrF?TLBS&JMHVdo-Xo@q3Hs(!sz+D!%A!Nq^4^_lEO55?(c>#Vdvr>OK&!IofjZQ zzU~;5ZS!BW)V*9AbmsDX6I53E_f@KFS~Hz#IiyuRa%rZE^=YFz_4I-TA-~o`-*aF8 zTOIWv9@ANtTMEwc5U4`;DgGJJzF^bj&R~Ze8hw;Ja)}i4nfXaC*_SPOa!%7Ax;Kc= znOjt~8(4(D`{+>}JiQTSr}JmhOw(NytoOZ!vTcM9j6&!e!N`0T%2vFjXA;*0_rHIt^6@De^G2IxOh(zh(SyO0 zSJh-r*i`Ya&Ok3-faI9C@u2I5$8?$)2bq3t>Z$ljR2F3FH)%y4Iw$I~Y6xNQt4@Dr z27sDKvXeU{VE`M@{}gKWbKF7JCC{!xovZljPnjAblx1ApaHSXui7Qq67L#U()~tz`TL~TYp~8)s?EoH&00RHR+nAfrtn3B;&2J~E857bXpht> zd;f9i6j8Lv>n;&QK0RTA*?n^-t7BjHj^#Vl)S)U*mUQ#>W}6|V*~gqx$3n+S*Yd}s z;@qd?0HB?gssI%%%ejZf2Q2M-KI8PMD?hXDLqH(31XcoWs`lhR&|Aidr%b{tN*m^} zxK22>ie~u5p702qzbu|_(4c3Krm~Sjb2TBm90aX+hoUagu2QE=TS$3>6!xe!K?qxJ^E@#%akAG+i9Q30qH$&F6c>QJvO83{9; z+7Sy)I1VQ9+9qo#(Jp3s@^CeKP9tle0kUDoG5ndBBdf2o1Vt6RakH4A)0wM_fo+K$ zRctl2na}H)J%*zB`6-mL9q;DoajbxD#h*VdBW2DRiuJSO8mk9Q6g~qlwf-AklY=NV zsMa5%?iqya-u81LI&!zCm(JBSX1aQ%I9#z_a*sOp^IyHw&m=ea6gOg7_!7^ykm3tH zgGlm}(3Ob5Vq!9s(g5dccbnRR&Crf2j#zt-9JPCZd^g`$T(KTMuUQ>u*C(1qw7a~h z^Xho+9<@KSb3A2vI;LY{H=yHX^uzG(>_c+aNl&_wSV=t{%31kXYdAIg zwnD=62Xqr*U`0C|LuFUr66GUDwaAEAHApGb7~MnB3%WGS>d(F?ZVpe8tJW3PT|euG zIIg9d4tH{}`|;}Vc(CYe=;8RZIdY}#*WRI(ypdINCZ5WCEPeT9x;;LfH3q^%TQMAx zdPk#$^~Skhf>9UTTqo}{rq4I7{x4?c3PXHMt`$--v`#~|V9tjNen6!wv1 z2!XD80V*sB*Z*5xQmGVY$eHmg8X)@Go#$|bd*Hgm>0KZtwX1Cu3|MKk-;CTImpaQ(Sw!PEjMY@aTZkSf_VJT6hqE zkp<<=DSQDeYPG%N{ndRa^=JE3ARvmgYUWsSAMp~NqckxK5!@azBeS09-f!fReboKw z7YECq4FLh^EQHMlbmHVd_~p^a#_t<#UnTI(joF|2Utz0!TeAQEwO{V(;-v5I<`2a{ zrcWK^aQK@U=tJZ$UIF0!(Poc4xu1cXjQg@8MGeDdK2vDcp`op>8>>nI?q}J)rsIal zrN_f@=)VNXmgOtCpDz-$T4iSX3s28aEB>zJ@BSbFBywzLX7M}QQnm(gv@uvKMB)jV z36!Ro;u}CjgM&45`H6bmE3dO3=pCZHp9u5>N~6E9X#?Y6MDjl~Oo*u)sQ+96x|-xh%a z_||Q^)9h?9-)uB;CZ$5r2RTofiDJ_>Q2i^b-1s zMhNjdf5Q6GyFyzj)@Zyf4LUa~a>b8xx;EU%dc;U*0yZM!bDka1Yl`4BSg1U8WLBB1 zjo>0BrGHGR&(xY!Fn@!Psk1tE4$u@N3~(4W$Kn#e?*fr#Krj8YA|#ha_xENfNx!)< z?&8JujT7EmAz5AhO{dMHSS@mDRwggrXkpiBmuCHra(z6m-&;TmIxed{uqc-twUFL0 zRcN$#-PnuOF)9=^njxr*B5A9^ZLSUTs}Q)Uo$CzJJTP3qz(Yef0xmO5<4vfju=Da` zri!PrvZ7YbEmF_Q_(S;ewVaCOB{|dG%Q3_^HSED=Qt-q=j`3pUsSy@Tk8`||0|K^p zmdhNr_8-8x1Vkr%`25;JAw4MqIbVJ>-N$Qe@cfi)u!Ot!3OWcP5a<=hqJ2 zv3;WDBQP-|7@75*YwiSq;OY@hd#xSPt7_#$hgC#bKFa!Vv0s+!p~I78IK98bKQoI! zSZQjRyq&hG!eHi1bn}|WCI-lJFJk8BWaSm&dMefvLefj!AsBbysGY5tu{%is&l8g4`of^ z&6{Fe+bqnRtK*RoV(xWIygNI&Ku)#{Bf5yzFZxWrw)C2cNZluFYA)ebXgSM=j{gvS zJ{bki8O`qgj$OEzJu|4MtuS5IH;a{}UeSjR>a0mgn_cm?!xR{)=-rX+;806B&&`dZeF;gyIF4pbI5s3@kz&Jg&l(PP^+8+h<%s zmUW-Z;Cz{A{9hddeIHevlw-V&afL3W6eL9UmCu^o#D_3_w|M_8m-yLzRi4OPc$x<< zbE*3Rq8SgBUUW6kTbNOcBv5BXSyh@UF&cNjkdhg=XPtoU6BOE!z0U9zDd3%&HI@pf z`Wu`B3+ArpDleWrB|+dKgPKXgJaw0rbz?vexsABv zK0U)1H1jq-?BlwH;Qj@JUFmuuP342lPXQZ2i-T;0?;d;SqZ<`neByyO3?+!uhfyr5 z-k{>R;-*c9|B5EoG=U?0%D#*ox$oG{em4CKH^6PIbn4McZKe znfaNW?X*sP?+n3kQJGXt^I_G#P_gf>crqyOo?XiV;wG-+<9+mmsiC4wXY}e7Uu&u3 zZyroLqN1+cvwB_p_0C$F?#3%kVPXZf=tXZxck3?}gH{|!ZhyV{gK&Hmj>WS+Kci!@ zVm}_|S&sHtc<}oNpXr#w$=hd2T~t+JUz;rpDf0l6Z-N#qjl&=iIwV=wYU_ z-)q+68QGW&mR2kVAE6Rv)T;_7Perm5sX+&oglq1jb=B@v8bo7KBDQYERvTzZ#De8ER`vZmZHC;vDV6 zOXu&X7BAx7j;M}q+C!Y~h`a0~f0TW*)byiyYtUULHb3w*)zQ-xL!b;AoC zHwWeMUXO<6+v5?QVe!4Z$s-)G4|Evwb=~DD!vf_~iovxr>gT)LqnSYEy_ujr(Vv^M z{&pweRerI^gd>$bT7|C}2JA(X5u`|E7PQb#*Z0?$thMEv;7u}A>L{z+hA6k|{99Fi zCb2Nb?FJHI@xr2Rlf-k_c#s|m@@4oInQo{$K~C@XZOJc{EMU_7za`tT3caJKlpoNx z7Yqq@L8spc9#H(*M{T0cf?l^fDB98>TMP;5&^kWk{HC@(ZWH>gAUafh^nvE`bPxqe zPfICK^KX3j&jKGkl72-7yU=?!4vp6-6coN$xZz8q*0X~G)EH&2h?sahBSu1BEHv{m zC2M)NmRc|8i0^dijcZ0h%2G%rpLy3;U~Xu+?0ZFC?YC=z-X5f&;nOa258-=!vCxx* zn2$aTnUslBC}sSxWJ*+Yzlx zF6v@|d`CWFY-QJdC|#l=P)}HN(B9`RPgZ@Y=lG@t_ISA-;mOOF%e7F!WurCQ+#9-Y z58AOX+GAvAgL8{ZX}*3{^d$e@aC2216uKlBJ3~wRV1`zpEWNhtgsVuqD_@tqS*_EE z0e34$2A+zDOA$4HuS;BGUpr&&rfY(j#VsAsR$}s3H4MvUz!wd_!;tJ>7oLQ~?tG4s z=b2@c?mK(0F<>2W49N+@!>L z{GlUyd~Z?kBkXzjh6soAHcRJ4=&4$hTI9A)9hXja{F#IHSCbiCRCn9&+>E(B^d(joqp+w6zqn*Y1iz^eeZAk^M#mF zy8a3LZR6x)@5$Q*N4lMx5&o2FnOD&*W?IlS3uOk<&+-#FnQy**YpgHy)D;m@ztt{= z19;zB#T?0I?J>xG6b09A#%ZTcTgVf#Qnvl~J!dR(@7^ z&+q$qYrQ2pEb(!P2p<#lOy&V(S!w&K=2^$(j_iiTZZ-GBlUzDrX9S6^yEt!Skc!pNINds3!W;uCe z+thU-oR4VA0x0DxZRLPCF=ub2yYyVuD`&ptCh=YDJOM*gislzoBq`LCn+`z^?OD^S zP1_KXYTv{R=(j$S6AKFB4Qh6Z%B0m;gCcR|fZ44_sz}ac1&EVZ5mdN>}#{E`)I~$ zYd#H+dZZR-mk7lzSr|HKL7WjrNi9bc)Yy z5iD>2%C<4@`groC6dJ*gOLOoAb;k1%l+~?DLYlrl(Wwfbdx<@RW|iInam$~jWH#xe|G!# z`kiWT0sM!&dRSRxkv4a8o_CtH@MWCTvwDX2p@UC(Q3bcilH=k1w`=r=<2?*){>1Ed z6L9W{{X>2#3ksy52O(rbPoME5Ty`3V`IK?@w=`}bTOYkyDA0Rpi>i?^->I8KgY2Uj z#A$F2Tnbte{=@dUcr~XbXG~_M%#_DXJG{ksMi`2lEuAoE8bvcQ_wV}jaRXF05eI9g7auTcLpc>GP)~2E zQOo^=1^6D>+Z-Rhir~B$PCJj!#A%rizYdj&j8|B4<9$t`&SB#7GYeTcSk?K84+BlG z5Fo}uOp-YH6Rqt1;f1#o|9#=F@zfB{D*mCjv%fJ{CYmNbje=#x;iE~ayb3N`53(WQ zNRe!{ZKYwGY%L)#FHeM&tbS`d+<9}wAi!d+*F13$op_|I!iA(ja`6r%YL_yEj|%;j z1%O^T{8?a@QGb*o0gMKC&km5{9BV#~RTWGSo~Qt^j?0a^W^9=>`JA z3RhJzO;bOO&&ipYJ`G2QODiU)5&`vzXc@>c1(UY)rg0V!;g+T*4=x^4Cec5Av-Q)% zQ;=ngGI*?RqH`Fe+9wp2pfT#l^^zKmX2|Zl zSq95Zb3eH?s+dTXllg5VfFX%fGBJXI*PkZ1grYP=(2w>#`ZIM$`B^W!%@2ctldQm3 z_k`PjyZcBcN}NUCdjB$Zg$&H6@w*>-pE{%0jnsc);-!k7ER|&Z`unhPKV1*PcF3)z z1D@8&PY~Vdpt~f(#CHv(P&Rb$lFMl_GYP5TF+JYI?~-4UK1w3Y|9Ke_BId-qc(bWo zFw2@MCSHf}vnD`O1fZcQ$EhA8rYS6h7zx=<1^ zRakeG(LY4G=oGh<^M`{he|RMHKFaUKD9e7d>DzxIe50|s|B4yPQ}$?@BLqG5)DbKT z62&5$3sZ{(CbQ5Dxd0P&8vn3J14bTKk%TN@2}Xj8!RbSZEQ#kvF6mq_9`=24M+XY_ukKRzWbnEw2MJ#BU-CE@O_q+;a6LT_;v@KrvMEl1<>7| z(5G9JG4+Wd^qelyYVSuk4M_Rb3;2V!yHv#^=VL$7Y3y%TEX&AFvvu%WT%y z5e5@2mT_kf-8HFoOgY13qGbolu;-++1v{iKB;JWe#JiY)+GYZqk@6A1ipZ!6w5jo z|2?Dy@u8+f`*N9I3S&q;re+s|&XX2Ef09<6U)QDlR8ML=QBT7y{8dkyTM@)F_Rh}1 zS`45+;(w&0XzXh3R8n{kV)Z*;1w3K8c~MM&fyrwCz2uPGzAwuR(5<{?8;7_N{vSqR(9tJRfM!||F_|3CQQ4JPqlEYiR0(nD`BNqYb83I3mD=>M*4 ze)#WBbz)8EC&`59QU)+Jm}7^6WKy}1OAY+^L33c--PbklUoI@cUG5{#wQpqiVLwao zM6KuPXJY%hJeD~%E{=D`XA1A>|EPU3KH`{^E+h(`z^;fQx@Tw}T1znXvfhZ66U0Qx zil#CnxpePa@Z+x@#^@0-j4%;5q>jz&t*}2#$#8HvT>hx8cuY_8CT2k*IxLERPiSpm zJ;+UxHi zP#Dn->h6`r3F*Uho~L{_p_>$1R^Cs z0KrD@AWD-ai69DsfFwwe7D5LLRf+`>lqyX?ij*K;M8Qk-3hI43+~2%E-pqaTX5N2$ z&SdsE+54>TTYI1Vtz`g46ezd7e*2-8snRJhXnXy>OHKtdFkH?S^BDtB?&a@%UuI<_ z$Pw56rEQ$mR@u_bv%0hU37mM65_g%|NAy=!;11T!Hnb-A2H7U*oO*aq&_V;{!3D!< zA@QJ|VfoyL7gG5>m(vBu3ZL?y&NB-e(C;g0 zFY5aQ{>=2n4o&$dD*=YQG7X9hy^H?JT?!9D51XGw7)c*)P%X?x%S)Qv_xww3y+Woc zRL|`g{&m8Ax%Zrd7huSC)1^%D4Ov@tM`RH_t)REN{tJ#&5F2mNjFEe@Wy)-bFB?%Ku2zecEI=lIc9b<9bs9oP z^)sm*%|0z&5<~Ob2-bRUgz}Avws?9Pe3TLNm3D*RA*yY@ zZGW7~>8Z9{ojn=uHU84S0gsjn;Ka&vrjQykV7wA4?B>0ycc!&XgFlu%r}e&gM()Ek zO(Z0ryFzJPRIK{UGeoEK2&IXVyZ4@T%sEHUyy)38WybRyeB`=i^R@gR!m69Uul^CN zns?D~JAV7YQ6KFqXS~B(M$exQ8y%l&a6m6f$U7SPqNx6tD@*(7F)+@MJC|fzg?>s*)LPiMk%y8owfwLaF^ z?fhTeu6x7aV_fjaxq*?XRP-3Xt9OfQ{D(%bUHi!pgw&zzjH8KifdeW@WSgA z233g4e%E{#8>=8>SHtC}WiJCu{RhF_gwrl?X__pxJ}YkQNPv`b|AzpD<^Xivf z0VFd@A6=@;I@U3rRqSx`+%p0Bo?%hVYR$hP96kFj#nb*t{U&G8k#&8ydP+l7C?%#$ z`@z>2k(HuW-_$R)w_WVsI7{wb3az?QwQy$Xyq8J+(~a>)xC*mcS&SA-q$#R`%(L{? z+PW^>n}X$FKRDS8v(WChXEWBun`<;TG7Uk1hq@=6#I86CSrwD1D71~U9#x3X*N;?X zsV)A&2y?FN8l{VxW}N!eo=)^JK;uX3j`6XR;my07Mk%(%#@+9s!3h>}7}7etAb@tkkO zsV~4!8u6tw9vcJM6vIl)odur!4F8=8JE(TEX-aRIvdffWgle#f3J6*Iu>BUwI5?Gb z9rLv28rcn!^4NXjvtyrL0qU<)ft*UrmtSgdn5~6APe=V)xldj@LfFDe?vTu^cI+Qw zCz#+ug<(gXCh8|(h>OP68A3pTh6KHitvBbQ&1&1+^V={L=USz;O9P=(-~+;tv!Q%e zHHQQJUs>P;!cd_DUxk?n z1EH!5R%7)mt5v4q$EE`nWqDK;qdgvZw+$4;Q=Ze!+G;8r&FNVSPL|Pr1-y81zRsqN zy3)oK`E2+nJKs5%8G*#4th}s`JPSX*Sobc@Vn+Uk#!0m1Sl-)eqcfAwujCr51W)5f z$`d!*a!^1a9Fz6%q^z0t@*p@{aIt2YJbAjWG>E=x6`i6@dW7~N$r1@da4o##^kwR1 zieJu1aRwG|WRo28E}F&N$9VX}O|5OK%;O_Yy{nf@2<}wOtr4~s+gXj7&Qi;9VhSD!u+uO9Zi6e~onnjf!bx>PL}v?R8LEpUoI98)Txq4b`lzFp9m zHluX)f=+=~3pW*`e8bq^ftYqeD@)Bpj=xft*Y@57AO7Q9qg6qd#ikRsY1tX4qbGj< z;ptE->3|D!o@OCUAD78iPReGSbzzt^|8KLb54z;7~gG!oP$Z} zQ!@Q=xC&}V?h)y!GA(_~a=PO8?jx_a5+=ld`&ruY$QkVECtLi+5g~~f^{69R&;iPx zf_}29DRYAIayr?arO1o0`lv@`zI-}4s5h}Uij(B3d5llV0Wzf~*$hULEPoO@|;Jtuma8b+fs4qEfyby6jvFw&noOQUCK zBV3271{<`r{Ru8_bVSBw!1ZbVx!&9=hEnpHcSg8pi=(v?xhTsn#`)0?d+rv{ zso_m!hKl&gb9mIDG+hQjDY`HKP&dWLK0zQ`x+!hNJ%2%2!JqqUDBXTtB)TcnHIi~x z&Y7*~_(3^6=Ln3@yO~+O}}y&w;1M8L-M-3~QbkQ>tn$oobzkKeJbDe|wo(Em>w*BpFrl7y}Akw4OKy zKz3J<-r;ACd3@x_ykqt%OhGM>!n>F~+w5M%UW}h3fyig4zmzX18RmW!b7JTKiTfiGi#Z>LFldM zhA3v@n-7ByD7n zWr-itwbnj#;dcTr@lBaM^LkrWRV&{#czp`Sk24`6vl73198@|j-s-qS*V_4>UxCue zdnvg+jfm5?jE3L$Nskywe$nZtea-?gsY%yj38a=)1hrVVlnUgxL|Df?E~+|tky>o4 z9$1uLr|C1@suD4ZRv0npQs<)g`LTJ&whR_{tKrN$oV+CEl)t{EzJr^12XrO}UsIA8 z1Y!af#=H#=s^N(Zk7DyYj`H;{qSIu~*oSJgYMC+jqF-|#65{Xdw{uB+cZC+Q@R28( zf*Qa`TA=dFW1qpGw__4zEPHP_(w>{Yr}mF2RC)%ZM+3RiI9h8slhjt&%Frl3(tRn3 z!o*a^8a~WBiJUDCe<)=1mO#Z;4o^mk0azjfWnstwOdmu5_2n$=5J7(Uljt%C>qw3R zHG#GwNwU)NqN0- zBt0shr-7*|J7YiM-v=Ay4mb;Le+1oS!7MOHU5Wu=U)xNh2=KOj$9906|8=W_aSsL* zLy!QI+yJJaQ53>phk*Mr!2l`5S;#YX4Wdn$C?{p6dA|slP{VoY;@2iJ`bG{=^BZW+++|K|nh zJm?-QSV0D6Xkvo~PJ_`Y2s027#Gid$Ob}*5E*7kB=HT$LPI22&{Vtrh9$@@r8rsQx z>>3b=Q(%lsgdxVh1{iSyn5F$oum_@UYl$GYgMqsYfp;5(E>;NwVgIo;O4uPH_$SOj z=^osj@fcv@AV9^zbRkXuA=@B8g6=@q`$PdW7!%3{12YR0r5T|zg^kfEgFL{IoscPG Rl0umNJDUFcqcQ(I`Cq|3vk?FQ literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/banner/banner02.jpg b/shierfeng-fastapi-app/src/static/images/banner/banner02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1086afd3c71c51472b01e2b8b1a14be3587a5b9f GIT binary patch literal 37061 zcmbTd1z1#F*DySEh)9bF(lH?2rNmG}52Zsl0}PFnNC=YB-QWxzf-@kkq|#jjf*>s* zttjx1-uL~y&--4_|9;=~or~CKpVfP{>#@Uh);0KoNUJa;D_9}gKpK{sy!Ydd!vdjVT_xS+qahoG>4kRU+rp}&W< zt&6=6i;cY_%uSx{ptX~Y1!gDDW+JXFr0t<>?*vm1^s+Yy)G@RTbg`ATV|)03Mb2Nw zAMOFS_pxU2hr7CY%lON){UI)cZU62TWMlaQ;^QLErudsH%Oh<)7G-xYdlqp4QGQz? zVPO^t2?1d-aR~|G`z#_t!Xkn~*dGagVKEtT2^kSdmcK7HEH^JZ2N?)R^>1F-J9)Oh zmGblR6YvuiaQAW)6qc5j{!K$fgdYpR?;YUgW9`rH=FR>O1(3bBtryJ02j=d^@|&Wy zjk~XpJR4Thzf^#GXlwtA@PD)x9R6FcKhWMj5c~fU<3B=s8wPmT3qtI@-F>}m?XlqO z{~%*+_rEjx4TzOSM%N359TaOIeeIj57vy%}5DNBGPB~=tbLSiZ) zVPPp{QIMjjw2-KZgtCy7u<}1N{u8dEq`0smNJSNkDIqMZ0#Z_y0x5w+#6(r4#X&-% zivQqhxOw|nyV=_R!xx6-`!}x2|B5T4>}7B54uqXC*s$TAJmOu8Df&B;mlFH&L(qgKTD&l|XEv+OZq6!if zk`$2!DT;|HvHgu}_y1xuL98%>zn$aXoaLV`tOx#X{>$~TH~$JAdpB&vcws~1dLKak z$Ke5X@30|=bBzH+00{B%@d@w=2?z*D2(iC6hzSXaZ;+9a-XJ9%sqR>u<_R1E9Gw5kAOIX(JOV-@d}3@T9?pM%f`^YwKuCdwz{khMg5Z zurw)I@&AqfZ`Kj-*pzUsevAEwTKHcL>|aoX0w@9(?*VX~6` zQ4=e`Z#5~|u-x(gqW?E*;(rqr2MfmX2j#cc|6R@B6mWmb_Kz|DhhhKUIkN6G&evv2 zHj9akJm`>7#SNiKz?-CW0eSm@p_9>(t&s#q9rX(`h^=zF&{ai3P^dS(owX^b&zi1! z##2yEGv%)_;$ZpLp!_lT--CtK`aihAA8BK#DJWTg>qYUm4`7E6YYi=me;;>69AVaf z`M`~Mp`ZK6(z^z=RrrmehOg%EML_nbxu@twNi}08^%6J|=Bcw<@qv*-)+X);ZIw

-wtB?K+}+jmbpC5} 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

j7PFa!NH&l+pFwQ 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

=9!F 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/banner/banner03.jpg b/shierfeng-fastapi-app/src/static/images/banner/banner03.jpg new file mode 100644 index 0000000000000000000000000000000000000000..092a5fc234fddbcdcc6ed598c30f6e603aa9b9f5 GIT binary patch literal 38123 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/tabbar/home.png b/shierfeng-fastapi-app/src/static/images/tabbar/home.png new file mode 100644 index 0000000000000000000000000000000000000000..50acdfdde68a0b48c62409c2706e8da8eee36910 GIT binary patch literal 3265 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/tabbar/home_.png b/shierfeng-fastapi-app/src/static/images/tabbar/home_.png new file mode 100644 index 0000000000000000000000000000000000000000..a408f71b28693f358228ff050e2d04617ba1e007 GIT binary patch literal 3229 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/tabbar/mine.png b/shierfeng-fastapi-app/src/static/images/tabbar/mine.png new file mode 100644 index 0000000000000000000000000000000000000000..f13fe44d266c1b0d6f17feda0ae376217865c88c GIT binary patch literal 4235 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 Ar~m)} literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/tabbar/work.png b/shierfeng-fastapi-app/src/static/images/tabbar/work.png new file mode 100644 index 0000000000000000000000000000000000000000..21e130d8e788d2ad97508c62ae7d65d618481b47 GIT binary patch literal 4108 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 G{r><2moMf3 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/images/tabbar/work_.png b/shierfeng-fastapi-app/src/static/images/tabbar/work_.png new file mode 100644 index 0000000000000000000000000000000000000000..80b979c5a3356c09cf37f1ca742c0da982293448 GIT binary patch literal 5008 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 a-+ox4z)NM}p*%Hd0~qR>BFnTL9{&eNattW| literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/logo.png b/shierfeng-fastapi-app/src/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d4ebbf0c1cd76571b978a7342c553a3484f09630 GIT binary patch literal 3616 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-app/src/static/logo200.png b/shierfeng-fastapi-app/src/static/logo200.png new file mode 100644 index 0000000000000000000000000000000000000000..ffa9988266f7852b40e1475a89bd02aae63976bd GIT binary patch literal 7995 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 { + const config = ref(); + const setConfig = (val) => { + config.value = val; + }; + return { + config, + setConfig, + }; +}); diff --git a/shierfeng-fastapi-app/src/store/modules/dict.js b/shierfeng-fastapi-app/src/store/modules/dict.js new file mode 100644 index 0000000..489f59d --- /dev/null +++ b/shierfeng-fastapi-app/src/store/modules/dict.js @@ -0,0 +1,56 @@ +import { defineStore } from "pinia"; + +const useDictStore = defineStore("dict", { + state: () => ({ + dict: new Array(), + }), + actions: { + // 获取字典 + getDict(_key) { + if (_key == null && _key == "") { + return null; + } + try { + for (let i = 0; i < this.dict.length; i++) { + if (this.dict[i].key == _key) { + return this.dict[i].value; + } + } + } catch (e) { + return null; + } + }, + // 设置字典 + setDict(_key, value) { + if (_key !== null && _key !== "") { + this.dict.push({ + key: _key, + value: value, + }); + } + }, + // 删除字典 + removeDict(_key) { + var bln = false; + try { + for (let i = 0; i < this.dict.length; i++) { + if (this.dict[i].key == _key) { + this.dict.splice(i, 1); + return true; + } + } + } catch (e) { + bln = false; + } + return bln; + }, + // 清空字典 + cleanDict() { + this.dict = new Array(); + }, + // 初始字典 + initDict() {}, + }, +}); + +export default useDictStore; diff --git a/shierfeng-fastapi-app/src/store/modules/user.js b/shierfeng-fastapi-app/src/store/modules/user.js new file mode 100644 index 0000000..de08f28 --- /dev/null +++ b/shierfeng-fastapi-app/src/store/modules/user.js @@ -0,0 +1,125 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import config from "@/config"; +import storage from "@/utils/storage"; +import constant from "@/utils/constant"; +import { isHttp, isEmpty } from "@/utils/validate"; +import { getInfo, login, logout } from "@/api/login"; +import { getToken, removeToken, setToken } from "@/utils/auth"; +import defAva from "@/static/images/profile.jpg"; + +const baseUrl = config.baseUrl; + +export const useUserStore = defineStore("user", () => { + const token = ref(getToken()); + const id = ref(storage.get(constant.id)); + const name = ref(storage.get(constant.name)); + const avatar = ref(storage.get(constant.avatar)); + const roles = ref(storage.get(constant.roles)); + const permissions = ref(storage.get(constant.permissions)); + + const SET_TOKEN = (val) => { + token.value = val; + }; + const SET_ID = (val) => { + id.value = val; + storage.set(constant.id, val); + }; + const SET_NAME = (val) => { + name.value = val; + storage.set(constant.name, val); + }; + const SET_AVATAR = (val) => { + avatar.value = val; + storage.set(constant.avatar, val); + }; + const SET_ROLES = (val) => { + roles.value = val; + storage.set(constant.roles, val); + }; + const SET_PERMISSIONS = (val) => { + permissions.value = val; + storage.set(constant.permissions, val); + }; + + // 登录 + const loginAction = (userInfo) => { + const username = userInfo.username.trim(); + const password = userInfo.password; + const code = userInfo.code; + const uuid = userInfo.uuid; + return new Promise((resolve, reject) => { + login(username, password, code, uuid) + .then((res) => { + setToken(res.token); + SET_TOKEN(res.token); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + // 获取用户信息 + const getInfoAction = () => { + return new Promise((resolve, reject) => { + getInfo() + .then((res) => { + const user = res.user; + let avatar = user.avatar || ""; + if (!isHttp(avatar)) { + avatar = isEmpty(avatar) ? defAva : baseUrl + avatar; + } + const userid = + isEmpty(user) || isEmpty(user.userId) ? "" : user.userId; + const username = + isEmpty(user) || isEmpty(user.userName) ? "" : user.userName; + if (res.roles && res.roles.length > 0) { + SET_ROLES(res.roles); + SET_PERMISSIONS(res.permissions); + } else { + SET_ROLES(["ROLE_DEFAULT"]); + } + SET_ID(userid); + SET_NAME(username); + SET_AVATAR(avatar); + resolve(res); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + // 退出系统 + const logOutAction = () => { + return new Promise((resolve, reject) => { + logout(token.value) + .then(() => { + SET_TOKEN(""); + SET_ROLES([]); + SET_PERMISSIONS([]); + removeToken(); + storage.clean(); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + return { + token, + id, + name, + avatar, + roles, + permissions, + SET_AVATAR, + login: loginAction, + getInfo: getInfoAction, + logOut: logOutAction, + }; +}); diff --git a/shierfeng-fastapi-app/src/theme.json b/shierfeng-fastapi-app/src/theme.json new file mode 100644 index 0000000..c1ec2c9 --- /dev/null +++ b/shierfeng-fastapi-app/src/theme.json @@ -0,0 +1,10 @@ +{ + "light": { + "navBgColor": "#f6f6f6", + "navTxtStyle": "black" + }, + "dark": { + "navBgColor": "#191919", + "navTxtStyle": "white" + } +} diff --git a/shierfeng-fastapi-app/src/uni.scss b/shierfeng-fastapi-app/src/uni.scss new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-app/src/utils/auth.js b/shierfeng-fastapi-app/src/utils/auth.js new file mode 100644 index 0000000..0e5b269 --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/auth.js @@ -0,0 +1,13 @@ +const TokenKey = "App-Token"; + +export function getToken() { + return uni.getStorageSync(TokenKey); +} + +export function setToken(token) { + return uni.setStorageSync(TokenKey, token); +} + +export function removeToken() { + return uni.removeStorageSync(TokenKey); +} diff --git a/shierfeng-fastapi-app/src/utils/common.js b/shierfeng-fastapi-app/src/utils/common.js new file mode 100644 index 0000000..0791a37 --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/common.js @@ -0,0 +1,58 @@ +/** + * 显示消息提示框 + * @param content 提示的标题 + */ +export function toast(content) { + uni.showToast({ + icon: "none", + title: content, + }); +} + +/** + * 显示模态弹窗 + * @param content 提示的标题 + */ +export function showConfirm(content) { + return new Promise((resolve, reject) => { + uni.showModal({ + title: "提示", + content: content, + cancelText: "取消", + confirmText: "确定", + success: function (res) { + resolve(res); + }, + }); + }); +} + +/** + * 参数处理 + * @param params 参数 + */ +export function tansParams(params) { + let result = ""; + for (const propName of Object.keys(params)) { + const value = params[propName]; + var part = encodeURIComponent(propName) + "="; + if (value !== null && value !== "" && typeof value !== "undefined") { + if (typeof value === "object") { + for (const key of Object.keys(value)) { + if ( + value[key] !== null && + value[key] !== "" && + typeof value[key] !== "undefined" + ) { + let params = propName + "[" + key + "]"; + var subPart = encodeURIComponent(params) + "="; + result += subPart + encodeURIComponent(value[key]) + "&"; + } + } + } else { + result += part + encodeURIComponent(value) + "&"; + } + } + } + return result; +} diff --git a/shierfeng-fastapi-app/src/utils/constant.js b/shierfeng-fastapi-app/src/utils/constant.js new file mode 100644 index 0000000..198fb6f --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/constant.js @@ -0,0 +1,9 @@ +const constant = { + avatar: "user_avatar", + id: "user_id", + name: "user_name", + roles: "user_roles", + permissions: "user_permissions", +}; + +export default constant; diff --git a/shierfeng-fastapi-app/src/utils/dict.js b/shierfeng-fastapi-app/src/utils/dict.js new file mode 100644 index 0000000..b90c9a0 --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/dict.js @@ -0,0 +1,30 @@ +import useDictStore from "@/store/modules/dict"; +import { getDicts } from "@/api/system/dict/data"; +import { ref, toRefs } from "vue"; + +/** + * 获取字典数据 + */ +export function useDict(...args) { + const res = ref({}); + return (() => { + args.forEach((dictType, index) => { + res.value[dictType] = []; + const dicts = useDictStore().getDict(dictType); + if (dicts) { + res.value[dictType] = dicts; + } else { + getDicts(dictType).then((resp) => { + res.value[dictType] = resp.data.map((p) => ({ + label: p.dictLabel, + value: p.dictValue, + elTagType: p.listClass, + elTagClass: p.cssClass, + })); + useDictStore().setDict(dictType, res.value[dictType]); + }); + } + }); + return toRefs(res.value); + })(); +} diff --git a/shierfeng-fastapi-app/src/utils/errorCode.js b/shierfeng-fastapi-app/src/utils/errorCode.js new file mode 100644 index 0000000..984fded --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/errorCode.js @@ -0,0 +1,6 @@ +export default { + 401: "认证失败,无法访问系统资源", + 403: "当前操作没有权限", + 404: "访问资源不存在", + default: "系统未知错误,请反馈给管理员", +}; diff --git a/shierfeng-fastapi-app/src/utils/permission.js b/shierfeng-fastapi-app/src/utils/permission.js new file mode 100644 index 0000000..e30a24c --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/permission.js @@ -0,0 +1,55 @@ +import store from "@/store"; + +/** + * 字符权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkPermi(value) { + if (value && value instanceof Array && value.length > 0) { + const permissions = store.getters && store.getters.permissions; + const permissionDatas = value; + const all_permission = "*:*:*"; + + const hasPermission = permissions.some((permission) => { + return ( + all_permission === permission || permissionDatas.includes(permission) + ); + }); + + if (!hasPermission) { + return false; + } + return true; + } else { + console.error( + `need roles! Like checkPermi="['system:user:add','system:user:edit']"`, + ); + return false; + } +} + +/** + * 角色权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkRole(value) { + if (value && value instanceof Array && value.length > 0) { + const roles = store.getters && store.getters.roles; + const permissionRoles = value; + const super_admin = "admin"; + + const hasRole = roles.some((role) => { + return super_admin === role || permissionRoles.includes(role); + }); + + if (!hasRole) { + return false; + } + return true; + } else { + console.error(`need roles! Like checkRole="['admin','editor']"`); + return false; + } +} diff --git a/shierfeng-fastapi-app/src/utils/request.js b/shierfeng-fastapi-app/src/utils/request.js new file mode 100644 index 0000000..35188a8 --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/request.js @@ -0,0 +1,131 @@ +import config from "@/config"; +import { getToken } from "@/utils/auth"; +import errorCode from "@/utils/errorCode"; +import { useUserStore } from "@/store/modules/user"; +import { toast, showConfirm, tansParams } from "@/utils/common"; +import { + decryptTransportErrorResponse, + decryptTransportResponse, + encryptTransportRequest, + invalidateTransportKeyMeta, + resetTransportRequestConfig, + shouldRetryTransportWithFreshKey, +} from "@/utils/transportCrypto"; + +let timeout = 10000; +const baseUrl = config.baseUrl; + +const request = async (config) => { + // 是否需要设置 token + const isToken = (config.headers || {}).isToken === false; + config.header = config.header || {}; + config.headers = config.headers || {}; + if (getToken() && !isToken) { + config.header["Authorization"] = "Bearer " + getToken(); + } + + try { + config = await encryptTransportRequest(config); + + // get请求映射params参数 + if (config.params) { + let url = config.url + "?" + tansParams(config.params); + url = url.slice(0, -1); + config.url = url; + } + + return await new Promise((resolve, reject) => { + uni.request({ + method: config.method || "get", + timeout: config.timeout || timeout, + url: config.baseUrl || baseUrl + config.url, + data: config.data, + header: config.header, + dataType: "json", + success: async (response) => { + try { + const res = await decryptTransportResponse(response, config); + + if (shouldRetryTransportWithFreshKey(res) && !config.__transportRetried) { + invalidateTransportKeyMeta(); + config.__transportRetried = true; + config.headers.repeatSubmit = false; + resetTransportRequestConfig(config); + resolve(await request(config)); + return; + } + + const code = res.data.code || 200; + const msg = errorCode[code] || res.data.msg || errorCode["default"]; + if (code === 401) { + showConfirm( + "登录状态已过期,您可以继续留在该页面,或者重新登录?", + ).then((res) => { + if (res.confirm) { + useUserStore() + .logOut() + .then(() => { + uni.reLaunch({ url: "/pages/login" }); + }); + } + }); + const error = new Error("无效的会话,或者会话已过期,请重新登录。"); + error.response = res; + reject(error); + } else if (code === 500) { + const error = new Error(msg); + error.response = res; + reject(error); + } else if (code !== 200) { + const error = new Error(msg); + error.response = res; + reject(error); + } else { + resolve(res.data); + } + } catch (error) { + reject(error); + } + }, + fail: reject, + }); + }); + } catch (error) { + error = await decryptTransportErrorResponse(error, config); + if (shouldRetryTransportWithFreshKey(error) && !config.__transportRetried) { + invalidateTransportKeyMeta(); + config.__transportRetried = true; + config.headers.repeatSubmit = false; + resetTransportRequestConfig(config); + return request(config); + } + + const response = error.response; + const responseStatus = response?.status ?? response?.statusCode; + const responseCode = response?.data?.code; + const responseMsg = response?.data?.msg; + if (responseMsg) { + uni.showToast({ + title: responseMsg, + icon: "none", + duration: responseStatus === 429 || responseCode === 429 ? 5000 : 3000, + }); + throw error; + } + + let { message } = error; + if (message === "Network Error") { + message = "后端接口连接异常"; + } else if (message && message.includes("timeout")) { + message = "系统接口请求超时"; + } else if (message && message.includes("Request failed with status code")) { + message = "系统接口" + message.substr(message.length - 3) + "异常"; + } + if (message) { + toast(message); + } + throw error; + } +}; + +export default request; diff --git a/shierfeng-fastapi-app/src/utils/storage.js b/shierfeng-fastapi-app/src/utils/storage.js new file mode 100644 index 0000000..957579e --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/storage.js @@ -0,0 +1,38 @@ +import constant from "./constant"; + +// 存储变量名 +let storageKey = "storage_data"; + +// 存储节点变量名 +let storageNodeKeys = [ + constant.avatar, + constant.id, + constant.name, + constant.roles, + constant.permissions, +]; + +const storage = { + set: function (key, value) { + if (storageNodeKeys.indexOf(key) != -1) { + let tmp = uni.getStorageSync(storageKey); + tmp = tmp ? tmp : {}; + tmp[key] = value; + uni.setStorageSync(storageKey, tmp); + } + }, + get: function (key) { + let storageData = uni.getStorageSync(storageKey) || {}; + return storageData[key] || ""; + }, + remove: function (key) { + let storageData = uni.getStorageSync(storageKey) || {}; + delete storageData[key]; + uni.setStorageSync(storageKey, storageData); + }, + clean: function () { + uni.removeStorageSync(storageKey); + }, +}; + +export default storage; diff --git a/shierfeng-fastapi-app/src/utils/transportCrypto.js b/shierfeng-fastapi-app/src/utils/transportCrypto.js new file mode 100644 index 0000000..0251aa6 --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/transportCrypto.js @@ -0,0 +1,1236 @@ +import config from "@/config"; +import forge, { primeForgeRandomBytes } from "@/utils/transportForge"; +import { + ensureTransportCryptoPolicyLoaded, + getTransportCryptoPolicy, + getTransportRequestPath, + shouldEncryptQuery, + shouldEncryptRequest, + shouldEncryptResponse, +} from "@/utils/transportCryptoPolicy"; + +const TRANSPORT_BASE_URL = config.baseUrl; +const TRANSPORT_ENABLE_HEADER = "X-Transport-Encrypt"; +const TRANSPORT_KEY_ID_HEADER = "X-Key-Id"; +const ENCRYPTED_RESPONSE_HEADER = "x-body-encrypted"; +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = "1"; +const KEY_REFRESH_BUFFER_MIN_SECONDS = 30; +const KEY_REFRESH_BUFFER_MAX_SECONDS = 300; +const TRANSPORT_KEY_META_CACHE_KEY = "transportCryptoKeyMeta"; +const TRANSPORT_RETRYABLE_ERROR_MESSAGES = new Set([ + "Decryption failed", + "密钥版本不存在", +]); +const AES_GCM_TAG_LENGTH_BYTES = 16; +const FORGE_RANDOM_POOL_BYTES = 4096; + +let cachedKeyMeta = null; +let inflightKeyMetaPromise = null; + +/** + * 获取当前运行时的全局对象引用。 + * + * @returns {Object|undefined} 全局对象 + */ +function getRuntimeGlobal() { + if (typeof globalThis !== "undefined") { + return globalThis; + } + if (typeof self !== "undefined") { + return self; + } + if (typeof window !== "undefined") { + return window; + } + if (typeof global !== "undefined") { + return global; + } + return undefined; +} + +/** + * 为 Forge 补齐运行时依赖的全局对象别名。 + * + * @returns {void} + */ +function ensureForgeRuntimeGlobal() { + const runtimeGlobal = getRuntimeGlobal(); + if (!runtimeGlobal) { + return; + } + if (typeof runtimeGlobal.self === "undefined") { + runtimeGlobal.self = runtimeGlobal; + } + if (typeof runtimeGlobal.window === "undefined") { + runtimeGlobal.window = runtimeGlobal; + } + if (typeof runtimeGlobal.global === "undefined") { + runtimeGlobal.global = runtimeGlobal; + } +} + +/** + * 获取已完成运行时适配的 Forge 实例。 + * + * @returns {Promise} Forge 实例 + */ +async function getForge() { + ensureForgeRuntimeGlobal(); + return forge; +} + +/** + * 从请求头对象中读取指定字段。 + * + * @param {Object} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined; + } + return headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()]; +} + +/** + * 为请求头对象设置指定字段。 + * + * @param {Object} headers 请求头对象 + * @param {string} name 请求头名称 + * @param {*} value 请求头值 + * @returns {void} + */ +function setHeaderValue(headers, name, value) { + if (!headers) { + return; + } + headers[name] = value; +} + +/** + * 获取 Uni 响应对象中的响应头映射,兼容 H5 与其它平台字段差异。 + * + * @param {Object} response Uni 响应对象 + * @returns {Object|undefined} 响应头对象 + */ +function getResponseHeaders(response) { + return response?.header || response?.headers; +} + +/** + * 将文本编码为 UTF-8 二进制字符串。 + * + * @param {string} text 原始文本 + * @returns {string} UTF-8 二进制字符串 + */ +function encodeUtf8(text) { + if (typeof TextEncoder !== "undefined") { + return uint8ArrayToBytes(new TextEncoder().encode(String(text || ""))); + } + return unescape(encodeURIComponent(String(text || ""))); +} + +/** + * 将 UTF-8 二进制字符串解码为文本。 + * + * @param {string} bytes UTF-8 二进制字符串 + * @returns {string} 解码后的文本 + */ +function decodeUtf8(bytes) { + if (typeof TextDecoder !== "undefined") { + return new TextDecoder().decode(bytesToUint8Array(bytes)); + } + return decodeURIComponent(escape(bytes)); +} + +/** + * 将 Uint8Array 转为 Forge 兼容的二进制字符串。 + * + * @param {Uint8Array} uint8Array 字节数组 + * @returns {string} 二进制字符串 + */ +function uint8ArrayToBytes(uint8Array) { + return Array.from(uint8Array, (item) => String.fromCharCode(item)).join(""); +} + +/** + * 将二进制字符串还原为 Uint8Array。 + * + * @param {string} bytes 二进制字符串 + * @returns {Uint8Array} 字节数组 + */ +function bytesToUint8Array(bytes) { + return Uint8Array.from(String(bytes || ""), (item) => item.charCodeAt(0)); +} + +/** + * 获取对象的内部类型标签。 + * + * @param {*} value 待检测值 + * @returns {string} 内部类型标签 + */ +function getObjectTag(value) { + return Object.prototype.toString.call(value); +} + +/** + * 判断对象是否为 ArrayBuffer 视图。 + * + * @param {*} value 待检测值 + * @returns {boolean} 是否为视图对象 + */ +function isArrayBufferView(value) { + return typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value); +} + +/** + * 判断对象是否表现为 ArrayBuffer。 + * + * @param {*} value 待检测值 + * @returns {boolean} 是否可按 ArrayBuffer 处理 + */ +function isArrayBufferLike(value) { + return ( + value instanceof ArrayBuffer || + getObjectTag(value) === "[object ArrayBuffer]" || + (value && + typeof value === "object" && + typeof value.byteLength === "number" && + typeof value.slice === "function" && + !("length" in value)) + ); +} + +/** + * 判断对象是否为数组风格的字节容器。 + * + * @param {*} value 待检测值 + * @returns {boolean} 是否可按字节数组处理 + */ +function isByteArrayLikeObject(value) { + return ( + value && + typeof value === "object" && + typeof value.length === "number" && + value.length >= 0 + ); +} + +/** + * 尝试通过平台 Base64 API 归一化原生 ArrayBuffer 对象。 + * + * @param {*} randomValues 平台返回的随机数字节对象 + * @returns {string|null} 归一化后的字节串,失败时返回 null + */ +function tryNormalizeArrayBufferLikeResult(randomValues) { + const arrayBufferToBase64Api = + (typeof uni !== "undefined" && uni.arrayBufferToBase64) || + (typeof wx !== "undefined" && wx.arrayBufferToBase64); + const base64ToArrayBufferApi = + (typeof uni !== "undefined" && uni.base64ToArrayBuffer) || + (typeof wx !== "undefined" && wx.base64ToArrayBuffer); + + if (!arrayBufferToBase64Api || !base64ToArrayBufferApi) { + return null; + } + + try { + const base64Text = arrayBufferToBase64Api(randomValues); + const arrayBuffer = base64ToArrayBufferApi(base64Text); + return uint8ArrayToBytes(new Uint8Array(arrayBuffer)); + } catch (error) { + return null; + } +} + +/** + * 将平台返回的随机数字节结果统一转换为二进制字符串。 + * + * @param {*} randomValues 平台返回结果 + * @param {number} expectedLength 期望字节长度 + * @returns {string} 归一化后的二进制字符串 + */ +function normalizeRandomBytesResult(randomValues, expectedLength) { + if (randomValues instanceof Uint8Array) { + return uint8ArrayToBytes(randomValues); + } + if (isArrayBufferView(randomValues)) { + return uint8ArrayToBytes( + new Uint8Array( + randomValues.buffer, + randomValues.byteOffset || 0, + randomValues.byteLength, + ), + ); + } + if (randomValues instanceof ArrayBuffer) { + return uint8ArrayToBytes(new Uint8Array(randomValues)); + } + if (Array.isArray(randomValues)) { + return uint8ArrayToBytes(Uint8Array.from(randomValues)); + } + if (randomValues && typeof randomValues === "object") { + if (randomValues.randomValues) { + return normalizeRandomBytesResult( + randomValues.randomValues, + expectedLength, + ); + } + if (randomValues.value) { + return normalizeRandomBytesResult(randomValues.value, expectedLength); + } + if (randomValues.data) { + return normalizeRandomBytesResult(randomValues.data, expectedLength); + } + if (randomValues.buffer instanceof ArrayBuffer) { + return normalizeRandomBytesResult( + new Uint8Array( + randomValues.buffer, + randomValues.byteOffset || 0, + randomValues.byteLength || expectedLength, + ), + expectedLength, + ); + } + if (isArrayBufferLike(randomValues)) { + const normalizedBytes = tryNormalizeArrayBufferLikeResult(randomValues); + if (normalizedBytes !== null) { + return normalizedBytes; + } + } + if (isByteArrayLikeObject(randomValues)) { + return uint8ArrayToBytes(Uint8Array.from(randomValues)); + } + } + if (typeof randomValues === "string" && randomValues.length === expectedLength) { + return randomValues; + } + throw new Error( + `平台随机数返回结果格式不受支持: tag=${getObjectTag(randomValues)}, keys=${Object.keys( + randomValues || {}, + ).join(",")}`, + ); +} + +/** + * 请求平台随机数字节并统一为二进制字符串。 + * + * @param {Function} api 平台随机数 API + * @param {number} length 需要的字节长度 + * @returns {Promise} 随机数字节串 + */ +function requestPlatformRandomBytes(api, length) { + return new Promise((resolve, reject) => { + let settled = false; + const resolveOnce = (result) => { + if (settled) { + return; + } + settled = true; + resolve(normalizeRandomBytesResult(result, length)); + }; + const rejectOnce = (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + try { + const maybeResult = api({ + length, + success: resolveOnce, + fail: rejectOnce, + }); + if (maybeResult && typeof maybeResult.then === "function") { + maybeResult.then(resolveOnce).catch(rejectOnce); + return; + } + if ( + maybeResult && + (maybeResult.randomValues || maybeResult.value || maybeResult.data) + ) { + resolveOnce(maybeResult); + } + } catch (error) { + rejectOnce(error); + } + }); +} + +/** + * 在 app-plus iOS 端通过原生 NSUUID 获取随机字节兜底。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机数字节串,当前平台不支持时返回 null + */ +function getAppPlusIosRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "ios" || typeof plus.ios?.importClass !== "function") { + return null; + } + + try { + const uuidClass = plus.ios.importClass("NSUUID"); + let randomBytes = ""; + while (randomBytes.length < length) { + const uuidObject = plus.ios.invoke(uuidClass, "UUID"); + const uuidText = String(plus.ios.invoke(uuidObject, "UUIDString") || "") + .replace(/-/g, "") + .toLowerCase(); + plus.ios.deleteObject(uuidObject); + if (!uuidText) { + return null; + } + for (let index = 0; index < uuidText.length && randomBytes.length < length; index += 2) { + const byteValue = parseInt(uuidText.slice(index, index + 2), 16); + if (isNaN(byteValue)) { + return null; + } + randomBytes += String.fromCharCode(byteValue); + } + } + return randomBytes; + } catch (error) { + console.warn("iOS NSUUID 初始化失败,继续尝试其它随机数能力", error); + } + // #endif + return null; +} + +/** + * 在 app-plus Android 端通过原生 SecureRandom 获取随机字节。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机数字节串,当前平台不支持时返回 null + */ +function getAppPlusAndroidRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "android" || typeof plus.android?.importClass !== "function") { + return null; + } + + try { + plus.android.importClass("java.security.SecureRandom"); + const secureRandom = + typeof plus.android.newObject === "function" + ? plus.android.newObject("java.security.SecureRandom") + : null; + if (!secureRandom) { + return null; + } + plus.android.importClass(secureRandom); + let randomBytes = ""; + for (let index = 0; index < length; index += 1) { + randomBytes += String.fromCharCode(secureRandom.nextInt(256)); + } + if (typeof plus.android.autoCollection === "function") { + plus.android.autoCollection(secureRandom); + } + return randomBytes; + } catch (error) { + console.warn("Android SecureRandom 初始化失败,继续尝试其它随机数能力", error); + } + // #endif + return null; +} + +/** + * 获取当前运行环境下的安全随机数字节。 + * + * @param {number} length 需要的字节长度 + * @returns {Promise} 随机数字节串 + */ +async function getRandomBytes(length) { + const runtimeGlobal = getRuntimeGlobal(); + const runtimeCrypto = runtimeGlobal?.crypto; + if (runtimeCrypto?.getRandomValues) { + const bytes = new Uint8Array(length); + runtimeCrypto.getRandomValues(bytes); + return uint8ArrayToBytes(bytes); + } + if (typeof uni !== "undefined" && typeof uni.getRandomValues === "function") { + return requestPlatformRandomBytes(uni.getRandomValues, length); + } + if (typeof wx !== "undefined" && typeof wx.getRandomValues === "function") { + return requestPlatformRandomBytes(wx.getRandomValues, length); + } + const appPlusRandomBytes = + getAppPlusAndroidRandomBytes(length) || getAppPlusIosRandomBytes(length); + if (appPlusRandomBytes !== null) { + return appPlusRandomBytes; + } + throw new Error("当前运行环境缺少安全随机数能力"); +} + +/** + * 预填充 Forge 需要的随机数字节池。 + * + * @param {number} length 预填充长度 + * @returns {Promise} + */ +async function primeForgeRandomPool(length = FORGE_RANDOM_POOL_BYTES) { + primeForgeRandomBytes(await getRandomBytes(length)); +} + +/** + * 将二进制字符串编码为 Base64URL 文本。 + * + * @param {string} bytes 二进制字符串 + * @returns {string} Base64URL 文本 + */ +function toBase64Url(bytes) { + const base64Text = uni.arrayBufferToBase64(bytesToUint8Array(bytes).buffer); + return base64Text.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +/** + * 将 Base64URL 文本还原为二进制字符串。 + * + * @param {string} text Base64URL 文本 + * @returns {string} 二进制字符串 + */ +function fromBase64Url(text) { + const normalizedText = String(text || "").replace(/-/g, "+").replace(/_/g, "/"); + const paddingLength = (4 - (normalizedText.length % 4 || 4)) % 4; + const arrayBuffer = uni.base64ToArrayBuffer( + normalizedText + "=".repeat(paddingLength), + ); + return uint8ArrayToBytes(new Uint8Array(arrayBuffer)); +} + +/** + * 将查询信封编码为可放入 URL 的字符串。 + * + * @param {Object} envelope 查询信封 + * @returns {string} 编码后的查询参数值 + */ +function encodeQueryEnvelope(envelope) { + return toBase64Url(encodeUtf8(JSON.stringify(envelope))); +} + +/** + * 计算加密查询参数最终生成的 URL 长度。 + * + * @param {string} url 请求地址 + * @param {Object} params 查询参数 + * @returns {number} URL 长度 + */ +function buildQueryUrlLength(url = "", params = {}) { + const queryText = Object.keys(params) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join("&"); + if (!queryText) { + return String(url || "").length; + } + const normalizedUrl = String(url || ""); + const separator = normalizedUrl.includes("?") ? "&" : "?"; + return `${normalizedUrl}${separator}${queryText}`.length; +} + +/** + * 构建请求方向的 AAD 元数据。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Object} 请求 AAD + */ +function buildRequestAad(requestConfig) { + return { + method: (requestConfig.method || "get").toUpperCase(), + path: getTransportRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ), + }; +} + +/** + * 构建响应方向的 AAD 元数据。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Object} 响应 AAD + */ +function buildResponseAad(requestConfig) { + return { + method: (requestConfig.method || "get").toUpperCase(), + path: getTransportRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ), + direction: "response", + }; +} + +/** + * 将空值载荷规范化为可序列化对象。 + * + * @param {*} payload 原始载荷 + * @returns {*} 规范化后的载荷 + */ +function normalizePlainPayload(payload) { + if (payload === undefined || payload === null) { + return {}; + } + return payload; +} + +/** + * 将请求载荷序列化为 JSON 文本。 + * + * @param {*} payload 原始载荷 + * @returns {string} JSON 文本 + */ +function stringifyPayload(payload) { + return JSON.stringify(normalizePlainPayload(payload)); +} + +/** + * 克隆请求配置中的可变字段,便于失败重试恢复。 + * + * @param {*} value 待克隆值 + * @returns {*} 克隆结果 + */ +function cloneRequestValue(value) { + if (value === undefined || value === null) { + return value; + } + const runtimeGlobal = getRuntimeGlobal(); + if (typeof runtimeGlobal?.structuredClone === "function") { + return runtimeGlobal.structuredClone(value); + } + if (typeof value === "object") { + return JSON.parse(JSON.stringify(value)); + } + return value; +} + +/** + * 将信封字段转换为适合表单提交的字符串。 + * + * @param {*} value 字段值 + * @returns {string} 序列化文本 + */ +function stringifyEnvelopeField(value) { + if (value && typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +/** + * 将加密信封编码为 x-www-form-urlencoded 文本。 + * + * @param {Object} envelope 信封对象 + * @returns {string} 表单编码文本 + */ +function encodeFormEnvelope(envelope) { + return Object.entries(envelope) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent( + stringifyEnvelopeField(value), + )}`, + ) + .join("&"); +} + +/** + * 将输入解析为 JSON 对象并校验结构。 + * + * @param {*} payload 原始数据 + * @param {string} errorMessage 失败提示 + * @returns {Object} 解析后的对象 + */ +function parseJsonObject(payload, errorMessage) { + const parsedPayload = typeof payload === "string" ? JSON.parse(payload) : payload; + if (!parsedPayload || typeof parsedPayload !== "object" || Array.isArray(parsedPayload)) { + throw new Error(errorMessage); + } + return parsedPayload; +} + +/** + * 基于随机字节生成 UUID v4 字符串。 + * + * @returns {Promise} UUID 文本 + */ +async function createUuid() { + const bytes = (await getRandomBytes(16)) + .split("") + .map((item) => item.charCodeAt(0)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hexText = bytes + .map((item) => item.toString(16).padStart(2, "0")) + .join(""); + return `${hexText.slice(0, 8)}-${hexText.slice(8, 12)}-${hexText.slice( + 12, + 16, + )}-${hexText.slice(16, 20)}-${hexText.slice(20)}`; +} + +/** + * 校验公钥接口响应壳是否有效。 + * + * @param {Object} responsePayload 公钥接口原始响应 + * @returns {void} + */ +function validateTransportPublicKeyResponse(responsePayload) { + if ( + responsePayload?.code !== 200 || + !responsePayload?.data || + typeof responsePayload.data !== "object" + ) { + throw new Error(responsePayload?.msg || "获取传输层公钥失败"); + } +} + +/** + * 校验公钥业务载荷是否满足当前协议要求。 + * + * @param {Object} payload 公钥业务载荷 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateTransportPublicKeyPayload(payload, transportPolicy) { + if (!payload?.publicKey || !payload?.kid) { + throw new Error("获取传输层公钥失败"); + } + if ( + String(payload.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION) !== + transportPolicy.envelopeVersion + ) { + throw new Error("传输层公钥协议版本不受支持"); + } + if (payload.alg !== transportPolicy.requestEnvelopeAlgorithm) { + throw new Error("传输层公钥算法不受支持"); + } +} + +/** + * 校验响应信封与当前请求上下文是否一致。 + * + * @param {Object} envelope 响应信封 + * @param {Object} response 原始响应对象 + * @param {Object} requestConfig 请求配置 + * @param {Object} transportContext 请求加密上下文 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateResponseEnvelope( + envelope, + response, + requestConfig, + transportContext, + transportPolicy, +) { + const expectedAad = buildResponseAad(requestConfig); + const responseKid = getHeaderValue( + getResponseHeaders(response), + TRANSPORT_KEY_ID_HEADER, + ); + const aad = envelope.aad; + + if (String(envelope.v || "") !== transportPolicy.envelopeVersion) { + throw new Error("传输层响应协议版本不受支持"); + } + if (String(envelope.alg || "") !== transportPolicy.responseEnvelopeAlgorithm) { + throw new Error("传输层响应算法不受支持"); + } + if (String(envelope.kid || "") !== String(transportContext.kid)) { + throw new Error("传输层响应密钥版本不匹配"); + } + if (responseKid && String(envelope.kid) !== String(responseKid)) { + throw new Error("传输层响应头与响应体密钥版本不一致"); + } + if (!aad || typeof aad !== "object" || Array.isArray(aad)) { + throw new Error("传输层响应AAD不合法"); + } + if ( + String(aad.method || "").toUpperCase() !== expectedAad.method || + String(aad.path || "") !== expectedAad.path + ) { + throw new Error("传输层响应的method/path与当前请求不匹配"); + } + if (String(aad.direction || "") !== expectedAad.direction) { + throw new Error("传输层响应方向标识不合法"); + } +} + +/** + * 记录原始请求快照,供密钥刷新后重试恢复。 + * + * @param {Object} requestConfig 请求配置 + * @returns {void} + */ +function rememberOriginalRequestSnapshot(requestConfig) { + if (requestConfig.__transportOriginalSnapshot) { + return; + } + requestConfig.__transportOriginalSnapshot = { + url: requestConfig.url, + params: cloneRequestValue(requestConfig.params), + data: cloneRequestValue(requestConfig.data), + contentType: getHeaderValue(requestConfig.header, "Content-Type"), + }; +} + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000); +} + +/** + * 根据公钥有效期计算本地提前刷新时间。 + * + * @param {number} expireAt 公钥过期时间 + * @param {number} fetchedAt 公钥获取时间 + * @returns {number} 建议刷新时间 + */ +function buildKeyRefreshAt(expireAt, fetchedAt = getNowTimestamp()) { + const normalizedExpireAt = Number(expireAt || 0); + const normalizedFetchedAt = Number(fetchedAt || 0); + const ttlSeconds = Math.max(normalizedExpireAt - normalizedFetchedAt, 0); + if (!normalizedExpireAt || !ttlSeconds) { + return 0; + } + const refreshBufferSeconds = Math.min( + KEY_REFRESH_BUFFER_MAX_SECONDS, + Math.max(KEY_REFRESH_BUFFER_MIN_SECONDS, Math.floor(ttlSeconds * 0.1)), + ); + return Math.max(normalizedFetchedAt, normalizedExpireAt - refreshBufferSeconds); +} + +/** + * 判断当前缓存的公钥元数据是否仍可使用。 + * + * @param {Object} keyMeta 公钥元数据 + * @param {number} nowTimestamp 当前时间戳 + * @returns {boolean} 是否仍可使用 + */ +function isUsableKeyMeta(keyMeta, nowTimestamp = getNowTimestamp()) { + if (!keyMeta?.publicKeyPem || !keyMeta?.kid || !keyMeta?.expireAt) { + return false; + } + const refreshAt = Number( + keyMeta.refreshAt || + buildKeyRefreshAt(keyMeta.expireAt, keyMeta.fetchedAt || nowTimestamp), + ); + return refreshAt > nowTimestamp; +} + +/** + * 请求后端公钥接口。 + * 这里故意直接使用原始 uni.request,避免在获取公钥前再次进入统一 request + * 包装器而形成“加密请求依赖公钥、公钥请求又依赖加密请求”的启动环路。 + * + * @param {string} publicKeyUrl 公钥接口地址 + * @returns {Promise} Uni 请求响应 + */ +function requestPublicKey(publicKeyUrl) { + return new Promise((resolve, reject) => { + uni.request({ + url: `${TRANSPORT_BASE_URL}${publicKeyUrl}`, + method: "GET", + timeout: 10000, + success: resolve, + fail: reject, + }); + }); +} + +/** + * 获取当前可用的后端公钥元信息。 + * + * @param {boolean} forceRefresh 是否强制刷新 + * @returns {Promise} 公钥元信息 + */ +async function getTransportKeyMeta(forceRefresh = false) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded(); + const nowTimestamp = getNowTimestamp(); + if (!forceRefresh && !cachedKeyMeta) { + const persistedKeyMeta = uni.getStorageSync(TRANSPORT_KEY_META_CACHE_KEY); + if (isUsableKeyMeta(persistedKeyMeta, nowTimestamp)) { + cachedKeyMeta = { + kid: persistedKeyMeta.kid, + alg: persistedKeyMeta.alg, + envelopeVersion: + persistedKeyMeta.envelopeVersion || transportPolicy.envelopeVersion, + publicKeyPem: persistedKeyMeta.publicKeyPem, + expireAt: persistedKeyMeta.expireAt, + fetchedAt: persistedKeyMeta.fetchedAt || nowTimestamp, + refreshAt: + persistedKeyMeta.refreshAt || + buildKeyRefreshAt( + persistedKeyMeta.expireAt, + persistedKeyMeta.fetchedAt || nowTimestamp, + ), + }; + } + } + if (!forceRefresh && isUsableKeyMeta(cachedKeyMeta, nowTimestamp)) { + return cachedKeyMeta; + } + if (inflightKeyMetaPromise) { + return inflightKeyMetaPromise; + } + inflightKeyMetaPromise = requestPublicKey( + transportPolicy.publicKeyUrl || "/transport/crypto/public-key", + ) + .then((response) => { + const responsePayload = response.data || {}; + const payload = responsePayload.data || {}; + const fetchedAt = getNowTimestamp(); + validateTransportPublicKeyResponse(responsePayload); + validateTransportPublicKeyPayload(payload, transportPolicy); + cachedKeyMeta = { + kid: payload.kid, + alg: payload.alg, + envelopeVersion: String( + payload.envelopeVersion || transportPolicy.envelopeVersion, + ), + publicKeyPem: payload.publicKey, + expireAt: payload.expireAt, + fetchedAt, + refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt), + }; + uni.setStorageSync(TRANSPORT_KEY_META_CACHE_KEY, cachedKeyMeta); + inflightKeyMetaPromise = null; + return cachedKeyMeta; + }) + .catch((error) => { + inflightKeyMetaPromise = null; + throw error; + }); + return inflightKeyMetaPromise; +} + +/** + * 使用 RSA-OAEP 加密当前请求的 AES 会话密钥。 + * + * @param {string} publicKeyPem PEM 格式公钥 + * @param {string} aesKeyBytes AES 会话密钥字节串 + * @returns {Promise} 加密后的会话密钥 + */ +async function rsaEncryptAesKey(publicKeyPem, aesKeyBytes) { + const cryptoForge = await getForge(); + await primeForgeRandomPool(); + const publicKey = cryptoForge.pki.publicKeyFromPem(String(publicKeyPem || "")); + return publicKey.encrypt(aesKeyBytes, "RSA-OAEP", { + md: cryptoForge.md.sha256.create(), + mgf1: { + md: cryptoForge.md.sha256.create(), + }, + }); +} + +/** + * 为当前请求构建一次性的传输加密上下文。 + * + * @returns {Promise} 请求级传输上下文 + */ +async function buildTransportContext() { + const keyMeta = await getTransportKeyMeta(); + const aesKey = await getRandomBytes(32); + const encryptedAesKey = await rsaEncryptAesKey(keyMeta.publicKeyPem, aesKey); + return { + kid: keyMeta.kid, + alg: keyMeta.alg, + envelopeVersion: keyMeta.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + aesKey, + ek: toBase64Url(encryptedAesKey), + }; +} + +/** + * 使用 AES-GCM 对明文载荷执行信封加密。 + * + * @param {Object} context 请求级传输上下文 + * @param {string} plainText 明文内容 + * @param {Object} aad AAD 元数据 + * @returns {Promise} 加密信封 + */ +async function encryptPayloadText(context, plainText, aad) { + const cryptoForge = await getForge(); + const iv = await getRandomBytes(12); + const cipher = cryptoForge.cipher.createCipher("AES-GCM", context.aesKey); + cipher.start({ + iv, + additionalData: encodeUtf8(JSON.stringify(aad)), + tagLength: AES_GCM_TAG_LENGTH_BYTES * 8, + }); + cipher.update(cryptoForge.util.createBuffer(encodeUtf8(plainText))); + if (!cipher.finish()) { + throw new Error("Encryption failed"); + } + const ciphertext = cipher.output.getBytes() + cipher.mode.tag.getBytes(); + return { + v: context.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + kid: context.kid, + alg: context.alg, + ts: getNowTimestamp(), + nonce: await createUuid(), + ek: context.ek, + aad, + iv: toBase64Url(iv), + ct: toBase64Url(ciphertext), + }; +} + +/** + * 使用请求上下文中的 AES 密钥解密响应信封。 + * + * @param {Object} envelope 响应信封 + * @param {Object} context 请求级传输上下文 + * @returns {Promise} 解密后的明文 + */ +async function decryptEnvelope(envelope, context) { + const cryptoForge = await getForge(); + const encryptedPayload = fromBase64Url(envelope.ct); + if (encryptedPayload.length <= AES_GCM_TAG_LENGTH_BYTES) { + throw new Error("Decryption failed"); + } + const ciphertext = encryptedPayload.slice(0, -AES_GCM_TAG_LENGTH_BYTES); + const tag = encryptedPayload.slice(-AES_GCM_TAG_LENGTH_BYTES); + const decipher = cryptoForge.cipher.createDecipher("AES-GCM", context.aesKey); + decipher.start({ + iv: fromBase64Url(envelope.iv), + additionalData: encodeUtf8(JSON.stringify(envelope.aad || {})), + tagLength: AES_GCM_TAG_LENGTH_BYTES * 8, + tag, + }); + decipher.update(cryptoForge.util.createBuffer(ciphertext)); + if (!decipher.finish()) { + throw new Error("Decryption failed"); + } + return decodeUtf8(decipher.output.getBytes()); +} + +/** + * 复用同一次请求内的传输上下文,避免重复生成密钥。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Promise} 请求级传输上下文 + */ +function getOrCreateTransportContext(requestConfig) { + if (requestConfig.__transportCryptoContextPromise) { + return requestConfig.__transportCryptoContextPromise; + } + requestConfig.__transportCryptoContextPromise = buildTransportContext(); + return requestConfig.__transportCryptoContextPromise; +} + +/** + * 对 Uni 请求配置执行传输层加密封装。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Promise} 加密后的请求配置 + */ +export async function encryptTransportRequest(requestConfig) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded(); + if (!shouldEncryptRequest(requestConfig, transportPolicy)) { + requestConfig.__transportCryptoEnabledForRequest = false; + return requestConfig; + } + + rememberOriginalRequestSnapshot(requestConfig); + const transportContext = await getOrCreateTransportContext(requestConfig); + const contentType = String( + getHeaderValue(requestConfig.header, "Content-Type") || "application/json", + ).toLowerCase(); + const method = (requestConfig.method || "get").toLowerCase(); + const requestAad = buildRequestAad(requestConfig); + + if ( + shouldEncryptQuery(requestConfig, transportPolicy) && + (requestConfig.params || method === "get" || method === "delete") + ) { + const queryEnvelope = await encryptPayloadText( + transportContext, + JSON.stringify(normalizePlainPayload(requestConfig.params)), + requestAad, + ); + requestConfig.params = { __enc: encodeQueryEnvelope(queryEnvelope) }; + if ( + buildQueryUrlLength(requestConfig.url, requestConfig.params) > + Number(transportPolicy.maxEncryptedGetUrlLength || 4096) + ) { + throw new Error( + "当前GET/DELETE请求参数加密后长度超限,请改用POST请求或精简查询条件", + ); + } + } + + if (["post", "put", "patch", "delete"].includes(method)) { + const plainText = stringifyPayload(requestConfig.data); + const bodyEnvelope = await encryptPayloadText( + transportContext, + plainText, + requestAad, + ); + if (contentType.includes("application/x-www-form-urlencoded")) { + requestConfig.data = encodeFormEnvelope(bodyEnvelope); + } else { + requestConfig.data = bodyEnvelope; + setHeaderValue( + requestConfig.header, + "Content-Type", + "application/json;charset=utf-8", + ); + } + } + + setHeaderValue(requestConfig.header, TRANSPORT_ENABLE_HEADER, "1"); + setHeaderValue(requestConfig.header, TRANSPORT_KEY_ID_HEADER, transportContext.kid); + requestConfig.__transportCryptoContext = transportContext; + requestConfig.__transportCryptoEnabledForRequest = true; + return requestConfig; +} + +/** + * 清空当前缓存的公钥元数据。 + * + * @returns {void} + */ +export function invalidateTransportKeyMeta() { + cachedKeyMeta = null; + inflightKeyMetaPromise = null; + uni.removeStorageSync(TRANSPORT_KEY_META_CACHE_KEY); +} + +/** + * 将被加密改写过的请求恢复为原始形态。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Object} 恢复后的请求配置 + */ +export function resetTransportRequestConfig(requestConfig) { + const originalSnapshot = requestConfig?.__transportOriginalSnapshot; + if (!requestConfig || !originalSnapshot) { + return requestConfig; + } + + requestConfig.url = originalSnapshot.url; + requestConfig.params = cloneRequestValue(originalSnapshot.params); + requestConfig.data = cloneRequestValue(originalSnapshot.data); + if (originalSnapshot.contentType) { + setHeaderValue(requestConfig.header, "Content-Type", originalSnapshot.contentType); + } + delete requestConfig.__transportCryptoContext; + delete requestConfig.__transportCryptoContextPromise; + delete requestConfig.__transportCryptoEnabledForRequest; + return requestConfig; +} + +/** + * 判断错误是否属于可通过刷新公钥进行重试的场景。 + * + * @param {Object} responseOrError 响应对象或错误对象 + * @returns {boolean} 是否可刷新密钥重试 + */ +export function shouldRetryTransportWithFreshKey(responseOrError) { + const responseMsg = responseOrError?.data?.msg || responseOrError?.response?.data?.msg; + const errorMessage = responseOrError?.message; + return ( + TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(responseMsg) || + TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(errorMessage) + ); +} + +/** + * 解密成功响应中的传输层信封。 + * + * @param {Object} response 原始响应对象 + * @param {Object} requestConfig 请求配置 + * @returns {Promise} 解密后的响应对象 + */ +export async function decryptTransportResponse(response, requestConfig) { + const encryptedResponseFlag = String( + getHeaderValue(getResponseHeaders(response), ENCRYPTED_RESPONSE_HEADER) || "", + ); + if (encryptedResponseFlag !== "1") { + return response; + } + if (!shouldEncryptResponse(requestConfig, getTransportCryptoPolicy())) { + return response; + } + const transportPolicy = getTransportCryptoPolicy(); + const transportContext = requestConfig.__transportCryptoContext; + if (!transportContext) { + throw new Error("缺少响应解密上下文"); + } + + const envelope = parseJsonObject(response.data, "传输层响应信封格式不合法"); + validateResponseEnvelope( + envelope, + response, + requestConfig, + transportContext, + transportPolicy, + ); + const plaintext = await decryptEnvelope(envelope, transportContext); + response.data = JSON.parse(plaintext); + return response; +} + +/** + * 尝试解密异常响应中的传输层信封。 + * + * @param {Object} error 错误对象 + * @param {Object} requestConfig 原始请求配置 + * @returns {Promise} 原始或已解密的错误对象 + */ +export async function decryptTransportErrorResponse(error, requestConfig) { + const response = + error?.response || + (error && (error.data !== undefined || error.header || error.headers || error.statusCode) + ? error + : null); + const encryptedResponseFlag = String( + getHeaderValue(getResponseHeaders(response), ENCRYPTED_RESPONSE_HEADER) || "", + ); + if (!response || encryptedResponseFlag !== "1") { + return error; + } + if (!shouldEncryptResponse(requestConfig || {}, getTransportCryptoPolicy())) { + return error; + } + const transportPolicy = getTransportCryptoPolicy(); + const transportContext = requestConfig?.__transportCryptoContext; + if (!transportContext) { + return error; + } + + try { + const envelope = parseJsonObject(response.data, "传输层响应信封格式不合法"); + validateResponseEnvelope( + envelope, + response, + requestConfig, + transportContext, + transportPolicy, + ); + const plaintext = await decryptEnvelope(envelope, transportContext); + response.data = JSON.parse(plaintext); + if (!error.response) { + error.response = response; + } + } catch (decryptError) { + console.error(decryptError); + } + return error; +} diff --git a/shierfeng-fastapi-app/src/utils/transportCryptoPolicy.js b/shierfeng-fastapi-app/src/utils/transportCryptoPolicy.js new file mode 100644 index 0000000..022d4ef --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/transportCryptoPolicy.js @@ -0,0 +1,436 @@ +import config from "@/config"; + +const TRANSPORT_BASE_URL = config.baseUrl; +const EXCLUDED_URL_PATTERNS = [ + "/transport/crypto/frontend-config", + "/transport/crypto/public-key", + "/common/download", + "/common/download/resource", +]; +const TRANSPORT_FRONTEND_CONFIG_CACHE_KEY = "transportCryptoFrontendConfig"; +const TRANSPORT_FRONTEND_CONFIG_URL = "/transport/crypto/frontend-config"; +const TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS = 60; +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = "1"; +const DEFAULT_REQUEST_ENVELOPE_ALGORITHM = "RSA_OAEP_AES_256_GCM"; +const DEFAULT_RESPONSE_ENVELOPE_ALGORITHM = "AES_256_GCM"; +const DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH = 4096; + +let cachedTransportPolicy = null; +let inflightTransportPolicyPromise = null; + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000); +} + +/** + * 判断请求地址是否命中固定排除名单。 + * + * @param {string} url 请求地址 + * @returns {boolean} 是否命中排除规则 + */ +function matchExcludedUrl(url = "") { + return EXCLUDED_URL_PATTERNS.some((pattern) => url.includes(pattern)); +} + +/** + * 判断请求路径是否命中路径前缀列表。 + * + * @param {string} path 待匹配路径 + * @param {string[]} pathPatterns 路径前缀集合 + * @returns {boolean} 是否匹配成功 + */ +function matchPathPrefix(path = "", pathPatterns = []) { + return pathPatterns.some( + (pattern) => path === pattern || path.startsWith(`${pattern}/`), + ); +} + +/** + * 从绝对地址中提取 pathname,或直接返回相对地址的路径部分。 + * + * @param {string} url 请求地址 + * @returns {string} 标准化后的路径 + */ +function parseAbsoluteUrlPath(url = "") { + const normalizedUrl = String(url || ""); + if (!normalizedUrl) { + return "/"; + } + if ( + !normalizedUrl.startsWith("http://") && + !normalizedUrl.startsWith("https://") + ) { + return normalizedUrl.split("?")[0] || "/"; + } + const pathMatch = normalizedUrl.match(/^https?:\/\/[^/]+(\/[^?#]*)?/i); + return pathMatch?.[1] || "/"; +} + +/** + * 解析基础 API 地址对应的路径前缀。 + * + * @param {string} baseUrl 基础 API 地址 + * @returns {string} 基础路径前缀 + */ +function getBaseApiPath(baseUrl = TRANSPORT_BASE_URL) { + if (!baseUrl) { + return ""; + } + const baseApiPath = parseAbsoluteUrlPath(baseUrl); + return baseApiPath === "/" ? "" : baseApiPath; +} + +/** + * 计算后端用于 AAD 与策略匹配的标准请求路径。 + * + * @param {string} url 请求地址 + * @param {string} baseUrl 基础 API 地址 + * @returns {string} 标准化请求路径 + */ +function getRequestPath(url = "", baseUrl = TRANSPORT_BASE_URL) { + const baseApiPath = getBaseApiPath(baseUrl); + const pathname = parseAbsoluteUrlPath(url); + if (baseApiPath && pathname.startsWith(baseApiPath)) { + const normalizedPath = pathname.slice(baseApiPath.length); + return normalizedPath || "/"; + } + return pathname || "/"; +} + +/** + * 标准化后端下发的路径列表配置。 + * + * @param {Array} paths 原始路径集合 + * @returns {string[]} 标准化后的路径数组 + */ +function normalizePaths(paths) { + if (!Array.isArray(paths)) { + return []; + } + return paths.map((path) => String(path || "").trim()).filter(Boolean); +} + +/** + * 从请求头对象中读取指定字段。 + * + * @param {Object} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined; + } + return headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()]; +} + +/** + * 将后端配置响应转换为前端统一使用的策略对象。 + * + * @param {Object} payload 后端返回的配置数据 + * @returns {Object} 标准化后的传输加密策略 + */ +function normalizeTransportPolicy(payload) { + return { + transportCryptoEnabled: Boolean(payload?.transportCryptoEnabled), + transportCryptoMode: String(payload?.transportCryptoMode || "off"), + transportCryptoActive: Boolean(payload?.transportCryptoActive), + envelopeVersion: String( + payload?.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + ), + publicKeyUrl: String(payload?.publicKeyUrl || "/transport/crypto/public-key"), + requestEnvelopeAlgorithm: String( + payload?.requestEnvelopeAlgorithm || DEFAULT_REQUEST_ENVELOPE_ALGORITHM, + ), + responseEnvelopeAlgorithm: String( + payload?.responseEnvelopeAlgorithm || DEFAULT_RESPONSE_ENVELOPE_ALGORITHM, + ), + enabledPaths: normalizePaths(payload?.enabledPaths), + requiredPaths: normalizePaths(payload?.requiredPaths), + excludePaths: normalizePaths(payload?.excludePaths), + maxEncryptedGetUrlLength: Number( + payload?.maxEncryptedGetUrlLength || DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH, + ), + configExpireAt: Number(payload?.configExpireAt || 0), + retryAt: Number(payload?.retryAt || payload?.configExpireAt || 0), + }; +} + +/** + * 构建不可用场景下的本地兜底策略。 + * + * @returns {Object} 明文回退策略 + */ +function buildFallbackTransportPolicy() { + const nowTimestamp = getNowTimestamp(); + return { + transportCryptoEnabled: false, + transportCryptoMode: "off", + transportCryptoActive: false, + envelopeVersion: DEFAULT_TRANSPORT_ENVELOPE_VERSION, + publicKeyUrl: "/transport/crypto/public-key", + requestEnvelopeAlgorithm: DEFAULT_REQUEST_ENVELOPE_ALGORITHM, + responseEnvelopeAlgorithm: DEFAULT_RESPONSE_ENVELOPE_ALGORITHM, + enabledPaths: [], + requiredPaths: [], + excludePaths: [...EXCLUDED_URL_PATTERNS], + maxEncryptedGetUrlLength: DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH, + configExpireAt: + nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + retryAt: nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + }; +} + +/** + * 基于旧策略生成短期可重试的缓存策略。 + * + * @param {Object} policy 旧的策略对象 + * @returns {Object} 可重试策略 + */ +function buildRetryableTransportPolicy(policy) { + const normalizedPolicy = normalizeTransportPolicy(policy); + return { + ...normalizedPolicy, + retryAt: getNowTimestamp() + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + }; +} + +/** + * 判断当前策略是否仍在可用期内。 + * + * @param {Object} policy 待校验策略 + * @returns {boolean} 是否可继续使用 + */ +function isUsableTransportPolicy(policy) { + if (!policy || !policy.publicKeyUrl || !policy.retryAt) { + return false; + } + return policy.retryAt > getNowTimestamp(); +} + +/** + * 从本地缓存加载最近一次持久化的策略。 + * + * @returns {Object|null} 缓存策略 + */ +function loadPersistedTransportPolicy() { + const persistedTransportPolicy = uni.getStorageSync( + TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, + ); + if (!persistedTransportPolicy) { + return null; + } + return normalizeTransportPolicy(persistedTransportPolicy); +} + +/** + * 请求后端传输加密前端配置。 + * 这里直接使用原始 uni.request,避免策略初始化阶段反向依赖统一 request + * 包装器,导致“是否加密尚未判定时又要先走加密请求”的循环依赖。 + * + * @returns {Promise} Uni 请求响应结果 + */ +function requestFrontendConfig() { + return new Promise((resolve, reject) => { + uni.request({ + url: `${TRANSPORT_BASE_URL}${TRANSPORT_FRONTEND_CONFIG_URL}`, + method: "GET", + timeout: 10000, + success: resolve, + fail: reject, + }); + }); +} + +/** + * 获取请求加密使用的标准路径。 + * + * @param {string} url 请求地址 + * @param {string} baseUrl 基础 API 地址 + * @returns {string} 标准请求路径 + */ +export function getTransportRequestPath( + url = "", + baseUrl = TRANSPORT_BASE_URL, +) { + return getRequestPath(url, baseUrl); +} + +/** + * 获取当前生效的传输加密策略。 + * + * @returns {Object} 当前策略对象 + */ +export function getTransportCryptoPolicy() { + return cachedTransportPolicy || buildFallbackTransportPolicy(); +} + +/** + * 清空当前策略缓存与持久化数据。 + * + * @returns {void} + */ +export function invalidateTransportCryptoPolicy() { + cachedTransportPolicy = null; + inflightTransportPolicyPromise = null; + uni.removeStorageSync(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY); +} + +/** + * 确保本地已加载一份可用的传输加密策略。 + * + * @param {boolean} forceRefresh 是否强制从后端刷新 + * @returns {Promise} 当前可用策略 + */ +export async function ensureTransportCryptoPolicyLoaded(forceRefresh = false) { + if (!forceRefresh && !cachedTransportPolicy) { + const persistedTransportPolicy = loadPersistedTransportPolicy(); + if (isUsableTransportPolicy(persistedTransportPolicy)) { + cachedTransportPolicy = persistedTransportPolicy; + } + } + + if (!forceRefresh && isUsableTransportPolicy(cachedTransportPolicy)) { + return cachedTransportPolicy; + } + + if (inflightTransportPolicyPromise) { + return inflightTransportPolicyPromise; + } + + inflightTransportPolicyPromise = requestFrontendConfig() + .then((response) => { + const payload = normalizeTransportPolicy(response?.data?.data || {}); + cachedTransportPolicy = payload; + uni.setStorageSync(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, payload); + inflightTransportPolicyPromise = null; + return cachedTransportPolicy; + }) + .catch((error) => { + const staleTransportPolicy = + cachedTransportPolicy || loadPersistedTransportPolicy(); + inflightTransportPolicyPromise = null; + cachedTransportPolicy = staleTransportPolicy + ? buildRetryableTransportPolicy(staleTransportPolicy) + : buildFallbackTransportPolicy(); + uni.setStorageSync( + TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, + cachedTransportPolicy, + ); + if (staleTransportPolicy) { + console.warn( + "加载传输加密前端配置失败,当前继续沿用最近一次后端策略", + error, + ); + } else { + console.warn("加载传输加密前端配置失败,当前回退为明文请求策略", error); + } + return cachedTransportPolicy; + }); + + return inflightTransportPolicyPromise; +} + +/** + * 判断当前请求是否需要执行请求体加密。 + * + * @param {Object} requestConfig 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用请求加密 + */ +export function shouldEncryptRequest( + requestConfig, + transportPolicy = getTransportCryptoPolicy(), +) { + if (!transportPolicy.transportCryptoActive) { + return false; + } + const requestPath = getRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ); + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false; + } + if ( + (transportPolicy.enabledPaths || []).length && + !matchPathPrefix(requestPath, transportPolicy.enabledPaths || []) + ) { + return false; + } + if ((requestConfig.headers || {}).encrypt === false) { + return false; + } + if (matchExcludedUrl(requestConfig.url)) { + return false; + } + const contentType = + getHeaderValue(requestConfig.header, "Content-Type") || + getHeaderValue(requestConfig.headers, "Content-Type") || + ""; + if (String(contentType).includes("multipart/form-data")) { + return false; + } + return true; +} + +/** + * 判断当前响应是否需要执行自动解密。 + * + * @param {Object} requestConfig 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用响应解密 + */ +export function shouldEncryptResponse( + requestConfig, + transportPolicy = getTransportCryptoPolicy(), +) { + const requestPath = getRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ); + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false; + } + if ( + (transportPolicy.enabledPaths || []).length && + !matchPathPrefix(requestPath, transportPolicy.enabledPaths || []) + ) { + return false; + } + if ((requestConfig.headers || {}).encryptResponse === false) { + return false; + } + if (matchExcludedUrl(requestConfig.url)) { + return false; + } + if (requestConfig.__transportCryptoEnabledForRequest === true) { + return true; + } + if (requestConfig.__transportCryptoEnabledForRequest === false) { + return false; + } + return transportPolicy.transportCryptoActive; +} + +/** + * 判断查询参数是否需要封装为加密信封。 + * + * @param {Object} requestConfig 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用查询参数加密 + */ +export function shouldEncryptQuery( + requestConfig, + transportPolicy = getTransportCryptoPolicy(), +) { + if ((requestConfig.headers || {}).encryptQuery === false) { + return false; + } + return shouldEncryptRequest(requestConfig, transportPolicy); +} diff --git a/shierfeng-fastapi-app/src/utils/transportForge.js b/shierfeng-fastapi-app/src/utils/transportForge.js new file mode 100644 index 0000000..cac307f --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/transportForge.js @@ -0,0 +1,214 @@ +import forge from "node-forge/lib/forge"; +import "node-forge/lib/util"; +import "node-forge/lib/asn1"; +import "node-forge/lib/oids"; +import "node-forge/lib/cipher"; +import "node-forge/lib/cipherModes"; +import "node-forge/lib/aes"; +import "node-forge/lib/jsbn"; +import "node-forge/lib/pkcs1"; +import "node-forge/lib/prime"; +import "node-forge/lib/random"; +import "node-forge/lib/md"; +import "node-forge/lib/sha256"; +import "node-forge/lib/mgf1"; +import "node-forge/lib/pem"; +import "node-forge/lib/pki"; + +let randomBytePool = ""; +const FORGE_RANDOM_REFILL_BYTES = 4096; + +/** + * 将 Uint8Array 转为 Forge 兼容的二进制字符串。 + * + * @param {Uint8Array} uint8Array 字节数组 + * @returns {string} 二进制字符串 + */ +function uint8ArrayToBytes(uint8Array) { + return Array.from(uint8Array, (item) => String.fromCharCode(item)).join(""); +} + +/** + * 获取当前运行时的全局对象引用。 + * + * @returns {Object|undefined} 全局对象 + */ +function getRuntimeGlobal() { + if (typeof globalThis !== "undefined") { + return globalThis; + } + if (typeof self !== "undefined") { + return self; + } + if (typeof window !== "undefined") { + return window; + } + if (typeof global !== "undefined") { + return global; + } + return undefined; +} + +/** + * 通过 Web Crypto 同步获取随机字节。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机字节串,当前运行环境不支持时返回 null + */ +function getWebCryptoRandomBytes(length) { + const runtimeCrypto = getRuntimeGlobal()?.crypto; + if (!runtimeCrypto?.getRandomValues) { + return null; + } + const bytes = new Uint8Array(length); + runtimeCrypto.getRandomValues(bytes); + return uint8ArrayToBytes(bytes); +} + +/** + * 在 app-plus iOS 端通过原生 NSUUID 获取随机字节兜底。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机字节串,当前运行环境不支持时返回 null + */ +function getAppPlusIosRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "ios" || typeof plus.ios?.importClass !== "function") { + return null; + } + + try { + const uuidClass = plus.ios.importClass("NSUUID"); + let randomBytes = ""; + while (randomBytes.length < length) { + const uuidObject = plus.ios.invoke(uuidClass, "UUID"); + const uuidText = String(plus.ios.invoke(uuidObject, "UUIDString") || "") + .replace(/-/g, "") + .toLowerCase(); + plus.ios.deleteObject(uuidObject); + if (!uuidText) { + return null; + } + for ( + let index = 0; + index < uuidText.length && randomBytes.length < length; + index += 2 + ) { + const byteValue = parseInt(uuidText.slice(index, index + 2), 16); + if (isNaN(byteValue)) { + return null; + } + randomBytes += String.fromCharCode(byteValue); + } + } + return randomBytes; + } catch (error) { + return null; + } + // #endif + return null; +} + +/** + * 在 app-plus Android 端通过原生 SecureRandom 获取随机字节兜底。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机字节串,当前运行环境不支持时返回 null + */ +function getAppPlusAndroidRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "android" || typeof plus.android?.importClass !== "function") { + return null; + } + + try { + plus.android.importClass("java.security.SecureRandom"); + const secureRandom = + typeof plus.android.newObject === "function" + ? plus.android.newObject("java.security.SecureRandom") + : null; + if (!secureRandom) { + return null; + } + plus.android.importClass(secureRandom); + let randomBytes = ""; + for (let index = 0; index < length; index += 1) { + randomBytes += String.fromCharCode(secureRandom.nextInt(256)); + } + if (typeof plus.android.autoCollection === "function") { + plus.android.autoCollection(secureRandom); + } + return randomBytes; + } catch (error) { + return null; + } + // #endif + return null; +} + +/** + * 在随机池不足时尝试同步补充一段新的随机字节。 + * + * @param {number} length 当前至少需要的字节长度 + * @returns {void} + */ +function refillRandomBytePool(length) { + const refillLength = Math.max(length, FORGE_RANDOM_REFILL_BYTES); + const randomBytes = + getWebCryptoRandomBytes(refillLength) || + getAppPlusAndroidRandomBytes(refillLength) || + getAppPlusIosRandomBytes(refillLength); + if (randomBytes) { + randomBytePool += randomBytes; + } +} + +/** + * 从随机数池中按需提取字节串。 + * + * @param {number} length 需要提取的字节长度 + * @returns {string} Forge 使用的二进制字符串 + */ +function consumeRandomBytes(length) { + if (randomBytePool.length < length) { + refillRandomBytePool(length); + } + if (randomBytePool.length < length) { + throw new Error("传输加密随机数池不足,且当前运行环境无法同步补充安全随机数"); + } + const bytes = randomBytePool.slice(0, length); + randomBytePool = randomBytePool.slice(length); + return bytes; +} + +forge.random.getBytesSync = function getBytesSync(length) { + return consumeRandomBytes(length); +}; + +forge.random.getBytes = function getBytes(length, callback) { + const bytes = consumeRandomBytes(length); + if (typeof callback === "function") { + callback(null, bytes); + } + return bytes; +}; + +/** + * 向 Forge 随机数池预填充平台侧生成的随机字节。 + * + * @param {string} bytes 待注入的二进制字符串 + * @returns {void} + */ +export function primeForgeRandomBytes(bytes) { + randomBytePool += String(bytes || ""); +} + +export default forge; diff --git a/shierfeng-fastapi-app/src/utils/upload.js b/shierfeng-fastapi-app/src/utils/upload.js new file mode 100644 index 0000000..632983b --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/upload.js @@ -0,0 +1,72 @@ +import { useUserStore } from "@/store"; +import config from "@/config"; +import { getToken } from "@/utils/auth"; +import errorCode from "@/utils/errorCode"; +import { toast, showConfirm, tansParams } from "@/utils/common"; + +let timeout = 10000; +const baseUrl = config.baseUrl; + +export default function upload(config) { + // 是否需要设置 token + const isToken = (config.headers || {}).isToken === false; + config.header = config.header || {}; + if (getToken() && !isToken) { + config.header["Authorization"] = "Bearer " + getToken(); + } + // get请求映射params参数 + if (config.params) { + let url = config.url + "?" + tansParams(config.params); + url = url.slice(0, -1); + config.url = url; + } + return new Promise((resolve, reject) => { + uni.uploadFile({ + timeout: config.timeout || timeout, + url: baseUrl + config.url, + filePath: config.filePath, + name: config.name || "file", + header: config.header, + formData: config.formData, + success: (res) => { + let result = JSON.parse(res.data); + const code = result.code || 200; + const msg = errorCode[code] || result.msg || errorCode["default"]; + if (code === 200) { + resolve(result); + } else if (code == 401) { + showConfirm( + "登录状态已过期,您可以继续留在该页面,或者重新登录?", + ).then((res) => { + if (res.confirm) { + useUserStore() + .logOut() + .then((res) => { + uni.reLaunch({ url: "/pages/login/login" }); + }); + } + }); + reject("无效的会话,或者会话已过期,请重新登录。"); + } else if (code === 500) { + toast(msg); + reject("500"); + } else if (code !== 200) { + toast(msg); + reject(code); + } + }, + fail: (error) => { + let { message } = error; + if (message == "Network Error") { + message = "后端接口连接异常"; + } else if (message.includes("timeout")) { + message = "系统接口请求超时"; + } else if (message.includes("Request failed with status code")) { + message = "系统接口" + message.substr(message.length - 3) + "异常"; + } + toast(message); + reject(error); + }, + }); + }); +} diff --git a/shierfeng-fastapi-app/src/utils/validate.js b/shierfeng-fastapi-app/src/utils/validate.js new file mode 100644 index 0000000..76b2b8d --- /dev/null +++ b/shierfeng-fastapi-app/src/utils/validate.js @@ -0,0 +1,124 @@ +/** + * 路径匹配器 + * @param {string} pattern + * @param {string} path + * @returns {Boolean} + */ +export function isPathMatch(pattern, path) { + const regexPattern = pattern + .replace(/\//g, "\\/") + .replace(/\*\*/g, ".*") + .replace(/\*/g, "[^\\/]*"); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(path); +} + +/** + * 判断value字符串是否为空 + * @param {string} value + * @returns {Boolean} + */ +export function isEmpty(value) { + if ( + value == null || + value == "" || + value == undefined || + value == "undefined" + ) { + return true; + } + return false; +} + +/** + * 判断url是否是http或https + * @param {string} url + * @returns {Boolean} + */ +export function isHttp(url) { + return url.indexOf("http://") !== -1 || url.indexOf("https://") !== -1; +} + +/** + * 判断path是否为外链 + * @param {string} path + * @returns {Boolean} + */ +export function isExternal(path) { + return /^(https?:|mailto:|tel:)/.test(path); +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validUsername(str) { + const valid_map = ["admin", "editor"]; + return valid_map.indexOf(str.trim()) >= 0; +} + +/** + * @param {string} url + * @returns {Boolean} + */ +export function validURL(url) { + const reg = + /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/; + return reg.test(url); +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validLowerCase(str) { + const reg = /^[a-z]+$/; + return reg.test(str); +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validUpperCase(str) { + const reg = /^[A-Z]+$/; + return reg.test(str); +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validAlphabets(str) { + const reg = /^[A-Za-z]+$/; + return reg.test(str); +} + +/** + * @param {string} email + * @returns {Boolean} + */ +export function validEmail(email) { + const reg = + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return reg.test(email); +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function isString(str) { + return typeof str === "string" || str instanceof String; +} + +/** + * @param {Array} arg + * @returns {Boolean} + */ +export function isArray(arg) { + if (typeof Array.isArray === "undefined") { + return Object.prototype.toString.call(arg) === "[object Array]"; + } + return Array.isArray(arg); +} diff --git a/shierfeng-fastapi-app/stylelint.config.mjs b/shierfeng-fastapi-app/stylelint.config.mjs new file mode 100644 index 0000000..3690858 --- /dev/null +++ b/shierfeng-fastapi-app/stylelint.config.mjs @@ -0,0 +1,3 @@ +import { icebreaker } from "@icebreakers/stylelint-config"; + +export default icebreaker(); diff --git a/shierfeng-fastapi-app/tailwind.config.ts b/shierfeng-fastapi-app/tailwind.config.ts new file mode 100644 index 0000000..d857f80 --- /dev/null +++ b/shierfeng-fastapi-app/tailwind.config.ts @@ -0,0 +1,53 @@ +import type { Config } from "tailwindcss"; +import { getIconCollections, iconsPlugin } from "@egoist/tailwindcss-icons"; +import cssMacro from "weapp-tailwindcss/css-macro"; +import { isMp } from "./platform"; + +export default { + content: ["./index.html", "./src/**/*.{html,js,ts,jsx,tsx,vue}"], + theme: { + extend: { + // colors: { + // // 你可以在这里进行颜色的扩展 + // primary: { + // 'DEFAULT': 'var(--color-primary, #0089FF)', + // 'light-3': 'var(--color-primary-light-3, rgb(85, 199, 255))', + // 'light-5': 'var(--color-primary-light-5, rgb(130, 217, 255))', + // 'light-7': 'var(--color-primary-light-7, rgb(175, 235, 255))', + // 'light-9': 'var(--color-primary-light-9, rgb(219, 252, 255))', + // 'dark-2': 'var(--color-primary-dark-2, rgb(0, 135, 204))', + // }, + // }, + }, + }, + // https://tw.icebreaker.top/docs/quick-start/uni-app-css-macro + plugins: [ + cssMacro({ + variantsMap: { + wx: "MP-WEIXIN", + "-wx": { + value: "MP-WEIXIN", + negative: true, + }, + // 定义多个条件判断 + // mv: { + // value: 'H5 || MP-WEIXIN' + // }, + // '-mv': { + // value: 'H5 || MP-WEIXIN', + // negative: true + // } + }, + }), + iconsPlugin({ + // 在这里可以选择你要使用的 icon, 更多详见: + // https://icon-sets.iconify.design/ + collections: getIconCollections(["svg-spinners", "mdi"]), + }), + ], + corePlugins: { + // 小程序去使用 h5 的 preflight 和响应式 container 没有意义 + preflight: !isMp, + container: !isMp, + }, +}; diff --git a/shierfeng-fastapi-app/tsconfig.json b/shierfeng-fastapi-app/tsconfig.json new file mode 100644 index 0000000..bf039d7 --- /dev/null +++ b/shierfeng-fastapi-app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "esnext", + "outDir": "true", + "jsx": "preserve", + "lib": ["esnext", "dom"], + "useDefineForClassFields": true, + "baseUrl": ".", + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": ["src/*"] + }, + "resolveJsonModule": true, + "types": ["@dcloudio/types"], + "allowJs": true, + "strict": true, + "sourceMap": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/shierfeng-fastapi-app/vite.config.ts b/shierfeng-fastapi-app/vite.config.ts new file mode 100644 index 0000000..8888b9a --- /dev/null +++ b/shierfeng-fastapi-app/vite.config.ts @@ -0,0 +1,70 @@ +import uni from "@dcloudio/vite-plugin-uni"; +import { defineConfig } from "vite"; +import { UnifiedViteWeappTailwindcssPlugin } from "weapp-tailwindcss/vite"; +import { WeappTailwindcssDisabled } from "./platform"; +import postcssPlugins from "./postcss.config"; + +function patchNodeForgeMiniProgramRuntime() { + return { + name: "patch-node-forge-mini-program-runtime", + enforce: "pre", + transform(code: string, id: string) { + if (id.includes("node-forge/lib/util.js")) { + return { + code: code.replace( + /return typeof self === 'undefined' \? window : self;/, + "return typeof globalThis !== 'undefined' ? globalThis : (typeof self === 'undefined' ? (typeof window === 'undefined' ? {} : window) : self);", + ), + map: null, + }; + } + + if (id.includes("node-forge/lib/random.js")) { + return { + code: code.replace( + /var _crypto = globalScope\.crypto \|\| globalScope\.msCrypto;/, + "var _crypto = (globalScope && (globalScope.crypto || globalScope.msCrypto)) || null;", + ), + map: null, + }; + } + + return null; + }, + }; +} + +// https://vitejs.dev/config/ +export default defineConfig(async () => { + // 新版本的 unplugin-auto-import 改成了只有 esm 格式的产物,而 uni-app 目前必须 cjs 格式 + // 所以需要改成动态 import 的写法来进行引入 + // 详见 https://github.com/sonofmagic/uni-app-vite-vue3-tailwind-vscode-template/issues/29 + const { default: AutoImport } = await import("unplugin-auto-import/vite"); + return { + // uvtw 一定要放在 uni 后面 + plugins: [ + uni(), + patchNodeForgeMiniProgramRuntime(), + UnifiedViteWeappTailwindcssPlugin({ + rem2rpx: true, + disabled: WeappTailwindcssDisabled, + }), + AutoImport({ + imports: ["vue", "uni-app", "pinia"], + dts: "./src/auto-imports.d.ts", + }), + ], + // 内联 postcss 注册 tailwindcss + css: { + postcss: { + plugins: postcssPlugins, + }, + // https://vitejs.dev/config/shared-options.html#css-preprocessoroptions + preprocessorOptions: { + scss: { + silenceDeprecations: ["legacy-js-api"], + }, + }, + }, + }; +}); diff --git a/shierfeng-fastapi-backend/.env.dev b/shierfeng-fastapi-backend/.env.dev new file mode 100644 index 0000000..5548461 --- /dev/null +++ b/shierfeng-fastapi-backend/.env.dev @@ -0,0 +1,173 @@ +# -------- 应用配置 -------- +# 应用运行环境 +APP_ENV = 'dev' +# 应用名称 +APP_NAME = 'ShiErFeng-FastAPI' +# 应用代理路径 +APP_ROOT_PATH = '/dev-api' +# 应用主机 +APP_HOST = '0.0.0.0' +# 应用端口 +APP_PORT = 9099 +# 应用版本 +APP_VERSION= '1.9.0' +# 应用是否开启热重载 +APP_RELOAD = true +# 应用工作进程数 +APP_WORKERS = 1 +# 应用是否开启IP归属区域查询 +APP_IP_LOCATION_QUERY = true +# 应用是否允许账号同时登录 +APP_SAME_TIME_LOGIN = true +# 应用是否为演示模式 +APP_DEMO_MODE = false +# 应用是否禁用Swagger文档 +APP_DISABLE_SWAGGER = false +# 应用是否禁用ReDoc文档 +APP_DISABLE_REDOC = false +# 可信代理IP列表,多个值使用逗号分隔 +APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' +# 可信代理跳数,单层Nginx代理通常为1 +APP_TRUSTED_PROXY_HOPS = 1 + +# -------- Jwt配置 -------- +# Jwt秘钥 +JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' +# Jwt算法 +JWT_ALGORITHM = 'HS256' +# 令牌过期时间 +JWT_EXPIRE_MINUTES = 1440 +# redis中令牌过期时间 +JWT_REDIS_EXPIRE_MINUTES = 30 + + +# -------- 数据库配置 -------- +# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' +DB_TYPE = 'mysql' +# 数据库主机 +DB_HOST = '127.0.0.1' +# 数据库端口 +DB_PORT = 3306 +# 数据库用户名 +DB_USERNAME = 'root' +# 数据库密码 +DB_PASSWORD = 'mysqlroot' +# 数据库名称 +DB_DATABASE = 'shierfeng-fastapi' +# 是否开启sqlalchemy日志 +DB_ECHO = true +# 允许溢出连接池大小的最大连接数 +DB_MAX_OVERFLOW = 10 +# 连接池大小,0表示连接数无限制 +DB_POOL_SIZE = 50 +# 连接回收时间(单位:秒) +DB_POOL_RECYCLE = 3600 +# 连接池中没有线程可用时,最多等待的时间(单位:秒) +DB_POOL_TIMEOUT = 30 + +# -------- Redis配置 -------- +# Redis主机 +REDIS_HOST = '127.0.0.1' +# Redis端口 +REDIS_PORT = 6379 +# Redis用户名 +REDIS_USERNAME = '' +# Redis密码 +REDIS_PASSWORD = '' +# Redis数据库 +REDIS_DATABASE = 2 + +# -------- 日志配置 -------- +# 是否启用日志脱敏 +LOG_MASK_ENABLED = false +# 日志脱敏占位符 +LOG_MASK_PLACEHOLDER = '******' +# 以下三项为互补关系(非互斥): +# 1) LOG_MASK_FIELDS:命中后执行全量脱敏 +# 2) LOG_PARTIAL_MASK_FIELDS:命中后执行部分脱敏 +# 3) LOG_CONFIG_SECRET_PATTERNS:按 configKey 关键词决定是否脱敏 configValue +# 全量脱敏字段,多个值使用逗号分隔 +LOG_MASK_FIELDS = 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,sms_code,captcha_code,system_prompt' +# 部分脱敏字段,多个值使用逗号分隔 +# 当前默认不对IP脱敏;如后期需要开启,可直接追加 ip,ipaddr,oper_ip,login_ip 并重启服务 +LOG_PARTIAL_MASK_FIELDS = 'phonenumber,phone,mobile,email' +# 按配置键名识别敏感配置的关键词,多个值使用逗号分隔 +LOG_CONFIG_SECRET_PATTERNS = 'password,token,secret,key,private,credential,access,jwt,captcha,sms' +# Redis Stream Key +LOG_STREAM_KEY = 'log:stream' +# Redis Stream 消费组名称 +LOG_STREAM_GROUP = 'log_aggregator' +# Redis Stream 消费者名称前缀 +LOG_STREAM_CONSUMER_PREFIX = 'worker' +# 每次读取的最大消息数量 +LOG_STREAM_BATCH_SIZE = 100 +# 阻塞读取等待时间(毫秒) +LOG_STREAM_BLOCK_MS = 2000 +# Stream 最大长度(近似裁剪) +LOG_STREAM_MAXLEN = 100000 +# Pending 回收最小空闲时间(毫秒) +LOG_STREAM_CLAIM_IDLE_MS = 60000 +# Pending 回收检查间隔(毫秒) +LOG_STREAM_CLAIM_INTERVAL_MS = 5000 +# 每次回收的最大消息数量 +LOG_STREAM_CLAIM_BATCH_SIZE = 100 +# 去重 Key 过期时间(秒) +LOG_STREAM_DEDUP_TTL = 3600 +# 去重 Key 前缀 +LOG_STREAM_DEDUP_PREFIX = 'log:dedup' +# stdout 输出是否为 JSON +LOGURU_JSON = false +# Loguru 最低输出级别 +LOGURU_LEVEL = 'INFO' +# 是否输出到 stdout +LOGURU_STDOUT = true +# 是否启用文件日志 +LOG_FILE_ENABLED = true +# 文件日志根目录 +LOG_FILE_BASE_DIR = 'logs' +# 文件滚动策略 +LOGURU_ROTATION = '50MB' +# 文件保留策略 +LOGURU_RETENTION = '30 days' +# 文件压缩格式 +LOGURU_COMPRESSION = 'zip' +# 实例标识(用于区分实例) +LOG_INSTANCE_ID = 'dev' +# 服务名称(用于统一标识服务) +LOG_SERVICE_NAME = 'shierfeng-fastapi-backend' +# Worker 标识(auto 自动生成) +LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = false +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'off' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;团队使用时可按需替换 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式 +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/shierfeng-fastapi-backend/.env.dockermy b/shierfeng-fastapi-backend/.env.dockermy new file mode 100644 index 0000000..9e19cb7 --- /dev/null +++ b/shierfeng-fastapi-backend/.env.dockermy @@ -0,0 +1,173 @@ +# -------- 应用配置 -------- +# 应用运行环境 +APP_ENV = 'prod' +# 应用名称 +APP_NAME = 'ShiErFeng-FastAPI' +# 应用代理路径 +APP_ROOT_PATH = '/docker-api' +# 应用主机 +APP_HOST = '0.0.0.0' +# 应用端口 +APP_PORT = 9099 +# 应用版本 +APP_VERSION= '1.9.0' +# 应用是否开启热重载 +APP_RELOAD = false +# 应用工作进程数 +APP_WORKERS = 1 +# 应用是否开启IP归属区域查询 +APP_IP_LOCATION_QUERY = true +# 应用是否允许账号同时登录 +APP_SAME_TIME_LOGIN = true +# 应用是否为演示模式 +APP_DEMO_MODE = false +# 应用是否禁用Swagger文档 +APP_DISABLE_SWAGGER = true +# 应用是否禁用ReDoc文档 +APP_DISABLE_REDOC = true +# 可信代理IP列表,多个值使用逗号分隔 +APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' +# 可信代理跳数,单层Nginx代理通常为1 +APP_TRUSTED_PROXY_HOPS = 1 + +# -------- Jwt配置 -------- +# Jwt秘钥 +JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' +# Jwt算法 +JWT_ALGORITHM = 'HS256' +# 令牌过期时间 +JWT_EXPIRE_MINUTES = 1440 +# redis中令牌过期时间 +JWT_REDIS_EXPIRE_MINUTES = 30 + + +# -------- 数据库配置 -------- +# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' +DB_TYPE = 'mysql' +# 数据库主机 +DB_HOST = 'shierfeng-mysql' +# 数据库端口 +DB_PORT = 3306 +# 数据库用户名 +DB_USERNAME = 'root' +# 数据库密码 +DB_PASSWORD = 'root' +# 数据库名称 +DB_DATABASE = 'shierfeng-fastapi' +# 是否开启sqlalchemy日志 +DB_ECHO = true +# 允许溢出连接池大小的最大连接数 +DB_MAX_OVERFLOW = 10 +# 连接池大小,0表示连接数无限制 +DB_POOL_SIZE = 50 +# 连接回收时间(单位:秒) +DB_POOL_RECYCLE = 3600 +# 连接池中没有线程可用时,最多等待的时间(单位:秒) +DB_POOL_TIMEOUT = 30 + +# -------- Redis配置 -------- +# Redis主机 +REDIS_HOST = 'shierfeng-redis' +# Redis端口 +REDIS_PORT = 6379 +# Redis用户名 +REDIS_USERNAME = '' +# Redis密码 +REDIS_PASSWORD = '' +# Redis数据库 +REDIS_DATABASE = 2 + +# -------- 日志配置 -------- +# 是否启用日志脱敏 +LOG_MASK_ENABLED = true +# 日志脱敏占位符 +LOG_MASK_PLACEHOLDER = '******' +# 以下三项为互补关系(非互斥): +# 1) LOG_MASK_FIELDS:命中后执行全量脱敏 +# 2) LOG_PARTIAL_MASK_FIELDS:命中后执行部分脱敏 +# 3) LOG_CONFIG_SECRET_PATTERNS:按 configKey 关键词决定是否脱敏 configValue +# 全量脱敏字段,多个值使用逗号分隔 +LOG_MASK_FIELDS = 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,sms_code,captcha_code,system_prompt' +# 部分脱敏字段,多个值使用逗号分隔 +# 当前默认不对IP脱敏;如后期需要开启,可直接追加 ip,ipaddr,oper_ip,login_ip 并重启服务 +LOG_PARTIAL_MASK_FIELDS = 'phonenumber,phone,mobile,email' +# 按配置键名识别敏感配置的关键词,多个值使用逗号分隔 +LOG_CONFIG_SECRET_PATTERNS = 'password,token,secret,key,private,credential,access,jwt,captcha,sms' +# Redis Stream Key +LOG_STREAM_KEY = 'log:stream' +# Redis Stream 消费组名称 +LOG_STREAM_GROUP = 'log_aggregator' +# Redis Stream 消费者名称前缀 +LOG_STREAM_CONSUMER_PREFIX = 'worker' +# 每次读取的最大消息数量 +LOG_STREAM_BATCH_SIZE = 100 +# 阻塞读取等待时间(毫秒) +LOG_STREAM_BLOCK_MS = 2000 +# Stream 最大长度(近似裁剪) +LOG_STREAM_MAXLEN = 100000 +# Pending 回收最小空闲时间(毫秒) +LOG_STREAM_CLAIM_IDLE_MS = 60000 +# Pending 回收检查间隔(毫秒) +LOG_STREAM_CLAIM_INTERVAL_MS = 5000 +# 每次回收的最大消息数量 +LOG_STREAM_CLAIM_BATCH_SIZE = 100 +# 去重 Key 过期时间(秒) +LOG_STREAM_DEDUP_TTL = 3600 +# 去重 Key 前缀 +LOG_STREAM_DEDUP_PREFIX = 'log:dedup' +# stdout 输出是否为 JSON +LOGURU_JSON = false +# Loguru 最低输出级别 +LOGURU_LEVEL = 'INFO' +# 是否输出到 stdout +LOGURU_STDOUT = true +# 是否启用文件日志 +LOG_FILE_ENABLED = true +# 文件日志根目录 +LOG_FILE_BASE_DIR = 'logs' +# 文件滚动策略 +LOGURU_ROTATION = '50MB' +# 文件保留策略 +LOGURU_RETENTION = '30 days' +# 文件压缩格式 +LOGURU_COMPRESSION = 'zip' +# 实例标识(用于区分实例) +LOG_INSTANCE_ID = 'dockermy' +# 服务名称(用于统一标识服务) +LOG_SERVICE_NAME = 'shierfeng-fastapi-backend' +# Worker 标识(auto 自动生成) +LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = true +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'optional' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/shierfeng-fastapi-backend/.env.dockerpg b/shierfeng-fastapi-backend/.env.dockerpg new file mode 100644 index 0000000..25b825e --- /dev/null +++ b/shierfeng-fastapi-backend/.env.dockerpg @@ -0,0 +1,173 @@ +# -------- 应用配置 -------- +# 应用运行环境 +APP_ENV = 'prod' +# 应用名称 +APP_NAME = 'ShiErFeng-FastAPI' +# 应用代理路径 +APP_ROOT_PATH = '/docker-api' +# 应用主机 +APP_HOST = '0.0.0.0' +# 应用端口 +APP_PORT = 9099 +# 应用版本 +APP_VERSION= '1.9.0' +# 应用是否开启热重载 +APP_RELOAD = false +# 应用工作进程数 +APP_WORKERS = 1 +# 应用是否开启IP归属区域查询 +APP_IP_LOCATION_QUERY = true +# 应用是否为演示模式 +APP_DEMO_MODE = false +# 应用是否允许账号同时登录 +APP_SAME_TIME_LOGIN = true +# 应用是否禁用Swagger文档 +APP_DISABLE_SWAGGER = true +# 应用是否禁用ReDoc文档 +APP_DISABLE_REDOC = true +# 可信代理IP列表,多个值使用逗号分隔 +APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' +# 可信代理跳数,单层Nginx代理通常为1 +APP_TRUSTED_PROXY_HOPS = 1 + +# -------- Jwt配置 -------- +# Jwt秘钥 +JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' +# Jwt算法 +JWT_ALGORITHM = 'HS256' +# 令牌过期时间 +JWT_EXPIRE_MINUTES = 1440 +# redis中令牌过期时间 +JWT_REDIS_EXPIRE_MINUTES = 30 + + +# -------- 数据库配置 -------- +# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' +DB_TYPE = 'postgresql' +# 数据库主机 +DB_HOST = 'shierfeng-pg' +# 数据库端口 +DB_PORT = 5432 +# 数据库用户名 +DB_USERNAME = 'postgres' +# 数据库密码 +DB_PASSWORD = 'root' +# 数据库名称 +DB_DATABASE = 'shierfeng-fastapi' +# 是否开启sqlalchemy日志 +DB_ECHO = true +# 允许溢出连接池大小的最大连接数 +DB_MAX_OVERFLOW = 10 +# 连接池大小,0表示连接数无限制 +DB_POOL_SIZE = 50 +# 连接回收时间(单位:秒) +DB_POOL_RECYCLE = 3600 +# 连接池中没有线程可用时,最多等待的时间(单位:秒) +DB_POOL_TIMEOUT = 30 + +# -------- Redis配置 -------- +# Redis主机 +REDIS_HOST = 'shierfeng-redis' +# Redis端口 +REDIS_PORT = 6379 +# Redis用户名 +REDIS_USERNAME = '' +# Redis密码 +REDIS_PASSWORD = '' +# Redis数据库 +REDIS_DATABASE = 2 + +# -------- 日志配置 -------- +# 是否启用日志脱敏 +LOG_MASK_ENABLED = true +# 日志脱敏占位符 +LOG_MASK_PLACEHOLDER = '******' +# 以下三项为互补关系(非互斥): +# 1) LOG_MASK_FIELDS:命中后执行全量脱敏 +# 2) LOG_PARTIAL_MASK_FIELDS:命中后执行部分脱敏 +# 3) LOG_CONFIG_SECRET_PATTERNS:按 configKey 关键词决定是否脱敏 configValue +# 全量脱敏字段,多个值使用逗号分隔 +LOG_MASK_FIELDS = 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,sms_code,captcha_code,system_prompt' +# 部分脱敏字段,多个值使用逗号分隔 +# 当前默认不对IP脱敏;如后期需要开启,可直接追加 ip,ipaddr,oper_ip,login_ip 并重启服务 +LOG_PARTIAL_MASK_FIELDS = 'phonenumber,phone,mobile,email' +# 按配置键名识别敏感配置的关键词,多个值使用逗号分隔 +LOG_CONFIG_SECRET_PATTERNS = 'password,token,secret,key,private,credential,access,jwt,captcha,sms' +# Redis Stream Key +LOG_STREAM_KEY = 'log:stream' +# Redis Stream 消费组名称 +LOG_STREAM_GROUP = 'log_aggregator' +# Redis Stream 消费者名称前缀 +LOG_STREAM_CONSUMER_PREFIX = 'worker' +# 每次读取的最大消息数量 +LOG_STREAM_BATCH_SIZE = 100 +# 阻塞读取等待时间(毫秒) +LOG_STREAM_BLOCK_MS = 2000 +# Stream 最大长度(近似裁剪) +LOG_STREAM_MAXLEN = 100000 +# Pending 回收最小空闲时间(毫秒) +LOG_STREAM_CLAIM_IDLE_MS = 60000 +# Pending 回收检查间隔(毫秒) +LOG_STREAM_CLAIM_INTERVAL_MS = 5000 +# 每次回收的最大消息数量 +LOG_STREAM_CLAIM_BATCH_SIZE = 100 +# 去重 Key 过期时间(秒) +LOG_STREAM_DEDUP_TTL = 3600 +# 去重 Key 前缀 +LOG_STREAM_DEDUP_PREFIX = 'log:dedup' +# stdout 输出是否为 JSON +LOGURU_JSON = false +# Loguru 最低输出级别 +LOGURU_LEVEL = 'INFO' +# 是否输出到 stdout +LOGURU_STDOUT = true +# 是否启用文件日志 +LOG_FILE_ENABLED = true +# 文件日志根目录 +LOG_FILE_BASE_DIR = 'logs' +# 文件滚动策略 +LOGURU_ROTATION = '50MB' +# 文件保留策略 +LOGURU_RETENTION = '30 days' +# 文件压缩格式 +LOGURU_COMPRESSION = 'zip' +# 实例标识(用于区分实例) +LOG_INSTANCE_ID = 'dockerpg' +# 服务名称(用于统一标识服务) +LOG_SERVICE_NAME = 'shierfeng-fastapi-backend' +# Worker 标识(auto 自动生成) +LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = true +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'optional' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/shierfeng-fastapi-backend/.env.prod b/shierfeng-fastapi-backend/.env.prod new file mode 100644 index 0000000..7c52633 --- /dev/null +++ b/shierfeng-fastapi-backend/.env.prod @@ -0,0 +1,173 @@ +# -------- 应用配置 -------- +# 应用运行环境 +APP_ENV = 'prod' +# 应用名称 +APP_NAME = 'ShiErFeng-FastAPI' +# 应用代理路径 +APP_ROOT_PATH = '/prod-api' +# 应用主机 +APP_HOST = '0.0.0.0' +# 应用端口 +APP_PORT = 9099 +# 应用版本 +APP_VERSION= '1.9.0' +# 应用是否开启热重载 +APP_RELOAD = false +# 应用工作进程数 +APP_WORKERS = 1 +# 应用是否开启IP归属区域查询 +APP_IP_LOCATION_QUERY = true +# 应用是否允许账号同时登录 +APP_SAME_TIME_LOGIN = true +# 应用是否为演示模式 +APP_DEMO_MODE = false +# 应用是否禁用Swagger文档 +APP_DISABLE_SWAGGER = true +# 应用是否禁用ReDoc文档 +APP_DISABLE_REDOC = true +# 可信代理IP列表,多个值使用逗号分隔 +APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' +# 可信代理跳数,单层Nginx代理通常为1 +APP_TRUSTED_PROXY_HOPS = 1 + +# -------- Jwt配置 -------- +# Jwt秘钥 +JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' +# Jwt算法 +JWT_ALGORITHM = 'HS256' +# 令牌过期时间 +JWT_EXPIRE_MINUTES = 1440 +# redis中令牌过期时间 +JWT_REDIS_EXPIRE_MINUTES = 30 + + +# -------- 数据库配置 -------- +# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' +DB_TYPE = 'mysql' +# 数据库主机 +DB_HOST = '127.0.0.1' +# 数据库端口 +DB_PORT = 3306 +# 数据库用户名 +DB_USERNAME = 'root' +# 数据库密码 +DB_PASSWORD = 'root' +# 数据库名称 +DB_DATABASE = 'shierfeng-fastapi' +# 是否开启sqlalchemy日志 +DB_ECHO = true +# 允许溢出连接池大小的最大连接数 +DB_MAX_OVERFLOW = 10 +# 连接池大小,0表示连接数无限制 +DB_POOL_SIZE = 50 +# 连接回收时间(单位:秒) +DB_POOL_RECYCLE = 3600 +# 连接池中没有线程可用时,最多等待的时间(单位:秒) +DB_POOL_TIMEOUT = 30 + +# -------- Redis配置 -------- +# Redis主机 +REDIS_HOST = '127.0.0.1' +# Redis端口 +REDIS_PORT = 6379 +# Redis用户名 +REDIS_USERNAME = '' +# Redis密码 +REDIS_PASSWORD = '' +# Redis数据库 +REDIS_DATABASE = 2 + +# -------- 日志配置 -------- +# 是否启用日志脱敏 +LOG_MASK_ENABLED = true +# 日志脱敏占位符 +LOG_MASK_PLACEHOLDER = '******' +# 以下三项为互补关系(非互斥): +# 1) LOG_MASK_FIELDS:命中后执行全量脱敏 +# 2) LOG_PARTIAL_MASK_FIELDS:命中后执行部分脱敏 +# 3) LOG_CONFIG_SECRET_PATTERNS:按 configKey 关键词决定是否脱敏 configValue +# 全量脱敏字段,多个值使用逗号分隔 +LOG_MASK_FIELDS = 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,sms_code,captcha_code,system_prompt' +# 部分脱敏字段,多个值使用逗号分隔 +# 当前默认不对IP脱敏;如后期需要开启,可直接追加 ip,ipaddr,oper_ip,login_ip 并重启服务 +LOG_PARTIAL_MASK_FIELDS = 'phonenumber,phone,mobile,email' +# 按配置键名识别敏感配置的关键词,多个值使用逗号分隔 +LOG_CONFIG_SECRET_PATTERNS = 'password,token,secret,key,private,credential,access,jwt,captcha,sms' +# Redis Stream Key +LOG_STREAM_KEY = 'log:stream' +# Redis Stream 消费组名称 +LOG_STREAM_GROUP = 'log_aggregator' +# Redis Stream 消费者名称前缀 +LOG_STREAM_CONSUMER_PREFIX = 'worker' +# 每次读取的最大消息数量 +LOG_STREAM_BATCH_SIZE = 100 +# 阻塞读取等待时间(毫秒) +LOG_STREAM_BLOCK_MS = 2000 +# Stream 最大长度(近似裁剪) +LOG_STREAM_MAXLEN = 100000 +# Pending 回收最小空闲时间(毫秒) +LOG_STREAM_CLAIM_IDLE_MS = 60000 +# Pending 回收检查间隔(毫秒) +LOG_STREAM_CLAIM_INTERVAL_MS = 5000 +# 每次回收的最大消息数量 +LOG_STREAM_CLAIM_BATCH_SIZE = 100 +# 去重 Key 过期时间(秒) +LOG_STREAM_DEDUP_TTL = 3600 +# 去重 Key 前缀 +LOG_STREAM_DEDUP_PREFIX = 'log:dedup' +# stdout 输出是否为 JSON +LOGURU_JSON = false +# Loguru 最低输出级别 +LOGURU_LEVEL = 'INFO' +# 是否输出到 stdout +LOGURU_STDOUT = true +# 是否启用文件日志 +LOG_FILE_ENABLED = true +# 文件日志根目录 +LOG_FILE_BASE_DIR = 'logs' +# 文件滚动策略 +LOGURU_ROTATION = '50MB' +# 文件保留策略 +LOGURU_RETENTION = '30 days' +# 文件压缩格式 +LOGURU_COMPRESSION = 'zip' +# 实例标识(用于区分实例) +LOG_INSTANCE_ID = 'prod' +# 服务名称(用于统一标识服务) +LOG_SERVICE_NAME = 'shierfeng-fastapi-backend' +# Worker 标识(auto 自动生成) +LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = true +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'optional' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;生产部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用;生产部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/shierfeng-fastapi-backend/Dockerfile.my b/shierfeng-fastapi-backend/Dockerfile.my new file mode 100644 index 0000000..49cff5b --- /dev/null +++ b/shierfeng-fastapi-backend/Dockerfile.my @@ -0,0 +1,14 @@ +FROM python:3.10 +WORKDIR /app + +# 复制源代码 +COPY . . + +# 安装依赖 +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 暴露端口 +EXPOSE 9099 + +# 启动命令 +CMD ["shierfeng", "app", "run", "--env=dockermy"] diff --git a/shierfeng-fastapi-backend/Dockerfile.pg b/shierfeng-fastapi-backend/Dockerfile.pg new file mode 100644 index 0000000..e725883 --- /dev/null +++ b/shierfeng-fastapi-backend/Dockerfile.pg @@ -0,0 +1,14 @@ +FROM python:3.10 +WORKDIR /app + +# 复制源代码 +COPY . . + +# 安装依赖 +RUN pip install --no-cache-dir -r requirements-pg.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 暴露端口 +EXPOSE 9099 + +# 启动命令 +CMD ["shierfeng", "app", "run", "--env=dockerpg"] diff --git a/shierfeng-fastapi-backend/alembic.ini b/shierfeng-fastapi-backend/alembic.ini new file mode 100644 index 0000000..d9a7ee9 --- /dev/null +++ b/shierfeng-fastapi-backend/alembic.ini @@ -0,0 +1,153 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S + +[settings] +# This section is used to set environment variables for the alembic. +# The env option is used to specify the environment for the alembic. +# It can be set to dev or prod. +env = dev diff --git a/shierfeng-fastapi-backend/alembic/README b/shierfeng-fastapi-backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/shierfeng-fastapi-backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/shierfeng-fastapi-backend/alembic/env.py b/shierfeng-fastapi-backend/alembic/env.py new file mode 100644 index 0000000..922aa03 --- /dev/null +++ b/shierfeng-fastapi-backend/alembic/env.py @@ -0,0 +1,135 @@ +import asyncio +import os +from collections.abc import Iterable, MutableMapping +from logging.config import fileConfig +from typing import Literal + +from alembic import context +from alembic.migration import MigrationContext +from alembic.operations.ops import MigrationScript +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from config.database import ASYNC_SQLALCHEMY_DATABASE_URL, Base +from utils.import_util import ImportUtil + +# 判断vesrions目录是否存在,如果不存在则创建 +alembic_veresions_path = 'alembic/versions' +if not os.path.exists(alembic_veresions_path): + os.makedirs(alembic_veresions_path) + + +# 自动查找所有模型 +found_models = ImportUtil.find_models(Base) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +alembic_config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if alembic_config.config_file_name is not None: + fileConfig(alembic_config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata +# ASYNC_SQLALCHEMY_DATABASE_URL = 'mysql+asyncmy://root:mysqlroot@127.0.0.1:3306/shierfeng-fastapi' +# other values from the config, defined by the needs of env.py, +alembic_config.set_main_option('sqlalchemy.url', ASYNC_SQLALCHEMY_DATABASE_URL) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = alembic_config.get_main_option('sqlalchemy.url') + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={'paramstyle': 'named'}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + def include_name( + name: str | None, + type_: Literal['schema', 'table', 'column', 'index', 'unique_constraint', 'foreign_key_constraint'], + parent_names: MutableMapping[Literal['schema_name', 'table_name', 'schema_qualified_table_name'], str | None], + ) -> bool: + if type_ == 'table': + return name in target_metadata.tables + return True + + def process_revision_directives( + context: MigrationContext, + revision: str | Iterable[str | None] | Iterable[str], + directives: list[MigrationScript], + ) -> None: + script = directives[0] + + # 检查所有操作集是否为空 + all_empty = all(ops.is_empty() for ops in script.upgrade_ops_list) + + if all_empty: + # 如果没有实际变更,不生成迁移文件 + directives[:] = [] + print('❎️ 未检测到模型变更,不生成迁移文件') + else: + print('✅️ 检测到模型变更,生成迁移文件') + + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + transaction_per_migration=True, + include_name=include_name, + process_revision_directives=process_revision_directives, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + alembic_config.get_section(alembic_config.config_ini_section, {}), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/shierfeng-fastapi-backend/alembic/script.py.mako b/shierfeng-fastapi-backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/shierfeng-fastapi-backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/shierfeng-fastapi-backend/app.py b/shierfeng-fastapi-backend/app.py new file mode 100644 index 0000000..1ae0470 --- /dev/null +++ b/shierfeng-fastapi-backend/app.py @@ -0,0 +1,18 @@ +import uvicorn + +from config.env import AppConfig +from server import create_app + +if __name__ != '__main__': + app = create_app() + +if __name__ == '__main__': + uvicorn.run( + app='server:create_app', + host=AppConfig.app_host, + port=AppConfig.app_port, + root_path=AppConfig.app_root_path, + reload=AppConfig.app_reload, + workers=AppConfig.app_workers, + factory=True, + ) diff --git a/shierfeng-fastapi-backend/assets/font/Arial.ttf b/shierfeng-fastapi-backend/assets/font/Arial.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9512aea86467504e94d97ddd6396955e1e6786f7 GIT binary patch literal 154600 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 f None: + """ + 初始化应用引导服务。 + + :param runtime_environment: 运行时环境服务 + :return: None + """ + self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT + + def get_app_entry_path(self) -> str: + """ + 获取应用启动入口路径。 + + :return: 应用启动入口绝对路径 + """ + return os.path.join(self.runtime_environment.get_backend_dir(), 'app.py') + + def build_app_run_command(self, env: str) -> list[str]: + """ + 构建应用启动命令。 + + :param env: 运行环境 + :return: 应用启动命令参数列表 + """ + return [ + self.runtime_environment.get_python_executable(), + self.get_app_entry_path(), + '--env', + env, + ] + + def exec_app_run_command(self, env: str) -> None: + """ + 以应用原始入口进程启动当前 FastAPI 应用。 + + :param env: 运行环境 + :return: None + """ + command = self.build_app_run_command(env) + os.execvp(command[0], command) + + +APP_BOOTSTRAP = AppBootstrapService() diff --git a/shierfeng-fastapi-backend/cli/completion/__init__.py b/shierfeng-fastapi-backend/cli/completion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-backend/cli/completion/commands.py b/shierfeng-fastapi-backend/cli/completion/commands.py new file mode 100644 index 0000000..1dce26b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/commands.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass, field +from pathlib import Path + +import typer + +from cli.completion.controller import CompletionCommandController +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway +from cli.context import OutputOption + + +@dataclass(frozen=True) +class CompletionCommandRegistration: + """ + completion 子命令注册描述。 + + :param name: 子命令名称 + :param help_text: 子命令帮助文本 + """ + + name: str + help_text: str + + +@dataclass +class CompletionSubcommandRegistrar: + """ + completion 子命令注册器。 + + 该对象负责将 `show/install/doctor` 子命令挂载到 completion 应用, + 将 Typer 命令声明细节从构建器主体中拆出。 + + :param completion_provider_gateway: completion 提供器对外网关 + """ + + completion_provider_gateway: CompletionProviderGateway = field(default_factory=lambda: COMPLETION_PROVIDER_GATEWAY) + + @staticmethod + def build_registrations() -> tuple[CompletionCommandRegistration, ...]: + """ + 返回 completion 子命令注册描述列表。 + + :return: 子命令注册描述元组 + """ + return ( + CompletionCommandRegistration(name='show', help_text='输出指定 shell 的 completion 脚本'), + CompletionCommandRegistration(name='install', help_text='安装指定 shell 的 completion 脚本'), + CompletionCommandRegistration(name='doctor', help_text='检查当前 completion 配置状态'), + ) + + def register(self, app: typer.Typer, *, controller: CompletionCommandController) -> None: + """ + 向 completion 应用注册全部子命令。 + + :param app: completion 子应用 + :param controller: completion 命令控制器 + :return: None + """ + registrations = {registration.name: registration for registration in self.build_registrations()} + + @app.command(registrations['show'].name, help=registrations['show'].help_text) + def show( + shell: str = typer.Argument( + ..., + help='shell 名称,如 bash、zsh、fish', + autocompletion=self.completion_provider_gateway.complete_shell_names, + ), + ) -> None: + """ + 输出指定 shell 的 completion 脚本。 + + :param shell: shell 名称 + :return: None + """ + controller.show(shell) + + @app.command(registrations['install'].name, help=registrations['install'].help_text) + def install( + shell: str | None = typer.Option( + None, + '--shell', + help='shell 名称,默认自动识别当前 shell', + autocompletion=self.completion_provider_gateway.complete_shell_names, + ), + output: OutputOption = 'text', + target_file: Path | None = typer.Option(None, '--target-file', help='completion 脚本目标文件路径'), + activate: bool = typer.Option(False, '--activate', help='将激活命令写入 shell rc 文件'), + rc_file: Path | None = typer.Option(None, '--rc-file', help='自定义 shell rc 文件路径'), + force: bool = typer.Option(False, '--force', help='覆盖已存在且内容不同的目标文件'), + ) -> None: + """ + 安装指定 shell 的 completion 脚本。 + + :param shell: shell 名称,默认自动识别当前 shell + :param output: 输出格式 + :param target_file: completion 脚本目标文件路径 + :param activate: 是否写入 shell rc 文件激活命令 + :param rc_file: 自定义 shell rc 文件路径 + :param force: 是否强制覆盖已存在文件 + :return: None + """ + controller.install( + output, + shell=shell, + target_file=target_file, + activate=activate, + rc_file=rc_file, + force=force, + ) + + @app.command(registrations['doctor'].name, help=registrations['doctor'].help_text) + def doctor( + output: OutputOption = 'text', + ) -> None: + """ + 检查当前 completion 配置状态。 + + :param output: 输出格式 + :return: None + """ + controller.doctor(output) + + +@dataclass +class CompletionCommandBuilder: + """ + completion 子应用构建器。 + + 该构建器负责装配 `completion` 子应用,并将控制器实例和子命令注册 + 细节收口到类式对象协作中。 + + :param completion_subcommand_registrar: completion 子命令注册器 + """ + + completion_subcommand_registrar: CompletionSubcommandRegistrar = field( + default_factory=CompletionSubcommandRegistrar + ) + + def build(self, root_cli: typer.Typer) -> typer.Typer: + """ + 构建 completion 命令组。 + + :param root_cli: 根 Typer 应用 + :return: completion 子应用 + """ + app = typer.Typer( + help='shell completion 相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, + ) + completion_command_controller = CompletionCommandController(root_cli) + self.completion_subcommand_registrar.register(app, controller=completion_command_controller) + return app + + +COMPLETION_COMMAND_BUILDER = CompletionCommandBuilder() diff --git a/shierfeng-fastapi-backend/cli/completion/controller.py b/shierfeng-fastapi-backend/cli/completion/controller.py new file mode 100644 index 0000000..16a7632 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/controller.py @@ -0,0 +1,116 @@ +from pathlib import Path + +import typer + +from cli.completion.doctor import COMPLETION_DOCTOR, CompletionDoctorService +from cli.completion.installers import COMPLETION_INSTALLER, CompletionInstallerService +from cli.completion.presenter import CompletionCommandPresenter +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) + + +class CompletionCommandController: + """ + completion 命令控制器。 + + 该控制器负责组织 `completion` 命令组的上下文准备、payload 生成、 + 文本渲染与脚本输出收口。 + + :param root_cli: 根 Typer 应用 + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: completion 命令文本渲染器 + :param installer_service: completion 安装与脚本生成服务 + :param doctor_service: completion 诊断服务 + """ + + def __init__( + self, + root_cli: typer.Typer, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: CompletionCommandPresenter | None = None, + installer_service: CompletionInstallerService | None = None, + doctor_service: CompletionDoctorService | None = None, + ) -> None: + """ + 初始化 completion 命令控制器。 + + :param root_cli: 根 Typer 应用 + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: completion 命令文本渲染器 + :param installer_service: completion 安装与脚本生成服务 + :param doctor_service: completion 诊断服务 + :return: None + """ + self.root_cli = root_cli + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or CompletionCommandPresenter() + self.installer_service = installer_service or COMPLETION_INSTALLER + self.doctor_service = doctor_service or COMPLETION_DOCTOR + + def show(self, shell: str) -> None: + """ + 输出指定 shell 的 completion 脚本。 + + :param shell: shell 名称 + :return: None + """ + typer.echo(self.installer_service.render_completion_script(self.root_cli, shell), nl=False) + + def install( + self, + output: str, + *, + shell: str | None, + target_file: Path | None, + activate: bool, + rc_file: Path | None, + force: bool, + ) -> None: + """ + 安装指定 shell 的 completion 脚本。 + + :param output: 输出格式 + :param shell: shell 名称 + :param target_file: completion 脚本目标文件路径 + :param activate: 是否写入 shell rc 文件激活命令 + :param rc_file: 自定义 shell rc 文件路径 + :param force: 是否强制覆盖已存在文件 + :return: None + """ + ctx = self.context_factory.build_readonly('dev', output) + payload = self.installer_service.install_completion_script( + self.root_cli, + shell, + target_file=target_file, + activate=activate, + rc_file=rc_file, + force=force, + ) + self.execution_service.complete_payload_result( + ctx, + payload, + text_builder=self.presenter.build_completion_install_text, + ) + + def doctor(self, output: str) -> None: + """ + 检查当前 completion 配置状态。 + + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly('dev', output) + payload = self.doctor_service.build_completion_doctor_payload() + self.execution_service.complete_payload_result( + ctx, + payload, + text_builder=self.presenter.build_completion_doctor_text, + ) diff --git a/shierfeng-fastapi-backend/cli/completion/doctor.py b/shierfeng-fastapi-backend/cli/completion/doctor.py new file mode 100644 index 0000000..7d18d29 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/doctor.py @@ -0,0 +1,95 @@ +from pathlib import Path +from typing import Any + +from cli.completion.installers import ( + CLICK_COMPLETE_ENV_VAR, + COMPLETION_INSTALLER, + CompletionInstallerService, +) +from cli.metadata import ( + COMPLETION_SHELL_SPEC_REGISTRY, + ENVIRONMENT_OPTION_SERVICE, + CompletionShellSpecRegistry, + EnvironmentOptionService, +) + + +class CompletionDoctorService: + """ + completion 诊断服务。 + + 该服务负责汇总活跃 shell、目标脚本路径、rc 文件状态、推荐安装命令 + 与环境候选列表,生成 `completion doctor` 所需的结构化结果。 + + :param installer_service: completion 安装服务 + :param shell_spec_registry: shell 元数据注册表 + :param environment_option_service: 环境选项服务 + """ + + def __init__( + self, + *, + installer_service: CompletionInstallerService | None = None, + shell_spec_registry: CompletionShellSpecRegistry | None = None, + environment_option_service: EnvironmentOptionService | None = None, + ) -> None: + """ + 初始化 completion 诊断服务。 + + :param installer_service: completion 安装服务 + :param shell_spec_registry: shell 元数据注册表 + :param environment_option_service: 环境选项服务 + :return: None + """ + self.installer_service = installer_service or COMPLETION_INSTALLER + self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY + self.environment_option_service = environment_option_service or ENVIRONMENT_OPTION_SERVICE + + def build_completion_doctor_payload(self) -> dict[str, Any]: + """ + 构建 completion 诊断结果。 + + :return: 诊断结果字典 + """ + active_shell = self.installer_service.detect_active_shell() + shells: dict[str, dict[str, Any]] = {} + for shell_name, shell_spec in self.shell_spec_registry.specs.items(): + target_file = self.installer_service.resolve_completion_target(shell_name) + rc_file = self.installer_service.resolve_completion_rc_file(shell_name) + source_command = None + if shell_spec.supported and shell_spec.generator in {'click', 'custom'}: + source_command = self.installer_service.build_source_command(target_file, shell_name) + recommended_install_command = f'shierfeng completion install --shell={shell_name}' + if shell_spec.supported and not shell_spec.auto_discovery: + recommended_install_command = f'{recommended_install_command} --activate' + shells[shell_name] = { + 'supported': shell_spec.supported, + 'detected': shell_name == active_shell, + 'description': shell_spec.description, + 'targetFile': str(target_file), + 'targetFileExists': target_file.exists(), + 'rcFile': str(rc_file) if rc_file is not None else None, + 'rcFileExists': rc_file.exists() if rc_file is not None else None, + 'autoDiscovery': shell_spec.auto_discovery, + 'sourceCommand': source_command, + 'recommendedInstallCommand': recommended_install_command, + } + + recommended_shell = active_shell if self.shell_spec_registry.get_spec(active_shell) is not None else None + recommended_install_command = None + if recommended_shell is not None: + recommended_install_command = shells[recommended_shell]['recommendedInstallCommand'] + + return { + 'ok': True, + 'message': 'completion 诊断信息已生成', + 'activeShell': active_shell or None, + 'projectDir': str(Path.cwd().resolve()), + 'envChoices': self.environment_option_service.discover_env_names(), + 'completeEnvVar': CLICK_COMPLETE_ENV_VAR, + 'recommendedInstallCommand': recommended_install_command, + 'shells': shells, + } + + +COMPLETION_DOCTOR = CompletionDoctorService() diff --git a/shierfeng-fastapi-backend/cli/completion/installers.py b/shierfeng-fastapi-backend/cli/completion/installers.py new file mode 100644 index 0000000..d57050c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/installers.py @@ -0,0 +1,465 @@ +import io +import os +from contextlib import redirect_stderr +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import click +import typer +import typer.main +from click.shell_completion import BashComplete, FishComplete, ZshComplete + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway +from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered +from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR +from cli.metadata import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry + +CLICK_COMPLETE_ENV_VAR = '_SHIERFENG_COMPLETE' + + +class CompletionInstallerShellSupport: + """ + completion shell 差异支持对象。 + + 该对象负责封装不同 shell 在脚本兼容处理和 source 命令构建上的差异, + 供安装服务通过轻量 strategy 协作对象统一复用。 + """ + + @staticmethod + def keep_script_text(script_text: str) -> str: + """ + 保持脚本文本原样返回。 + + :param script_text: 原始脚本文本 + :return: 原样脚本文本 + """ + return script_text + + @staticmethod + def make_bash_completion_script_compatible(script_text: str) -> str: + """ + 对 Click 生成的 Bash completion 脚本做旧版本兼容处理。 + + 兼容目标主要包括 macOS 默认 Bash 3.2: + + - `complete -o nosort` 在旧 Bash 上会在 `source` 阶段直接报错 + - `compopt` 在旧 Bash 上可能不存在 + + :param script_text: Click 原始 Bash completion 脚本文本 + :return: 兼容处理后的脚本文本 + """ + compatible_lines: list[str] = [] + for line in script_text.splitlines(): + stripped_line = line.strip() + if stripped_line == 'compopt -o dirnames': + indent = line[: len(line) - len(line.lstrip(' '))] + compatible_lines.extend( + [ + f'{indent}if command -v compopt >/dev/null 2>&1; then', + f'{indent} compopt -o dirnames', + f'{indent}fi', + ] + ) + continue + if stripped_line == 'compopt -o default': + indent = line[: len(line) - len(line.lstrip(' '))] + compatible_lines.extend( + [ + f'{indent}if command -v compopt >/dev/null 2>&1; then', + f'{indent} compopt -o default', + f'{indent}fi', + ] + ) + continue + if stripped_line.startswith('complete -o nosort -F '): + compatibility_line = stripped_line.replace('complete -o nosort -F ', '', 1) + function_name, command_name = compatibility_line.split(' ', 1) + compatible_lines.extend( + [ + f'if complete -o nosort -F {function_name} {command_name} 2>/dev/null; then', + ' :', + 'else', + f' complete -F {function_name} {command_name}', + 'fi', + ] + ) + continue + compatible_lines.append(line) + return '\n'.join(compatible_lines) + '\n' + + @staticmethod + def build_posix_source_command(target_file: Path) -> str: + """ + 构建 POSIX shell 使用的 source 命令。 + + :param target_file: completion 脚本文件路径 + :return: source 命令 + """ + return f'source {target_file}' + + @staticmethod + def build_fish_source_command(target_file: Path) -> str: + """ + 构建 Fish shell 使用的 source 命令。 + + :param target_file: completion 脚本文件路径 + :return: source 命令 + """ + return f'status --is-interactive; and source {target_file}' + + @staticmethod + def build_powershell_source_command(target_file: Path) -> str: + """ + 构建 PowerShell 使用的 source 命令。 + + :param target_file: completion 脚本文件路径 + :return: source 命令 + """ + return f'. "{target_file}"' + + +@dataclass(frozen=True) +class CompletionShellRuntimePolicy: + """ + completion shell 运行时策略定义。 + + :param name: shell 名称 + :param click_completion_class: Click completion 生成器类型 + :param script_transformer: 脚本文本后处理函数 + :param source_command_builder: 激活命令构建函数 + """ + + name: str + click_completion_class: type[Any] + script_transformer: Any + source_command_builder: Any + + +@dataclass(frozen=True) +class CompletionShellRuntimePolicyRegistry: + """ + completion shell 运行时策略注册表。 + + :param policies: 按 shell 名称索引的运行时策略映射 + """ + + policies: dict[str, CompletionShellRuntimePolicy] + + def get(self, shell_name: str) -> CompletionShellRuntimePolicy | None: + """ + 获取指定 shell 的运行时策略。 + + :param shell_name: shell 名称 + :return: 运行时策略,不存在时返回 None + """ + return self.policies.get(shell_name) + + +DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES = CompletionShellRuntimePolicyRegistry( + policies={ + 'bash': CompletionShellRuntimePolicy( + name='bash', + click_completion_class=BashComplete, + script_transformer=CompletionInstallerShellSupport.make_bash_completion_script_compatible, + source_command_builder=CompletionInstallerShellSupport.build_posix_source_command, + ), + 'zsh': CompletionShellRuntimePolicy( + name='zsh', + click_completion_class=ZshComplete, + script_transformer=CompletionInstallerShellSupport.keep_script_text, + source_command_builder=CompletionInstallerShellSupport.build_posix_source_command, + ), + 'fish': CompletionShellRuntimePolicy( + name='fish', + click_completion_class=FishComplete, + script_transformer=CompletionInstallerShellSupport.keep_script_text, + source_command_builder=CompletionInstallerShellSupport.build_fish_source_command, + ), + 'powershell': CompletionShellRuntimePolicy( + name='powershell', + click_completion_class=PowerShellComplete, + script_transformer=CompletionInstallerShellSupport.keep_script_text, + source_command_builder=CompletionInstallerShellSupport.build_powershell_source_command, + ), + } +) + + +class CompletionInstallerService: + """ + completion 安装与脚本生成服务。 + + 该服务负责 shell 元数据解析、脚本生成、目标路径解析、激活命令构建、 + rc 文件写入以及安装结果收口。 + + :param completion_provider_gateway: completion 提供器对外网关 + :param shell_spec_registry: shell 元数据注册表 + :param shell_runtime_policy_registry: shell 运行时策略注册表 + """ + + def __init__( + self, + *, + completion_provider_gateway: CompletionProviderGateway | None = None, + shell_spec_registry: CompletionShellSpecRegistry | None = None, + shell_runtime_policy_registry: CompletionShellRuntimePolicyRegistry | None = None, + ) -> None: + """ + 初始化 completion 安装服务。 + + :param completion_provider_gateway: completion 提供器对外网关 + :param shell_spec_registry: shell 元数据注册表 + :param shell_runtime_policy_registry: shell 运行时策略注册表 + :return: None + """ + self.completion_provider_gateway = completion_provider_gateway or COMPLETION_PROVIDER_GATEWAY + self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY + self.shell_runtime_policy_registry = shell_runtime_policy_registry or DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES + ensure_custom_completion_classes_registered() + + def resolve_completion_shell_spec(self, shell: str) -> CompletionShellSpec: + """ + 获取指定 shell 的 completion 元数据。 + + :param shell: shell 名称 + :return: shell 元数据 + :raises typer.BadParameter: shell 不存在时抛出异常 + """ + normalized_shell = shell.strip().lower() + shell_spec = self.shell_spec_registry.get_spec(normalized_shell) + if shell_spec is None: + supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells()) + raise typer.BadParameter(f'不支持的 shell:{shell},可选值为 {supported_shells}') + return shell_spec + + def resolve_shell_runtime_policy(self, shell: str) -> CompletionShellRuntimePolicy: + """ + 获取指定 shell 的运行时策略。 + + :param shell: shell 名称 + :return: shell 运行时策略 + :raises typer.BadParameter: 当前 shell 未实现运行时策略时抛出异常 + """ + shell_spec = self.resolve_completion_shell_spec(shell) + runtime_policy = self.shell_runtime_policy_registry.get(shell_spec.name) + if runtime_policy is None: + raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现') + return runtime_policy + + @staticmethod + def build_completion_click_command(root_cli: typer.Typer) -> click.Command: + """ + 将 Typer 根应用转换为 Click 命令对象,供 shell completion 生成使用。 + + :param root_cli: Typer 根应用 + :return: Click 命令对象 + """ + return typer.main.get_command(root_cli) + + def render_completion_script(self, root_cli: typer.Typer, shell: str) -> str: + """ + 生成指定 shell 的 completion 脚本文本。 + + :param root_cli: Typer 根应用 + :param shell: shell 名称 + :return: completion 脚本文本 + :raises typer.BadParameter: 当前 shell 未实现脚本生成时抛出异常 + """ + shell_spec = self.resolve_completion_shell_spec(shell) + if not shell_spec.supported or shell_spec.generator not in {'click', 'custom'}: + raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现') + + runtime_policy = self.resolve_shell_runtime_policy(shell_spec.name) + click_command = self.build_completion_click_command(root_cli) + stderr_buffer = io.StringIO() + with redirect_stderr(stderr_buffer): + script_text = runtime_policy.click_completion_class( + click_command, + {}, + 'shierfeng', + CLICK_COMPLETE_ENV_VAR, + ).source() + return runtime_policy.script_transformer(script_text) + + def resolve_completion_target(self, shell: str, target_file: Path | None = None) -> Path: + """ + 解析 completion 脚本目标文件路径。 + + :param shell: shell 名称 + :param target_file: 用户显式指定的目标文件 + :return: 目标文件绝对路径 + """ + if target_file is not None: + return target_file.expanduser().resolve() + + shell_spec = self.resolve_completion_shell_spec(shell) + return (Path.home() / shell_spec.default_target).expanduser().resolve() + + def resolve_completion_rc_file(self, shell: str, rc_file: Path | None = None) -> Path | None: + """ + 解析 completion 激活所使用的 rc 文件路径。 + + :param shell: shell 名称 + :param rc_file: 用户显式指定的 rc 文件 + :return: rc 文件绝对路径,若当前 shell 无 rc 文件则返回 None + """ + if rc_file is not None: + return rc_file.expanduser().resolve() + + shell_spec = self.resolve_completion_shell_spec(shell) + if shell_spec.default_rc_file is None: + return None + return (Path.home() / shell_spec.default_rc_file).expanduser().resolve() + + def build_source_command(self, target_file: Path, shell: str) -> str: + """ + 构建当前 shell 对应的激活命令。 + + :param target_file: completion 脚本文件路径 + :param shell: shell 名称 + :return: 激活命令文本 + """ + runtime_policy = self.resolve_shell_runtime_policy(shell) + return runtime_policy.source_command_builder(target_file) + + @staticmethod + def detect_active_shell() -> str: + """ + 检测当前进程环境下的活跃 shell 名称。 + + :return: shell 名称,未知时返回空字符串 + """ + shell_path = os.environ.get('SHELL', '').strip() + if not shell_path: + return '' + return Path(shell_path).name.lower() + + def resolve_install_shell(self, shell: str | None) -> str: + """ + 解析安装命令实际使用的 shell。 + + :param shell: 用户显式指定的 shell,允许为空 + :return: 实际使用的 shell 名称 + :raises typer.BadParameter: 无法推断或不支持时抛出异常 + """ + if shell and shell.strip(): + return self.resolve_completion_shell_spec(shell).name + + active_shell = self.detect_active_shell() + if not active_shell: + raise typer.BadParameter('未检测到当前 shell,请显式传入 --shell') + + shell_spec = self.shell_spec_registry.get_spec(active_shell) + if shell_spec is None: + supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells()) + raise typer.BadParameter( + f'当前 shell `{active_shell}` 不在支持列表中,请显式传入 --shell,可选值为 {supported_shells}' + ) + return shell_spec.name + + @staticmethod + def append_activation_line(rc_file: Path, source_command: str) -> bool: + """ + 将激活命令追加到 rc 文件,若已存在则不重复写入。 + + :param rc_file: rc 文件路径 + :param source_command: 激活命令文本 + :return: 本次是否发生写入 + """ + existing_text = '' + if rc_file.exists(): + existing_text = rc_file.read_text(encoding='utf-8') + if source_command in existing_text: + return False + + rc_file.parent.mkdir(parents=True, exist_ok=True) + with rc_file.open('a', encoding='utf-8') as file_object: + if existing_text and not existing_text.endswith('\n'): + file_object.write('\n') + file_object.write(f'{source_command}\n') + return True + + def install_completion_script( + self, + root_cli: typer.Typer, + shell: str | None, + *, + target_file: Path | None = None, + activate: bool = False, + rc_file: Path | None = None, + force: bool = False, + ) -> dict[str, Any]: + """ + 安装指定 shell 的 completion 脚本文件。 + + :param root_cli: Typer 根应用 + :param shell: shell 名称 + :param target_file: completion 脚本目标文件 + :param activate: 是否写入 rc 文件激活命令 + :param rc_file: 自定义 rc 文件路径 + :param force: 目标文件已存在时是否强制覆盖 + :return: 安装结果字典 + """ + resolved_shell = self.resolve_install_shell(shell) + shell_spec = self.resolve_completion_shell_spec(resolved_shell) + if not shell_spec.supported: + return { + 'ok': False, + 'message': f'{shell_spec.name} completion 当前版本未实现', + 'shell': shell_spec.name, + 'exit_code': ARGUMENT_ERROR, + } + + resolved_target_file = self.resolve_completion_target(shell_spec.name, target_file) + script_text = self.render_completion_script(root_cli, shell_spec.name) + existing_text = resolved_target_file.read_text(encoding='utf-8') if resolved_target_file.exists() else None + + if existing_text is not None and existing_text != script_text and not force: + return { + 'ok': False, + 'message': '目标文件已存在且内容不同,请传入 --force 覆盖', + 'shell': shell_spec.name, + 'targetFile': str(resolved_target_file), + 'exit_code': RUNTIME_ERROR, + } + + resolved_target_file.parent.mkdir(parents=True, exist_ok=True) + resolved_target_file.write_text(script_text, encoding='utf-8') + + source_command = self.build_source_command(resolved_target_file, shell_spec.name) + activated = shell_spec.auto_discovery + rc_file_path = self.resolve_completion_rc_file(shell_spec.name, rc_file) + rc_file_updated = False + activation_required = not shell_spec.auto_discovery + detected_shell = self.detect_active_shell() or None + + if activate and rc_file_path is not None: + rc_file_updated = self.append_activation_line(rc_file_path, source_command) + activated = True + + next_step = '当前 shell 会自动发现 completion 脚本,无需额外 source 命令' + if activation_required and not activate: + next_step = f'请执行 `{source_command}`,或重新运行并传入 --activate' + elif activation_required and activate: + next_step = '请重启当前 shell,或手动执行 rc 文件中的 source 命令使其立即生效' + + return { + 'ok': True, + 'message': 'completion 脚本已安装', + 'shell': shell_spec.name, + 'detectedShell': detected_shell, + 'targetFile': str(resolved_target_file), + 'activated': activated, + 'activateRequested': activate, + 'rcFile': str(rc_file_path) if rc_file_path is not None else None, + 'rcFileUpdated': rc_file_updated, + 'sourceCommand': source_command, + 'autoDiscovery': shell_spec.auto_discovery, + 'activationRequired': activation_required, + 'nextStep': next_step, + 'completeEnvVar': CLICK_COMPLETE_ENV_VAR, + } + + +COMPLETION_INSTALLER = CompletionInstallerService() diff --git a/shierfeng-fastapi-backend/cli/completion/presenter.py b/shierfeng-fastapi-backend/cli/completion/presenter.py new file mode 100644 index 0000000..f15667f --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/presenter.py @@ -0,0 +1,83 @@ +from typing import Any + + +class CompletionCommandPresenter: + """ + completion 命令文本渲染器。 + + 该渲染器负责将 `completion` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_completion_doctor_text(self, payload: dict[str, Any]) -> str: + """ + 将 completion 诊断结果渲染为文本摘要。 + + :param payload: completion 诊断结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'active_shell: {payload.get("activeShell") or "-"}', + f'project_dir: {payload.get("projectDir", "-")}', + f'complete_env_var: {payload.get("completeEnvVar", "-")}', + ] + if payload.get('recommendedInstallCommand'): + lines.append(f'recommended_install_command: {payload.get("recommendedInstallCommand")}') + env_choices = payload.get('envChoices') + if isinstance(env_choices, list) and env_choices: + lines.append('env_choices:') + lines.extend(f' - {env_name}' for env_name in env_choices) + shells = payload.get('shells') + if isinstance(shells, dict) and shells: + lines.append('shells:') + for shell_name, shell_payload in shells.items(): + if not isinstance(shell_payload, dict): + continue + lines.extend( + [ + f' {shell_name}:', + f' supported: {str(shell_payload.get("supported", False)).lower()}', + f' detected: {str(shell_payload.get("detected", False)).lower()}', + f' target_file: {shell_payload.get("targetFile", "-")}', + f' target_file_exists: {str(shell_payload.get("targetFileExists", False)).lower()}', + f' rc_file: {shell_payload.get("rcFile", "-") or "-"}', + f' rc_file_exists: {str(shell_payload.get("rcFileExists", False)).lower()}', + f' auto_discovery: {str(shell_payload.get("autoDiscovery", False)).lower()}', + f' source_command: {shell_payload.get("sourceCommand", "-")}', + f' recommended_install_command: {shell_payload.get("recommendedInstallCommand", "-")}', + ] + ) + return '\n'.join(lines) + + @staticmethod + def build_completion_install_text(payload: dict[str, Any]) -> str: + """ + 将 completion 安装结果渲染为文本摘要。 + + :param payload: completion 安装结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'shell: {payload.get("shell", "-")}', + ] + field_label_mapping = { + 'detectedShell': 'detected_shell', + 'targetFile': 'target_file', + 'activated': 'activated', + 'activateRequested': 'activate_requested', + 'rcFile': 'rc_file', + 'rcFileUpdated': 'rc_file_updated', + 'sourceCommand': 'source_command', + 'autoDiscovery': 'auto_discovery', + 'activationRequired': 'activation_required', + 'nextStep': 'next_step', + 'completeEnvVar': 'complete_env_var', + } + for field_name, field_label in field_label_mapping.items(): + if field_name in payload: + lines.append(f'{field_label}: {payload.get(field_name)}') + return '\n'.join(lines) diff --git a/shierfeng-fastapi-backend/cli/completion/providers.py b/shierfeng-fastapi-backend/cli/completion/providers.py new file mode 100644 index 0000000..1b9dadf --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/providers.py @@ -0,0 +1,1022 @@ +import asyncio +import os +from dataclasses import dataclass, field +from importlib import import_module +from pathlib import Path +from typing import Any + +import click + +from cli.metadata import ( + COMPLETION_SHELL_SPEC_REGISTRY, + ENVIRONMENT_OPTION_SERVICE, + CompletionShellSpecRegistry, + EnvironmentOptionService, +) + +DEFAULT_ALEMBIC_REVISION_CHOICES = ('head', 'base', 'current', '-1') +DYNAMIC_COMPLETION_TIMEOUT_SECONDS = 0.8 + + +@dataclass(frozen=True) +class CompletionContextResolver: + """ + shell completion 上下文解析器。 + + 该对象负责解析当前补全场景下的环境、缓存名称和项目目录, + 将原本散落的参数与环境变量解析逻辑收口到单一职责对象中。 + """ + + def resolve_completion_env(self, ctx: click.Context | None) -> str: + """ + 解析当前 completion 场景使用的环境名称。 + + :param ctx: Click 上下文 + :return: 环境名称,默认返回 `dev` + """ + if ctx is not None: + env_value = getattr(ctx, 'params', {}).get('env') + if isinstance(env_value, str) and env_value.strip(): + return env_value.strip() + + comp_words = os.environ.get('COMP_WORDS', '').strip() + if comp_words: + tokens = comp_words.split() + for index, token in enumerate(tokens): + if token.startswith('--env='): + env_value = token.split('=', 1)[1].strip() + if env_value: + return env_value + if token == '--env' and index + 1 < len(tokens): + env_value = tokens[index + 1].strip() + if env_value: + return env_value + + return 'dev' + + def resolve_cache_name_for_completion(self, ctx: click.Context | None) -> str: + """ + 解析当前 cache key 补全场景使用的缓存名称。 + + :param ctx: Click 上下文 + :return: 缓存名称,缺失时返回空字符串 + """ + if ctx is not None: + cache_name = getattr(ctx, 'params', {}).get('cache_name') + if isinstance(cache_name, str) and cache_name.strip(): + return cache_name.strip() + + comp_words = os.environ.get('COMP_WORDS', '').strip() + if not comp_words: + return '' + + tokens = comp_words.split() + try: + cache_index = tokens.index('cache') + except ValueError: + return '' + + if cache_index + 2 >= len(tokens): + return '' + + subcommand = tokens[cache_index + 1] + if subcommand not in {'get', 'ttl'}: + return '' + + cache_name = tokens[cache_index + 2].strip() + if cache_name.startswith('-'): + return '' + return cache_name + + @staticmethod + def resolve_project_dir() -> Path: + """ + 获取当前 CLI 工作目录对应的项目根目录。 + + :return: 当前项目根目录 + """ + return Path.cwd().resolve() + + @staticmethod + def normalize_completion_prefix(incomplete: str) -> str: + """ + 规范化补全输入前缀。 + + :param incomplete: 原始未完成输入 + :return: 规范化后的前缀文本 + """ + return incomplete.strip() + + @staticmethod + def to_display_path(path: Path, *, project_dir: Path) -> str: + """ + 将绝对路径转换为适合补全显示的路径文本。 + + :param path: 原始路径 + :param project_dir: 项目根目录 + :return: 用于 shell completion 的显示路径 + """ + try: + relative_path = path.relative_to(project_dir) + return str(relative_path) or '.' + except ValueError: + return str(path) + + +@dataclass +class DynamicCompletionService: + """ + 动态补全执行服务。 + + 该服务负责 runtime 模块导入、只读协程执行、标准 payload 提取与 + 候选过滤,从而避免各补全函数继续复制相同的模板。 + + :param context_resolver: completion 上下文解析器 + :param timeout_seconds: 动态补全超时时间 + """ + + context_resolver: CompletionContextResolver = field(default_factory=CompletionContextResolver) + timeout_seconds: float = DYNAMIC_COMPLETION_TIMEOUT_SECONDS + + @staticmethod + def _scoped_app_env(env: str) -> Any: + """ + 为动态补全临时注入 `APP_ENV` 环境变量。 + + `config.env` 在导入阶段会优先读取 `APP_ENV`,因此补全场景下 + 直接切换进程环境变量比改写 `sys.argv` 更稳定,也更符合当前 + 后端配置模块的解析约束。 + + :param env: 当前补全环境名称 + :return: 环境变量恢复上下文 + """ + + class _CompletionEnvScope: + def __enter__(self) -> None: + self._original_app_env = os.environ.get('APP_ENV') + os.environ['APP_ENV'] = env + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + if self._original_app_env is None: + os.environ.pop('APP_ENV', None) + else: + os.environ['APP_ENV'] = self._original_app_env + + return _CompletionEnvScope() + + def load_runtime_module(self, module_name: str) -> Any: + """ + 加载动态补全依赖的 runtime 模块。 + + :param module_name: runtime 模块名 + :return: 已导入模块 + """ + return import_module(module_name) + + def run_completion_coroutine(self, coroutine: Any, *, env: str) -> Any: + """ + 在补全场景下执行异步只读查询,并临时注入 `APP_ENV`。 + + :param coroutine: 待执行协程 + :param env: 当前补全环境名称 + :return: 协程执行结果 + """ + try: + with self._scoped_app_env(env): + return asyncio.run(asyncio.wait_for(coroutine, timeout=self.timeout_seconds)) + finally: + if asyncio.iscoroutine(coroutine): + coroutine.close() + + @staticmethod + def extract_completion_items(payload: dict[str, Any], field_name: str) -> list[str]: + """ + 从 CLI 标准结果中提取补全候选字段列表。 + + :param payload: CLI 标准结果字典 + :param field_name: 需要提取的字段名 + :return: 去重后的字符串列表 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return [] + + items = payload.get('items') + if not isinstance(items, list): + page_payload = payload.get('page') + if isinstance(page_payload, dict): + items = page_payload.get('rows') + if not isinstance(items, list): + return [] + + candidates = [] + for item in items: + if not isinstance(item, dict): + continue + value = item.get(field_name) + if isinstance(value, str) and value.strip(): + candidates.append(value.strip()) + return sorted(set(candidates)) + + @staticmethod + def extract_completion_values(payload: dict[str, Any], field_name: str) -> list[str]: + """ + 从 CLI 标准结果中提取字符串或整数类型的补全候选值。 + + :param payload: CLI 标准结果字典 + :param field_name: 需要提取的字段名 + :return: 去重后的字符串列表 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return [] + + items = payload.get('items') + if not isinstance(items, list): + page_payload = payload.get('page') + if isinstance(page_payload, dict): + items = page_payload.get('rows') + if not isinstance(items, list): + return [] + + candidates = [] + for item in items: + if not isinstance(item, dict): + continue + value = item.get(field_name) + if isinstance(value, str) and value.strip(): + candidates.append(value.strip()) + elif isinstance(value, int): + candidates.append(str(value)) + return sorted(set(candidates)) + + @staticmethod + def extract_completion_list(payload: dict[str, Any], field_name: str) -> list[str]: + """ + 从 CLI 标准结果中提取直接位于顶层字段的字符串列表。 + + 该方法用于缓存键名等不走 `items/page.rows` 结构的场景, + 使这类动态补全也能统一复用 runtime 对象加载与协程执行逻辑。 + + :param payload: CLI 标准结果字典 + :param field_name: 需要提取的顶层字段名 + :return: 去重后的字符串列表 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return [] + + items = payload.get(field_name) + if not isinstance(items, list): + return [] + + candidates = [item.strip() for item in items if isinstance(item, str) and item.strip()] + return sorted(set(candidates)) + + @staticmethod + def filter_candidates(candidates: list[str], incomplete: str) -> list[str]: + """ + 按未完成输入前缀过滤候选值。 + + :param candidates: 原始候选列表 + :param incomplete: 当前未完成输入 + :return: 过滤后的候选列表 + """ + normalized_incomplete = incomplete.strip().lower() + return [candidate for candidate in candidates if candidate.lower().startswith(normalized_incomplete)] + + def complete_dynamic_items( + self, + ctx: click.Context | None, + incomplete: str, + *, + runtime_module_name: str, + runtime_object_name: str | None = None, + runtime_method_name: str, + runtime_kwargs: dict[str, Any], + field_name: str, + allow_int_values: bool = False, + ) -> list[str]: + """ + 执行通用的动态只读补全查询。 + + :param ctx: Click 上下文 + :param incomplete: 当前未完成输入 + :param runtime_module_name: runtime 模块名 + :param runtime_object_name: runtime 对象名,为空时直接使用模块 + :param runtime_method_name: runtime 方法名 + :param runtime_kwargs: runtime 调用参数 + :param field_name: 待提取字段名 + :param allow_int_values: 是否允许整数候选 + :return: 过滤后的候选列表 + """ + env = self.context_resolver.resolve_completion_env(ctx) + try: + runtime_module = self.load_runtime_module(runtime_module_name) + runtime_target = getattr(runtime_module, runtime_object_name) if runtime_object_name else runtime_module + runtime_method = getattr(runtime_target, runtime_method_name) + payload = self.run_completion_coroutine(runtime_method(**runtime_kwargs), env=env) + except Exception: + return [] + + if allow_int_values: + candidates = self.extract_completion_values(payload, field_name) + else: + candidates = self.extract_completion_items(payload, field_name) + return self.filter_candidates(candidates, incomplete) + + def complete_dynamic_list( + self, + ctx: click.Context | None, + incomplete: str, + *, + runtime_module_name: str, + runtime_object_name: str | None = None, + runtime_method_name: str, + runtime_args: tuple[Any, ...] = (), + runtime_kwargs: dict[str, Any] | None = None, + field_name: str, + ) -> list[str]: + """ + 执行返回顶层字符串列表字段的动态只读补全查询。 + + :param ctx: Click 上下文 + :param incomplete: 当前未完成输入 + :param runtime_module_name: runtime 模块名 + :param runtime_object_name: runtime 对象名,为空时直接使用模块 + :param runtime_method_name: runtime 方法名 + :param runtime_args: runtime 位置参数 + :param runtime_kwargs: runtime 关键字参数 + :param field_name: 顶层列表字段名 + :return: 过滤后的候选列表 + """ + env = self.context_resolver.resolve_completion_env(ctx) + try: + runtime_module = self.load_runtime_module(runtime_module_name) + runtime_target = getattr(runtime_module, runtime_object_name) if runtime_object_name else runtime_module + runtime_method = getattr(runtime_target, runtime_method_name) + payload = self.run_completion_coroutine( + runtime_method(*runtime_args, **(runtime_kwargs or {})), + env=env, + ) + except Exception: + return [] + + candidates = self.extract_completion_list(payload, field_name) + return self.filter_candidates(candidates, incomplete) + + +@dataclass +class StaticCompletionProvider: + """ + 静态 completion 提供器。 + + 该对象负责 shell、环境、缓存名称以及 Alembic revision 等静态或 + 本地推导型补全候选。 + + :param context_resolver: completion 上下文解析器 + :param dynamic_service: 动态补全执行服务 + :param shell_spec_registry: shell 元数据注册表 + :param environment_option_service: 环境选项服务 + """ + + context_resolver: CompletionContextResolver = field(default_factory=CompletionContextResolver) + dynamic_service: DynamicCompletionService = field( + default_factory=lambda: DynamicCompletionService(context_resolver=CompletionContextResolver()) + ) + shell_spec_registry: CompletionShellSpecRegistry = field(default_factory=lambda: COMPLETION_SHELL_SPEC_REGISTRY) + environment_option_service: EnvironmentOptionService = field(default_factory=lambda: ENVIRONMENT_OPTION_SERVICE) + + def list_completion_shells(self) -> list[str]: + """ + 获取已声明的 completion shell 列表。 + + :return: shell 名称列表 + """ + return self.shell_spec_registry.list_shell_names() + + def list_static_cache_names(self) -> list[str]: + """ + 读取系统内置缓存名称列表。 + + :return: 缓存名称列表 + """ + try: + redis_init_key_config = self.dynamic_service.load_runtime_module('common.enums').RedisInitKeyConfig + except Exception: + return [] + return [key_config.key for key_config in redis_init_key_config if getattr(key_config, 'key', '')] + + def complete_env_values( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为 `--env` 选项提供可补全的环境名称。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 匹配的环境名称列表 + """ + del ctx, args + normalized_incomplete = incomplete.strip().lower() + return [ + env_name + for env_name in self.environment_option_service.discover_env_names() + if env_name.lower().startswith(normalized_incomplete) + ] + + def complete_shell_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为 shell 参数提供静态补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: shell 名称列表 + """ + del ctx, args + normalized_incomplete = incomplete.strip().lower() + return [ + shell_name for shell_name in self.list_completion_shells() if shell_name.startswith(normalized_incomplete) + ] + + def complete_cache_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为缓存名称参数提供静态补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 缓存名称列表 + """ + del ctx, args + normalized_incomplete = incomplete.strip().lower() + return [ + cache_name + for cache_name in sorted(self.list_static_cache_names()) + if cache_name.lower().startswith(normalized_incomplete) + ] + + def complete_alembic_revisions( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为数据库迁移版本参数提供本地补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 迁移版本候选列表 + """ + del ctx, args + normalized_incomplete = self.context_resolver.normalize_completion_prefix(incomplete).lower() + revision_choices = set(DEFAULT_ALEMBIC_REVISION_CHOICES) + versions_dir = self.context_resolver.resolve_project_dir() / 'alembic' / 'versions' + if versions_dir.is_dir(): + for revision_file in versions_dir.glob('*.py'): + revision_stem = revision_file.stem + revision_id = revision_stem.split('-', 1)[-1].split('_', 1)[0].strip() + if revision_id: + revision_choices.add(revision_id) + return sorted(choice for choice in revision_choices if choice.lower().startswith(normalized_incomplete)) + + +@dataclass +class PathCompletionProvider: + """ + 路径 completion 提供器。 + + 该对象负责项目相对 SQL 文件路径和输出路径补全。 + + :param context_resolver: completion 上下文解析器 + """ + + context_resolver: CompletionContextResolver = field(default_factory=CompletionContextResolver) + + def complete_sql_files( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为 SQL 文件参数提供项目内 `.sql` 文件补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: SQL 文件路径列表 + """ + del ctx, args + normalized_incomplete = self.context_resolver.normalize_completion_prefix(incomplete).lower() + project_dir = self.context_resolver.resolve_project_dir() + candidates = [] + for sql_file in project_dir.rglob('*.sql'): + if '.git' in sql_file.parts or '__pycache__' in sql_file.parts: + continue + display_path = self.context_resolver.to_display_path(sql_file, project_dir=project_dir) + if display_path.lower().startswith(normalized_incomplete): + candidates.append(display_path) + return sorted(candidates) + + def complete_output_paths( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为导出文件参数提供目录和 `.zip` 文件补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 目录或 zip 文件路径列表 + """ + del ctx, args + raw_incomplete = self.context_resolver.normalize_completion_prefix(incomplete) + project_dir = self.context_resolver.resolve_project_dir() + + expanded_input = Path(raw_incomplete).expanduser() + input_is_absolute = expanded_input.is_absolute() + if raw_incomplete.endswith(('/', os.sep)): + search_dir = expanded_input if input_is_absolute else (project_dir / expanded_input).resolve() + partial_name = '' + else: + search_dir = ( + expanded_input.parent + if input_is_absolute + else ( + project_dir / expanded_input.parent if str(expanded_input.parent) != '.' else project_dir + ).resolve() + ) + partial_name = expanded_input.name + + if not search_dir.exists() or not search_dir.is_dir(): + return [] + + candidates = [] + for child_path in sorted(search_dir.iterdir()): + if not child_path.name.startswith(partial_name): + continue + if child_path.is_dir(): + display_path = self.context_resolver.to_display_path(child_path, project_dir=project_dir) + candidates.append(f'{display_path}/') + continue + if child_path.suffix.lower() == '.zip': + candidates.append(self.context_resolver.to_display_path(child_path, project_dir=project_dir)) + return candidates + + +@dataclass +class DomainDynamicCompletionProvider: + """ + 业务域动态 completion 提供器。 + + 该对象负责配置、代码生成、缓存、任务等运行时驱动的只读补全。 + + :param context_resolver: completion 上下文解析器 + :param dynamic_service: 动态补全执行服务 + """ + + context_resolver: CompletionContextResolver = field(default_factory=CompletionContextResolver) + dynamic_service: DynamicCompletionService = field(init=False) + + def __post_init__(self) -> None: + """ + 初始化动态补全执行服务。 + + :return: None + """ + self.dynamic_service = DynamicCompletionService(context_resolver=self.context_resolver) + + def complete_config_keys( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为参数键名提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 参数键名列表 + """ + del args + return self.dynamic_service.complete_dynamic_items( + ctx, + incomplete, + runtime_module_name='cli.runtime.config', + runtime_object_name='CONFIG_RUNTIME', + runtime_method_name='list_configs', + runtime_kwargs={'config_key': incomplete, 'paged': True, 'page_num': 1, 'page_size': 20}, + field_name='configKey', + ) + + def complete_gen_table_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为代码生成业务表名称提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 业务表名称列表 + """ + del args + return self.dynamic_service.complete_dynamic_items( + ctx, + incomplete, + runtime_module_name='cli.runtime.gen', + runtime_object_name='GEN_RUNTIME', + runtime_method_name='list_gen_tables', + runtime_kwargs={'table_name': incomplete, 'paged': True, 'page_num': 1, 'page_size': 20}, + field_name='tableName', + ) + + def complete_gen_db_table_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为数据库物理表名称提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 数据库物理表名称列表 + """ + del args + return self.dynamic_service.complete_dynamic_items( + ctx, + incomplete, + runtime_module_name='cli.runtime.gen', + runtime_object_name='GEN_RUNTIME', + runtime_method_name='list_gen_db_tables', + runtime_kwargs={'table_name': incomplete, 'paged': True, 'page_num': 1, 'page_size': 20}, + field_name='tableName', + ) + + def complete_cache_keys( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为缓存键名提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 缓存键名列表 + """ + del args + cache_name = self.context_resolver.resolve_cache_name_for_completion(ctx) + if not cache_name: + return [] + return self.dynamic_service.complete_dynamic_list( + ctx, + incomplete, + runtime_module_name='cli.runtime.cache', + runtime_object_name='CACHE_RUNTIME', + runtime_method_name='list_cache_keys', + runtime_args=(cache_name,), + field_name='keys', + ) + + def complete_job_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为任务名称提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 任务名称列表 + """ + del args + return self.dynamic_service.complete_dynamic_items( + ctx, + incomplete, + runtime_module_name='cli.runtime.job', + runtime_object_name='JOB_RUNTIME', + runtime_method_name='list_jobs', + runtime_kwargs={'job_name': incomplete, 'paged': True, 'page_num': 1, 'page_size': 20}, + field_name='jobName', + ) + + def complete_job_ids( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为任务 ID 提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 任务 ID 列表 + """ + del args + return self.dynamic_service.complete_dynamic_items( + ctx, + incomplete, + runtime_module_name='cli.runtime.job', + runtime_object_name='JOB_RUNTIME', + runtime_method_name='list_jobs', + runtime_kwargs={'paged': True, 'page_num': 1, 'page_size': 20}, + field_name='jobId', + allow_int_values=True, + ) + + +@dataclass +class CompletionProviderRegistry: + """ + completion 提供器注册表。 + + 该注册表只负责持有静态补全、路径补全与动态只读补全 provider, + 供上层 gateway、安装器与其他协作者按职责选择使用。 + + :param context_resolver: completion 上下文解析器 + :param dynamic_service: 动态补全执行服务 + :param static_provider: 静态 completion 提供器 + :param path_provider: 路径 completion 提供器 + :param domain_provider: 业务域动态 completion 提供器 + """ + + context_resolver: CompletionContextResolver = field(default_factory=CompletionContextResolver) + dynamic_service: DynamicCompletionService = field(init=False) + static_provider: StaticCompletionProvider = field(init=False) + path_provider: PathCompletionProvider = field(init=False) + domain_provider: DomainDynamicCompletionProvider = field(init=False) + + def __post_init__(self) -> None: + """ + 初始化聚合 provider 依赖。 + + :return: None + """ + self.dynamic_service = DynamicCompletionService(context_resolver=self.context_resolver) + self.static_provider = StaticCompletionProvider( + context_resolver=self.context_resolver, + dynamic_service=self.dynamic_service, + ) + self.path_provider = PathCompletionProvider(context_resolver=self.context_resolver) + self.domain_provider = DomainDynamicCompletionProvider(context_resolver=self.context_resolver) + self.domain_provider.dynamic_service = self.dynamic_service + + +@dataclass +class CompletionProviderGateway: + """ + completion 对外网关。 + + 该对象负责对命令声明层、上下文层和 TUI 层暴露统一的 completion 入口, + 避免 `CompletionProviderRegistry` 本体继续膨胀为大而全的委托门面。 + + :param provider_registry: completion provider 注册表 + """ + + provider_registry: CompletionProviderRegistry = field(default_factory=CompletionProviderRegistry) + + def list_completion_shells(self) -> list[str]: + """ + 获取已声明的 completion shell 列表。 + + :return: shell 名称列表 + """ + return self.provider_registry.static_provider.list_completion_shells() + + def list_static_cache_names(self) -> list[str]: + """ + 读取系统内置缓存名称列表。 + + :return: 缓存名称列表 + """ + return self.provider_registry.static_provider.list_static_cache_names() + + def complete_env_values( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为 `--env` 选项提供可补全的环境名称。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 匹配的环境名称列表 + """ + return self.provider_registry.static_provider.complete_env_values(ctx, args, incomplete) + + def complete_shell_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为 shell 参数提供静态补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: shell 名称列表 + """ + return self.provider_registry.static_provider.complete_shell_names(ctx, args, incomplete) + + def complete_cache_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为缓存名称参数提供静态补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 缓存名称列表 + """ + return self.provider_registry.static_provider.complete_cache_names(ctx, args, incomplete) + + def complete_alembic_revisions( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为数据库迁移版本参数提供本地补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 迁移版本候选列表 + """ + return self.provider_registry.static_provider.complete_alembic_revisions(ctx, args, incomplete) + + def complete_sql_files( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为 SQL 文件参数提供项目内 `.sql` 文件补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: SQL 文件路径列表 + """ + return self.provider_registry.path_provider.complete_sql_files(ctx, args, incomplete) + + def complete_output_paths( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为导出文件参数提供目录和 `.zip` 文件补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 目录或 zip 文件路径列表 + """ + return self.provider_registry.path_provider.complete_output_paths(ctx, args, incomplete) + + def complete_config_keys( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为参数键名提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 参数键名列表 + """ + return self.provider_registry.domain_provider.complete_config_keys(ctx, args, incomplete) + + def complete_gen_table_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为代码生成业务表名称提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 业务表名称列表 + """ + return self.provider_registry.domain_provider.complete_gen_table_names(ctx, args, incomplete) + + def complete_gen_db_table_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为数据库物理表名称提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 数据库物理表名称列表 + """ + return self.provider_registry.domain_provider.complete_gen_db_table_names(ctx, args, incomplete) + + def complete_cache_keys( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为缓存键名提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 缓存键名列表 + """ + return self.provider_registry.domain_provider.complete_cache_keys(ctx, args, incomplete) + + def complete_job_names( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为任务名称提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 任务名称列表 + """ + return self.provider_registry.domain_provider.complete_job_names(ctx, args, incomplete) + + def complete_job_ids( + self, + ctx: click.Context | None, + args: list[str] | None, + incomplete: str, + ) -> list[str]: + """ + 为任务 ID 提供动态只读补全结果。 + + :param ctx: Click 上下文 + :param args: 当前命令参数列表 + :param incomplete: 当前未完成输入片段 + :return: 任务 ID 列表 + """ + return self.provider_registry.domain_provider.complete_job_ids(ctx, args, incomplete) + + +COMPLETION_PROVIDER_REGISTRY = CompletionProviderRegistry() +COMPLETION_PROVIDER_GATEWAY = CompletionProviderGateway(provider_registry=COMPLETION_PROVIDER_REGISTRY) diff --git a/shierfeng-fastapi-backend/cli/completion/shells.py b/shierfeng-fastapi-backend/cli/completion/shells.py new file mode 100644 index 0000000..1bd8fc5 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/completion/shells.py @@ -0,0 +1,113 @@ +import os + +from click.shell_completion import ( + CompletionItem, + ShellComplete, + add_completion_class, + get_completion_class, + split_arg_string, +) + +_SOURCE_POWERSHELL = """\ +$%(complete_func)s = { + param($wordToComplete, $commandAst, $cursorPosition) + + $previousCompWords = $env:COMP_WORDS + $previousCompCword = $env:COMP_CWORD + $previousCompleteInstruction = $env:%(complete_var)s + + $commandLine = $commandAst.ToString() + if ($cursorPosition -lt $commandLine.Length) { + $commandLine = $commandLine.Substring(0, $cursorPosition) + } + + $env:COMP_WORDS = $commandLine + $env:COMP_CWORD = $wordToComplete + $env:%(complete_var)s = "powershell_complete" + + try { + %(prog_name)s | ForEach-Object { + $line = $_.ToString() + if ([string]::IsNullOrWhiteSpace($line)) { + return + } + $parts = $line -split "`t", 3 + $completionValue = if ($parts.Length -ge 2) { $parts[1] } else { "" } + $completionHelp = if ($parts.Length -ge 3 -and $parts[2]) { $parts[2] } else { $completionValue } + [System.Management.Automation.CompletionResult]::new( + $completionValue, + $completionValue, + [System.Management.Automation.CompletionResultType]::ParameterValue, + $completionHelp + ) + } + } finally { + if ($null -ne $previousCompWords) { + $env:COMP_WORDS = $previousCompWords + } else { + Remove-Item Env:\\COMP_WORDS -ErrorAction SilentlyContinue + } + if ($null -ne $previousCompCword) { + $env:COMP_CWORD = $previousCompCword + } else { + Remove-Item Env:\\COMP_CWORD -ErrorAction SilentlyContinue + } + if ($null -ne $previousCompleteInstruction) { + $env:%(complete_var)s = $previousCompleteInstruction + } else { + Remove-Item Env:\\%(complete_var)s -ErrorAction SilentlyContinue + } + } +} + +Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $%(complete_func)s +""" + + +class PowerShellComplete(ShellComplete): + """ + PowerShell shell completion 支持。 + + 基于 PowerShell `Register-ArgumentCompleter -Native` 协议,将当前 + 命令行和光标位置传回 Click completion 分发器,再把返回的候选项转换为 + `CompletionResult` 对象。 + """ + + name = 'powershell' + source_template = _SOURCE_POWERSHELL + + def get_completion_args(self) -> tuple[list[str], str]: + """ + 从 PowerShell 注入的环境变量中恢复 CLI 上下文。 + + :return: 已解析的完整参数与当前不完整输入 + """ + cwords = split_arg_string(os.environ.get('COMP_WORDS', '')) + incomplete = os.environ.get('COMP_CWORD', '') + if incomplete: + incomplete_parts = split_arg_string(incomplete) + incomplete = incomplete_parts[0] if incomplete_parts else incomplete + args = cwords[1:] + if incomplete and args and args[-1] == incomplete: + args.pop() + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + """ + 将候选项格式化为 PowerShell 脚本可解析的文本行。 + + :param item: Click completion 候选项 + :return: 格式化后的文本 + """ + help_text = item.help or item.value + return f'{item.type}\t{item.value}\t{help_text}' + + +def ensure_custom_completion_classes_registered() -> None: + """ + 确保自定义 shell completion 类已注册到 Click。 + + :return: None + """ + if get_completion_class(PowerShellComplete.name) is None: + add_completion_class(PowerShellComplete) diff --git a/shierfeng-fastapi-backend/cli/context.py b/shierfeng-fastapi-backend/cli/context.py new file mode 100644 index 0000000..adbfab9 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/context.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from typing import Annotated, Literal + +import typer + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY + + +@dataclass +class CliContext: + """ + CLI 运行上下文。 + + env: 当前命令使用的环境名称 + output: 结果输出格式 + operator: 操作者标识,后续用于审计 + allow_prod: 是否显式允许生产环境执行危险命令 + yes: 是否跳过二次确认 + dry_run: 是否执行演练模式 + """ + + env: str = 'dev' + output: Literal['text', 'json'] = 'text' + operator: str | None = None + allow_prod: bool = False + yes: bool = False + dry_run: bool = False + + +class CliContextBuilder: + """ + CLI 上下文构建器。 + + 该对象负责将命令层显式参数收口为统一的 `CliContext` 数据对象, + 供上下文工厂与测试场景复用。 + """ + + @staticmethod + def build( + env: str, + output: Literal['text', 'json'], + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> CliContext: + """ + 构建命令执行上下文。 + + :param env: 当前命令环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过二次确认 + :param dry_run: 是否执行演练模式 + :return: 构建完成的 CLI 上下文 + """ + return CliContext( + env=env, + output=output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + +CLI_CONTEXT_BUILDER = CliContextBuilder() + +EnvOption = Annotated[ + str, + typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values), +] +OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')] +AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')] +YesOption = Annotated[bool, typer.Option('--yes', help='跳过危险命令确认')] +DryRunOption = Annotated[bool, typer.Option('--dry-run', help='仅执行预演,不落地实际变更')] diff --git a/shierfeng-fastapi-backend/cli/core/__init__.py b/shierfeng-fastapi-backend/cli/core/__init__.py new file mode 100644 index 0000000..17df42c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/core/__init__.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass, field + +from cli.core.app_builder import CliApplicationBuilder, ProjectRuntimeLocator +from cli.core.completion_dispatcher import CompletionDispatcher +from cli.core.context_factory import CliContextFactory, CliRuntimeState +from cli.core.execution import CliExecutionService +from cli.output import OutputRenderer + + +@dataclass +class CoreServiceContainer: + """ + CLI 核心服务容器。 + + 该容器负责统一装配输出渲染器、运行期状态、上下文工厂与执行服务, + 作为控制器、向导和 TUI 等编排层的默认依赖入口。 + + :param output_renderer: 输出渲染器 + :param runtime_state: CLI 运行期状态对象 + """ + + output_renderer: OutputRenderer = field(default_factory=OutputRenderer) + runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState) + context_factory: CliContextFactory = field(init=False) + execution_service: CliExecutionService = field(init=False) + + def __post_init__(self) -> None: + """ + 初始化核心服务依赖图。 + + :return: None + """ + self.context_factory = CliContextFactory( + runtime_state=self.runtime_state, + output_renderer=self.output_renderer, + ) + self.execution_service = CliExecutionService(output_renderer=self.output_renderer) + + +DEFAULT_CORE_SERVICES = CoreServiceContainer() + +__all__ = [ + 'DEFAULT_CORE_SERVICES', + 'CliApplicationBuilder', + 'CliContextFactory', + 'CliExecutionService', + 'CliRuntimeState', + 'CompletionDispatcher', + 'CoreServiceContainer', + 'ProjectRuntimeLocator', +] diff --git a/shierfeng-fastapi-backend/cli/core/app_builder.py b/shierfeng-fastapi-backend/cli/core/app_builder.py new file mode 100644 index 0000000..a6ad2ab --- /dev/null +++ b/shierfeng-fastapi-backend/cli/core/app_builder.py @@ -0,0 +1,363 @@ +import importlib +import sys +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Annotated, Literal + +import typer + +from cli.output import OutputRenderer +from cli.runtime.base import RUNTIME_ENVIRONMENT + + +@dataclass(frozen=True) +class ProjectRuntimeLocator: + """ + CLI 项目运行时定位器。 + + 该对象负责判断当前目录是否为后端项目根目录、注入 `sys.path`, + 并为配置导入阶段提取最小化的 `argv` 参数。 + """ + + def is_backend_project_dir(self, project_dir: Path) -> bool: + """ + 判断给定目录是否为后端项目根目录。 + + :param project_dir: 待检查目录 + :return: 是否为后端项目根目录 + """ + return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir) + + def ensure_backend_dir_on_sys_path(self) -> Path: + """ + 将当前后端项目根目录加入 `sys.path` 首位。 + + :return: 当前后端项目根目录 + :raises typer.Exit: 当前目录不是后端项目根目录时退出命令 + """ + project_dir = Path.cwd().resolve() + if not self.is_backend_project_dir(project_dir): + typer.echo('`shierfeng` 命令需在 `shierfeng-fastapi-backend` 目录下执行', err=True) + raise typer.Exit(code=2) + + project_dir_str = str(project_dir) + if sys.path and sys.path[0] == project_dir_str: + return project_dir + + sys.path.insert(0, project_dir_str) + return project_dir + + def extract_import_argv(self, argv: list[str] | None = None) -> list[str]: + """ + 提取供配置模块导入阶段使用的最小参数集。 + + :param argv: 原始命令行参数列表 + :return: 仅保留程序名和 `--env` 的参数列表 + """ + current_argv = argv or sys.argv + program_name = current_argv[0] if current_argv else 'shierfeng' + import_argv = [program_name] + env_value = '' + current_args = current_argv[1:] + for index, argument in enumerate(current_args): + if argument.startswith('--env='): + env_value = argument.split('=', 1)[1].strip() + break + if argument == '--env' and index + 1 < len(current_args): + env_value = current_args[index + 1].strip() + break + + if env_value: + import_argv.extend(['--env', env_value]) + return import_argv + + +@dataclass(frozen=True) +class CliCommandGroupRegistry: + """ + CLI 命令组注册表。 + + 该注册表集中维护根 CLI 需要挂载的业务命令组模块路径, + 避免命令组声明散落在应用构建器主体中。 + + :param command_modules: 命令组到模块路径的映射 + """ + + command_modules: dict[str, str] + + +@dataclass(frozen=True) +class CliExtensionRegistration: + """ + CLI 扩展入口注册描述。 + + :param module_path: 扩展模块导入路径 + :param attach: 已加载模块挂载到根 CLI 的函数 + """ + + module_path: str + attach: Callable[[typer.Typer, object], None] + + +@dataclass(frozen=True) +class CliExtensionRegistry: + """ + CLI 扩展入口注册表。 + + 该注册表集中维护 completion、wizard、TUI 等扩展入口的模块路径与 + 挂载方式,避免这些规则继续散落在注册器主体中。 + + :param registrations: 扩展注册描述列表 + """ + + registrations: tuple[CliExtensionRegistration, ...] + + +@dataclass(frozen=True) +class CliCommandGroupRegistrar: + """ + CLI 命令组注册器。 + + 该对象负责根据命令组注册表向根 Typer 应用挂载业务命令组。 + + :param command_group_registry: CLI 命令组注册表 + """ + + command_group_registry: CliCommandGroupRegistry + module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER) + + def register(self, cli: typer.Typer) -> None: + """ + 向根应用注册业务命令组。 + + :param cli: 根 Typer 应用 + :return: None + """ + for command_name, module_path in self.command_group_registry.command_modules.items(): + command_module = self.module_loader.load(module_path) + cli.add_typer(command_module.app, name=command_name) + + +class CliExtensionMountSupport: + """ + CLI 扩展入口挂载支持对象。 + + 该对象负责封装不同扩展模块挂载到根 CLI 的差异,避免扩展注册器本体 + 继续堆积 `if/else` 或硬编码模块处理逻辑。 + """ + + @staticmethod + def attach_completion(cli: typer.Typer, module: object) -> None: + """ + 挂载 completion 扩展入口。 + + :param cli: 根 Typer 应用 + :param module: 已加载模块 + :return: None + """ + cli.add_typer(module.COMPLETION_COMMAND_BUILDER.build(cli), name='completion') + + @staticmethod + def attach_wizard(cli: typer.Typer, module: object) -> None: + """ + 挂载 wizard 扩展入口。 + + :param cli: 根 Typer 应用 + :param module: 已加载模块 + :return: None + """ + cli.add_typer(module.WIZARD_COMMAND_BUILDER.build(), name='wizard') + + @staticmethod + def attach_tui(cli: typer.Typer, module: object) -> None: + """ + 挂载 TUI 扩展入口。 + + :param cli: 根 Typer 应用 + :param module: 已加载模块 + :return: None + """ + module.TUI_COMMAND_REGISTRATION.register(cli) + + +@dataclass(frozen=True) +class CliExtensionRegistrar: + """ + CLI 扩展入口注册器。 + + 该对象负责向根 Typer 应用挂载 completion、wizard 与 TUI + 等扩展入口。 + """ + + extension_registry: CliExtensionRegistry + module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER) + + def register(self, cli: typer.Typer) -> None: + """ + 向根应用注册扩展入口。 + + :param cli: 根 Typer 应用 + :return: None + """ + for registration in self.extension_registry.registrations: + extension_module = self.module_loader.load(registration.module_path) + registration.attach(cli, extension_module) + + +@dataclass(frozen=True) +class CliRootOptionInitializer: + """ + CLI 根级选项初始化器。 + + 该对象负责将根级 `--color`、`--icon` 选项映射到共享输出渲染器, + 避免根回调继续内联输出配置逻辑。 + + :param output_renderer: 根命令共享的输出渲染器 + """ + + output_renderer: OutputRenderer + + def initialize(self, *, color: str, icon: str) -> None: + """ + 初始化根级输出选项。 + + :param color: 文本输出颜色模式 + :param icon: 文本输出图标模式 + :return: None + """ + self.output_renderer.set_color_mode(color) + self.output_renderer.set_icon_mode(icon) + + +@dataclass(frozen=True) +class CliRootCallbackRegistrar: + """ + CLI 根回调注册器。 + + 该对象负责把根级 `--color`、`--icon` 选项回调挂载到 Typer 根应用, + 让 `CliApplicationBuilder` 更专注于整体装配流程。 + + :param root_option_initializer: 根级选项初始化器 + """ + + root_option_initializer: CliRootOptionInitializer + + def register(self, cli: typer.Typer) -> None: + """ + 向根应用注册回调。 + + :param cli: 根 Typer 应用 + :return: None + """ + + @cli.callback() + def root_callback( + color: Annotated[ + Literal['auto', 'always', 'never'], + typer.Option('--color', help='文本输出颜色模式'), + ] = 'always', + icon: Annotated[ + Literal['emoji', 'ascii', 'none'], + typer.Option('--icon', help='文本输出图标模式'), + ] = 'emoji', + ) -> None: + """ + 初始化 CLI 根级运行参数。 + + :param color: 文本输出颜色模式 + :param icon: 文本输出图标模式 + :return: None + """ + self.root_option_initializer.initialize(color=color, icon=icon) + + +@dataclass(frozen=True) +class CliModuleLoader: + """ + CLI 模块加载服务。 + + 该对象负责集中执行命令组与扩展入口所需的延迟导入, + 让注册器本身更专注于挂载动作。 + """ + + @staticmethod + def load(module_path: str) -> object: + """ + 按模块路径加载模块对象。 + + :param module_path: 模块导入路径 + :return: 已加载模块 + """ + return importlib.import_module(module_path) + + +@dataclass +class CliApplicationBuilder: + """ + CLI 根应用构建器。 + + :param output_renderer: 根命令共享的输出渲染器 + :param command_group_registrar: CLI 命令组注册器 + :param extension_registrar: CLI 扩展入口注册器 + """ + + output_renderer: OutputRenderer + command_group_registrar: CliCommandGroupRegistrar = field( + default_factory=lambda: CliCommandGroupRegistrar(DEFAULT_COMMAND_GROUP_REGISTRY) + ) + extension_registrar: CliExtensionRegistrar = field( + default_factory=lambda: CliExtensionRegistrar(DEFAULT_CLI_EXTENSION_REGISTRY) + ) + root_option_initializer: CliRootOptionInitializer = field(init=False) + root_callback_registrar: CliRootCallbackRegistrar = field(init=False) + + def __post_init__(self) -> None: + """ + 初始化根应用构建器依赖。 + + :return: None + """ + self.root_option_initializer = CliRootOptionInitializer(self.output_renderer) + self.root_callback_registrar = CliRootCallbackRegistrar(self.root_option_initializer) + + def build(self) -> typer.Typer: + """ + 构建 Typer 根应用并注册全部命令组。 + + :return: Typer 根应用 + """ + cli = typer.Typer( + name='shierfeng', + help='ShiErFeng FastAPI 后端统一命令入口', + no_args_is_help=True, + add_completion=False, + context_settings={'help_option_names': ['-h', '--help']}, + ) + self.root_callback_registrar.register(cli) + self.command_group_registrar.register(cli) + self.extension_registrar.register(cli) + return cli + + +DEFAULT_COMMAND_GROUP_REGISTRY = CliCommandGroupRegistry( + command_modules={ + 'app': 'cli.groups.app', + 'db': 'cli.groups.db', + 'ops': 'cli.groups.ops', + 'cache': 'cli.groups.cache', + 'job': 'cli.groups.job', + 'config': 'cli.groups.config', + 'crypto': 'cli.groups.crypto', + 'gen': 'cli.groups.gen', + 'dev': 'cli.groups.dev', + } +) +DEFAULT_CLI_EXTENSION_REGISTRY = CliExtensionRegistry( + registrations=( + CliExtensionRegistration('cli.completion.commands', CliExtensionMountSupport.attach_completion), + CliExtensionRegistration('cli.wizard.commands', CliExtensionMountSupport.attach_wizard), + CliExtensionRegistration('cli.tui', CliExtensionMountSupport.attach_tui), + ) +) +DEFAULT_CLI_MODULE_LOADER = CliModuleLoader() diff --git a/shierfeng-fastapi-backend/cli/core/completion_dispatcher.py b/shierfeng-fastapi-backend/cli/core/completion_dispatcher.py new file mode 100644 index 0000000..d09cd54 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/core/completion_dispatcher.py @@ -0,0 +1,121 @@ +import os +from dataclasses import dataclass, field + +import typer +from click.shell_completion import shell_complete as click_shell_complete +from typer.completion import shell_complete as typer_shell_complete + +from cli.completion.shells import ensure_custom_completion_classes_registered + + +@dataclass(frozen=True) +class CompletionInstructionSupport: + """ + completion 指令解析支持对象。 + + 该对象负责管理 completion 环境变量名、受支持 shell 列表,以及 + Click / Typer 两种指令格式的判定逻辑。 + + :param complete_env_var: completion 环境变量名 + :param supported_shells: 支持的 shell 名称列表 + """ + + complete_env_var: str = '_SHIERFENG_COMPLETE' + supported_shells: tuple[str, ...] = ('bash', 'zsh', 'fish', 'powershell') + + def is_click_style_instruction(self, instruction: str) -> bool: + """ + 判断 completion 指令是否为 Click 原生格式。 + + Click 原生格式为 `_`,例如 `bash_complete`。 + + :param instruction: completion 指令文本 + :return: 是否为 Click 风格指令 + """ + return any(instruction.startswith(f'{shell}_') for shell in self.supported_shells) + + def is_typer_style_instruction(self, instruction: str) -> bool: + """ + 判断 completion 指令是否为 Typer 兼容格式。 + + Typer 兼容格式为 `_`,例如 `complete_bash`。 + + :param instruction: completion 指令文本 + :return: 是否为 Typer 风格指令 + """ + return any(instruction.endswith(f'_{shell}') for shell in self.supported_shells) + + +@dataclass(frozen=True) +class CompletionInstructionReader: + """ + completion 指令读取器。 + + 该对象负责从当前进程环境中读取并规范化 completion 指令。 + + :param support: completion 指令解析支持对象 + """ + + support: CompletionInstructionSupport + + def read_instruction(self) -> str: + """ + 读取当前进程中的 completion 指令文本。 + + :return: 规范化后的 completion 指令文本 + """ + return os.environ.get(self.support.complete_env_var, '').strip() + + +@dataclass(frozen=True) +class CompletionDispatcher: + """ + shell completion 请求分发器。 + + 该对象负责识别 Click 与 Typer 两种 completion 指令格式, + 并在命中时直接执行补全流程后退出当前进程。 + + :param support: completion 指令解析支持对象 + :param instruction_reader: completion 指令读取器 + """ + + support: CompletionInstructionSupport = field(default_factory=CompletionInstructionSupport) + instruction_reader: CompletionInstructionReader = field(init=False) + + def __post_init__(self) -> None: + """ + 初始化 completion 指令读取器。 + + :return: None + """ + object.__setattr__(self, 'instruction_reader', CompletionInstructionReader(self.support)) + + def dispatch(self, cli: typer.Typer) -> None: + """ + 处理 shell completion 请求并在命中时直接退出。 + + 这里同时兼容 Click 与 Typer 两种 completion 指令格式,避免 + 生成脚本格式和运行时解析格式不一致时出现 `Shell complete not supported.`。 + + :param cli: Typer 根应用 + :return: None + """ + ensure_custom_completion_classes_registered() + instruction = self.instruction_reader.read_instruction() + if not instruction: + return + + click_command = typer.main.get_command(cli) + if self.support.is_click_style_instruction(instruction): + raise SystemExit( + click_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction) + ) + if self.support.is_typer_style_instruction(instruction): + raise SystemExit( + typer_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction) + ) + + raise SystemExit(1) + + +DEFAULT_COMPLETION_INSTRUCTION_SUPPORT = CompletionInstructionSupport() diff --git a/shierfeng-fastapi-backend/cli/core/context_factory.py b/shierfeng-fastapi-backend/cli/core/context_factory.py new file mode 100644 index 0000000..23adf62 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/core/context_factory.py @@ -0,0 +1,239 @@ +import logging +from dataclasses import dataclass, field +from importlib import import_module +from typing import Any + +from cli.context import CLI_CONTEXT_BUILDER, CliContext, CliContextBuilder +from cli.guards import ( + DEFAULT_DANGEROUS_COMMAND_GUARD, + DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY, + DangerousCommandGuardService, + DangerousCommandRuleRegistry, +) +from cli.output import OutputRenderer + + +@dataclass +class CliRuntimeState: + """ + CLI 运行期状态。 + + :param logs_suppressed: 当前进程日志是否已切换为静默模式 + """ + + logs_suppressed: bool = False + sqlalchemy_logs_suppressed: bool = False + + def get_logger(self) -> Any: + """ + 获取 CLI 使用的日志对象。 + + :return: 日志对象 + """ + return import_module('utils.log_util').logger + + def suppress_logs(self) -> None: + """ + 关闭 CLI 进程中的终端日志输出。 + + :return: None + """ + if self.logs_suppressed: + return + self.get_logger().remove() + self.logs_suppressed = True + + def suppress_sqlalchemy_logs(self) -> None: + """ + 关闭 CLI 进程中的 SQLAlchemy 终端 SQL 日志输出。 + + 该逻辑同时处理两类来源: + + 1. 将 `config.env.DataBaseConfig.db_echo` 强制关闭,避免后续新建 Engine 时 + 继续打开 SQLAlchemy echo。 + 2. 将已知 SQLAlchemy logger 级别提升到 WARNING,避免已有 logger 配置把 + `INFO sqlalchemy.engine.Engine ...` 继续打到标准输出。 + + :return: None + """ + if self.sqlalchemy_logs_suppressed: + return + env_module = import_module('config.env') + database_config = getattr(env_module, 'DataBaseConfig', None) + if database_config is not None and hasattr(database_config, 'db_echo'): + database_config.db_echo = False + for logger_name in ( + 'sqlalchemy', + 'sqlalchemy.engine', + 'sqlalchemy.engine.Engine', + 'sqlalchemy.pool', + ): + logging.getLogger(logger_name).setLevel(logging.WARNING) + self.sqlalchemy_logs_suppressed = True + + +@dataclass +class CliLogPolicy: + """ + CLI 日志策略服务。 + + 该对象负责在上下文构建前应用统一的日志输出策略, + 将“是否静默日志”这一策略从上下文工厂主体中拆出。 + + :param runtime_state: CLI 运行期状态对象 + """ + + runtime_state: CliRuntimeState + + def prepare_regular_command(self) -> None: + """ + 为普通 CLI 命令应用默认日志策略。 + + :return: None + """ + self.runtime_state.suppress_logs() + self.runtime_state.suppress_sqlalchemy_logs() + + +@dataclass +class DangerousCommandContextSupport: + """ + 危险命令上下文支持服务。 + + 该对象负责危险命令规则查询、保护执行与拒绝结果收口, + 让上下文工厂本体只保留装配职责。 + + :param dangerous_command_rule_registry: 危险命令规则注册表 + :param dangerous_command_guard_service: 危险命令保护执行服务 + :param output_renderer: 输出渲染器 + """ + + dangerous_command_rule_registry: DangerousCommandRuleRegistry + dangerous_command_guard_service: DangerousCommandGuardService + output_renderer: OutputRenderer + + def guard_context(self, ctx: CliContext, *, command_name: str) -> CliContext: + """ + 对危险命令上下文执行统一保护。 + + :param ctx: 已构建的 CLI 上下文 + :param command_name: 命令唯一标识 + :return: 通过保护后的 CLI 上下文 + """ + rule = self.dangerous_command_rule_registry.require_rule(command_name) + guard_result = self.dangerous_command_guard_service.guard(ctx, rule=rule) + if guard_result is not None: + self.output_renderer.complete_command(guard_result, ctx) + return ctx + + +@dataclass +class CliContextFactory: + """ + 统一构建 CLI 命令上下文。 + + :param runtime_state: CLI 运行期状态对象 + :param output_renderer: 输出渲染器 + :param cli_context_builder: CLI 上下文构建器 + :param dangerous_command_rule_registry: 危险命令规则注册表 + :param dangerous_command_guard_service: 危险命令保护执行服务 + """ + + runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState) + output_renderer: OutputRenderer = field(default_factory=OutputRenderer) + cli_context_builder: CliContextBuilder = field(default_factory=lambda: CLI_CONTEXT_BUILDER) + dangerous_command_rule_registry: DangerousCommandRuleRegistry = field( + default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY + ) + dangerous_command_guard_service: DangerousCommandGuardService = field( + default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_GUARD + ) + _log_policy: CliLogPolicy | None = field(default=None, init=False, repr=False) + _dangerous_command_support: DangerousCommandContextSupport | None = field( + default=None, + init=False, + repr=False, + ) + + def get_log_policy(self) -> CliLogPolicy: + """ + 获取当前上下文工厂使用的日志策略服务。 + + :return: CLI 日志策略服务 + """ + if self._log_policy is None: + self._log_policy = CliLogPolicy(runtime_state=self.runtime_state) + return self._log_policy + + def get_dangerous_command_support(self) -> DangerousCommandContextSupport: + """ + 获取当前上下文工厂使用的危险命令上下文支持服务。 + + :return: 危险命令上下文支持服务 + """ + if self._dangerous_command_support is None: + self._dangerous_command_support = DangerousCommandContextSupport( + dangerous_command_rule_registry=self.dangerous_command_rule_registry, + dangerous_command_guard_service=self.dangerous_command_guard_service, + output_renderer=self.output_renderer, + ) + return self._dangerous_command_support + + def build_regular( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> CliContext: + """ + 构建普通命令上下文。 + + :param env: 运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :return: CLI 上下文 + """ + self.get_log_policy().prepare_regular_command() + return self.cli_context_builder.build(env, output, allow_prod, yes, dry_run) + + def build_readonly( + self, + env: str, + output: str, + ) -> CliContext: + """ + 构建只读命令上下文。 + + :param env: 运行环境 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.build_regular(env, output, False, False, False) + + def build_dangerous( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + command_name: str, + ) -> CliContext: + """ + 构建危险命令上下文并执行统一保护。 + + :param env: 运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param command_name: 命令唯一标识 + :return: CLI 上下文 + """ + ctx = self.build_regular(env, output, allow_prod, yes, dry_run) + return self.get_dangerous_command_support().guard_context(ctx, command_name=command_name) diff --git a/shierfeng-fastapi-backend/cli/core/execution.py b/shierfeng-fastapi-backend/cli/core/execution.py new file mode 100644 index 0000000..2afd47c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/core/execution.py @@ -0,0 +1,137 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from cli.context import CliContext +from cli.exit_codes import RUNTIME_ERROR +from cli.output import CommandResult, OutputRenderer + + +@dataclass +class CliExecutionService: + """ + CLI 执行与结果收口服务。 + + :param output_renderer: 输出渲染器 + """ + + output_renderer: OutputRenderer = field(default_factory=OutputRenderer) + + def run_async(self, coroutine: Any) -> Any: + """ + 执行异步协程并返回结果。 + + :param coroutine: 待执行协程 + :return: 协程执行结果 + """ + return asyncio.run(coroutine) + + def complete_payload( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + default_exit_code: int = 0, + ) -> None: + """ + 输出标准字典负载并结束命令。 + + :param ctx: CLI 上下文 + :param payload: 标准结果字典 + :param default_exit_code: 默认退出码 + :return: None + """ + self.output_renderer.complete_command(self.build_result(payload, default_exit_code=default_exit_code), ctx) + + def complete_result( + self, + ctx: CliContext, + result: CommandResult, + ) -> None: + """ + 输出命令结果对象并结束命令。 + + :param ctx: CLI 上下文 + :param result: 命令执行结果对象 + :return: None + """ + self.output_renderer.complete_command(result, ctx) + + def complete_payload_with_text( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + text_builder: Callable[[dict[str, Any]], Any], + default_exit_code: int = 0, + text_condition: Callable[[dict[str, Any]], bool] | None = None, + ) -> None: + """ + 按输出格式收口标准负载,必要时先转换为文本结果。 + + :param ctx: CLI 上下文 + :param payload: 标准结果字典 + :param text_builder: 文本结果构建函数 + :param default_exit_code: 默认退出码 + :param text_condition: 文本模式下是否应用构建函数的判定函数 + :return: None + """ + if ctx.output != 'text': + self.complete_payload(ctx, payload, default_exit_code=default_exit_code) + return + + result = self.build_result(payload, default_exit_code=default_exit_code) + should_build_text = True + if callable(text_condition) and isinstance(result.data, dict): + should_build_text = bool(text_condition(result.data)) + if should_build_text and isinstance(result.data, dict): + result.data = text_builder(result.data) + self.complete_result(ctx, result) + + def complete_payload_result( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + text_builder: Callable[[dict[str, Any]], Any] | None = None, + default_exit_code: int = 0, + text_condition: Callable[[dict[str, Any]], bool] | None = None, + ) -> None: + """ + 统一收口标准负载,按输出格式决定是否渲染为文本结果。 + + :param ctx: CLI 上下文 + :param payload: 标准结果字典 + :param text_builder: 文本结果构建函数 + :param default_exit_code: 默认退出码 + :param text_condition: 文本模式下是否应用构建函数的判定函数 + :return: None + """ + if text_builder is None: + self.complete_payload(ctx, payload, default_exit_code=default_exit_code) + return + self.complete_payload_with_text( + ctx, + payload, + text_builder=text_builder, + default_exit_code=default_exit_code, + text_condition=text_condition, + ) + + @staticmethod + def build_result(payload: dict[str, Any], *, default_exit_code: int = 0) -> CommandResult: + """ + 将标准字典负载转换为命令结果对象。 + + :param payload: 标准结果字典 + :param default_exit_code: 默认退出码;当 payload 未显式提供 exit_code 且 + 该值仍为 0 时,失败结果会统一回退到 `RUNTIME_ERROR` + :return: 命令结果对象 + """ + result_payload = dict(payload) + exit_code = result_payload.pop('exit_code', None) + if exit_code is None: + is_ok = bool(result_payload.get('ok', True)) + exit_code = default_exit_code if is_ok or default_exit_code != 0 else RUNTIME_ERROR + return CommandResult(data=result_payload, exit_code=exit_code) diff --git a/shierfeng-fastapi-backend/cli/exit_codes.py b/shierfeng-fastapi-backend/cli/exit_codes.py new file mode 100644 index 0000000..ead6bb0 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/exit_codes.py @@ -0,0 +1,8 @@ +SUCCESS = 0 +ARGUMENT_ERROR = 2 +DEPENDENCY_ERROR = 10 +DATABASE_ERROR = 20 +REDIS_ERROR = 21 +SCHEDULER_ERROR = 22 +GUARD_REJECTED = 30 +RUNTIME_ERROR = 50 diff --git a/shierfeng-fastapi-backend/cli/groups/__init__.py b/shierfeng-fastapi-backend/cli/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-backend/cli/groups/app/__init__.py b/shierfeng-fastapi-backend/cli/groups/app/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/app/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/app/command.py b/shierfeng-fastapi-backend/cli/groups/app/command.py new file mode 100644 index 0000000..40bcea4 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/app/command.py @@ -0,0 +1,102 @@ +from typing import Annotated, Literal + +import typer + +from cli.context import EnvOption, OutputOption + +from .controller import AppCommandController + +app = typer.Typer( + help='应用相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_APP_COMMAND_CONTROLLER = AppCommandController() + + +@app.command('run', help='启动当前 FastAPI 应用') +def run_app( + env: EnvOption = 'dev', +) -> None: + """ + 启动当前 FastAPI 应用。 + + :param env: 当前命令运行环境 + :return: None + """ + _APP_COMMAND_CONTROLLER.run_app(env) + + +@app.command('doctor', help='执行应用启动前检查') +def doctor( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 执行应用启动前检查。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _APP_COMMAND_CONTROLLER.doctor(env, output) + + +@app.command('config', help='查看当前应用配置快照') +def app_config( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 输出当前应用配置快照。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _APP_COMMAND_CONTROLLER.show_config(env, output) + + +@app.command('env', help='查看当前 CLI 解析到的应用环境信息') +def app_env( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 输出当前 CLI 解析到的应用环境信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _APP_COMMAND_CONTROLLER.show_env(env, output) + + +@app.command('routes', help='查看当前应用注册路由清单') +def routes( + env: EnvOption = 'dev', + output: OutputOption = 'text', + path_prefix: Annotated[str, typer.Option('--path-prefix', help='按路由前缀过滤')] = '', + method: Annotated[str, typer.Option('--method', help='按请求方法过滤,如 GET、POST')] = '', + group_by: Annotated[Literal['none', 'tag'], typer.Option('--group-by', help='路由分组方式')] = 'none', + include_hidden: Annotated[bool, typer.Option('--include-hidden', help='包含未出现在 OpenAPI 中的路由')] = False, +) -> None: + """ + 输出当前应用注册路由清单。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param path_prefix: 路由前缀过滤条件 + :param method: 请求方法过滤条件 + :param group_by: 路由分组方式 + :param include_hidden: 是否包含隐藏路由 + :return: None + """ + _APP_COMMAND_CONTROLLER.show_routes( + env, + output, + path_prefix=path_prefix, + method=method, + group_by=group_by, + include_hidden=include_hidden, + ) diff --git a/shierfeng-fastapi-backend/cli/groups/app/controller.py b/shierfeng-fastapi-backend/cli/groups/app/controller.py new file mode 100644 index 0000000..2e4a4e8 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/app/controller.py @@ -0,0 +1,236 @@ +from collections import defaultdict +from typing import Any + +from fastapi.routing import APIRoute + +from cli.bootstrap import APP_BOOTSTRAP, AppBootstrapService +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS +from cli.runtime.app import APP_RUNTIME, AppRuntimeService +from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService +from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService +from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService + +from .presenter import AppCommandPresenter + + +class AppCommandController: + """ + 应用命令控制器。 + + 该控制器负责组织 `app` 命令组的上下文准备、runtime 调用、 + payload 构建,以及基于输出格式选择 presenter 或直接返回 JSON。 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: AppCommandPresenter | None = None, + runtime_service: AppRuntimeService | None = None, + database_runtime: DatabaseRuntimeService | None = None, + operations_runtime: OperationsRuntimeService | None = None, + crypto_runtime: CryptoRuntimeService | None = None, + bootstrap_service: AppBootstrapService | None = None, + ) -> None: + """ + 初始化应用命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 应用命令文本渲染器 + :param runtime_service: 应用运行时服务 + :param database_runtime: 数据库运行时服务 + :param operations_runtime: 运维运行时服务 + :param crypto_runtime: 传输加密运行时服务 + :param bootstrap_service: 应用引导服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or AppCommandPresenter() + self.runtime_service = runtime_service or APP_RUNTIME + self.database_runtime = database_runtime or DATABASE_RUNTIME + self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME + self.crypto_runtime = crypto_runtime or CRYPTO_RUNTIME + self.bootstrap_service = bootstrap_service or APP_BOOTSTRAP + + def run_app(self, env: str) -> None: + """ + 启动当前 FastAPI 应用。 + + :param env: 当前命令运行环境 + :return: None + """ + self.bootstrap_service.exec_app_run_command(env) + + def doctor(self, env: str, output: str) -> None: + """ + 执行应用启动前检查。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + db_status = self.execution_service.run_async(self.database_runtime.ping_database()) + redis_status = self.execution_service.run_async(self.operations_runtime.ping_redis()) + crypto_status = self.crypto_runtime.validate_crypto_config() + payload = { + 'env': ctx.env, + 'database': db_status, + 'redis': redis_status, + 'crypto': crypto_status, + } + payload['ok'] = all(item.get('ok', False) for item in (db_status, redis_status, crypto_status)) + exit_code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_doctor_text, + default_exit_code=exit_code, + ) + + def show_config(self, env: str, output: str) -> None: + """ + 输出当前应用配置快照。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload_result( + ctx, + {'ok': True, 'env': ctx.env, 'config': self.runtime_service.get_app_config_snapshot()}, + text_builder=self.presenter.build_app_config_text, + default_exit_code=SUCCESS, + ) + + def show_env(self, env: str, output: str) -> None: + """ + 输出当前 CLI 解析到的应用环境信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload_result( + ctx, + {'ok': True, 'env': ctx.env, 'runtime': self.runtime_service.get_app_env_snapshot()}, + text_builder=self.presenter.build_app_env_text, + default_exit_code=SUCCESS, + ) + + def show_routes( + self, + env: str, + output: str, + *, + path_prefix: str, + method: str, + group_by: str, + include_hidden: bool, + ) -> None: + """ + 输出当前应用注册路由清单。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param path_prefix: 路由前缀过滤条件 + :param method: 请求方法过滤条件 + :param group_by: 路由分组方式 + :param include_hidden: 是否包含隐藏路由 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + routes_payload = self._serialize_routes( + self.runtime_service.build_app_instance(), + path_prefix=path_prefix, + method=method, + include_hidden=include_hidden, + ) + grouped_routes = self._group_routes_by_tag(routes_payload) if group_by == 'tag' else None + payload = { + 'ok': True, + 'env': ctx.env, + 'count': len(routes_payload), + 'filters': { + 'pathPrefix': path_prefix, + 'method': method.upper().strip(), + 'groupBy': group_by, + 'includeHidden': include_hidden, + }, + 'routes': routes_payload, + 'groupedRoutes': grouped_routes, + } + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_routes_text, + default_exit_code=SUCCESS, + text_condition=lambda data: data.get('ok', False), + ) + + @staticmethod + def _serialize_routes( + app_instance: Any, + *, + path_prefix: str = '', + method: str = '', + include_hidden: bool = False, + ) -> list[dict[str, Any]]: + """ + 序列化 FastAPI 路由信息。 + + :param app_instance: FastAPI 应用实例 + :param path_prefix: 路径前缀过滤条件 + :param method: 请求方法过滤条件 + :param include_hidden: 是否包含未出现在 OpenAPI 中的路由 + :return: 序列化后的路由列表 + """ + normalized_method = method.upper().strip() + routes = [] + for route in app_instance.routes: + if not isinstance(route, APIRoute): + continue + if not include_hidden and not route.include_in_schema: + continue + if path_prefix and not route.path.startswith(path_prefix): + continue + route_methods = sorted(item for item in route.methods if item not in {'HEAD', 'OPTIONS'}) + if normalized_method and normalized_method not in route_methods: + continue + routes.append( + { + 'path': route.path, + 'methods': route_methods, + 'name': route.name, + 'summary': route.summary or '', + 'operationId': route.operation_id or '', + 'tags': route.tags or [], + 'includeInSchema': route.include_in_schema, + } + ) + return sorted(routes, key=lambda item: (item['path'], ','.join(item['methods']))) + + @staticmethod + def _group_routes_by_tag(routes_payload: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + """ + 按标签对路由信息分组。 + + :param routes_payload: 原始路由列表 + :return: 按标签分组后的路由映射 + """ + grouped_routes: dict[str, list[dict[str, Any]]] = defaultdict(list) + for route in routes_payload: + tags = route.get('tags') or ['__untagged__'] + for tag in tags: + grouped_routes[tag].append(route) + return dict(sorted(grouped_routes.items(), key=lambda item: item[0])) diff --git a/shierfeng-fastapi-backend/cli/groups/app/presenter.py b/shierfeng-fastapi-backend/cli/groups/app/presenter.py new file mode 100644 index 0000000..7a6cb79 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/app/presenter.py @@ -0,0 +1,210 @@ +from typing import Any + +from cli.utils import SHELL_TEXT_FORMATTER + + +class AppCommandPresenter: + """ + 应用命令文本渲染器。 + + 该渲染器负责将 `app` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_routes_text(self, payload: dict[str, Any]) -> str: + """ + 将路由列表结果渲染为文本摘要。 + + :param payload: 路由列表结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'count: {payload.get("count", 0)}', + ] + filters = payload.get('filters') + if isinstance(filters, dict): + lines.extend(self._build_route_filter_lines(filters)) + + grouped_routes = payload.get('groupedRoutes') + if filters and filters.get('groupBy') == 'tag' and isinstance(grouped_routes, dict): + lines.extend(self._build_grouped_routes_text(grouped_routes)) + return '\n'.join(lines) + + routes = payload.get('routes') + if not isinstance(routes, list) or not routes: + lines.append('routes: none') + return '\n'.join(lines) + + lines.append('routes:') + for route in routes: + if isinstance(route, dict): + lines.extend([f' {line}' for line in self._build_route_item_lines(route)]) + return '\n'.join(lines) + + def build_app_config_text(self, payload: dict[str, Any]) -> str: + """ + 将应用配置快照渲染为文本摘要。 + + :param payload: 应用配置结果字典 + :return: 文本摘要 + """ + config = payload.get('config') + if not isinstance(config, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'config: none', + ] + ) + + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'application:', + f' name: {config.get("name", "-")}', + f' host: {config.get("host", "-")}:{config.get("port", "-")}', + f' root_path: {config.get("rootPath", "-") or "/"}', + f' reload: {str(config.get("reload", False)).lower()}', + f' workers: {config.get("workers", "-")}', + f' disable_swagger: {str(config.get("disableSwagger", False)).lower()}', + f' disable_redoc: {str(config.get("disableRedoc", False)).lower()}', + 'database:', + f' type: {config.get("dbType", "-")}', + f' host: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}', + f' name: {config.get("dbDatabase", "-")}', + 'redis:', + f' host: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}', + 'logging:', + f' level: {config.get("logLevel", "-")}', + 'transport_crypto:', + f' enabled: {str(config.get("transportCryptoEnabled", False)).lower()}', + f' mode: {config.get("transportCryptoMode", "-")}', + ] + return '\n'.join(lines) + + def build_app_env_text(self, payload: dict[str, Any]) -> str: + """ + 将应用环境解析结果渲染为文本摘要。 + + :param payload: 应用环境结果字典 + :return: 文本摘要 + """ + runtime = payload.get('runtime') + if not isinstance(runtime, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'runtime: none', + ] + ) + + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'runtime:', + f' cli_env: {runtime.get("cliEnv", "-")}', + f' config_env: {runtime.get("configEnv", "-")}', + f' app_env: {runtime.get("appEnv", "-")}', + f' env_file: {runtime.get("envFile", "-")}', + f' env_file_exists: {str(runtime.get("envFileExists", False)).lower()}', + f' backend_dir: {runtime.get("backendDir", "-")}', + f' python_executable: {runtime.get("pythonExecutable", "-")}', + ] + ) + + def build_doctor_text(self, payload: dict[str, Any]) -> str: + """ + 将应用启动前检查结果渲染为文本摘要。 + + :param payload: 启动前检查结果字典 + :return: 文本摘要 + """ + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'checks:', + self._build_check_status_line('database', payload.get('database')), + self._build_check_status_line('redis', payload.get('redis')), + self._build_check_status_line('crypto', payload.get('crypto')), + ] + ) + + @staticmethod + def _build_route_filter_lines(filters: dict[str, object]) -> list[str]: + """ + 构建路由过滤条件文本行。 + + :param filters: 过滤条件字典 + :return: 过滤条件文本行列表 + """ + active_filters = [] + for key, value in filters.items(): + if value in (None, '', False, 'none'): + continue + active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}') + if not active_filters: + return ['filters: none'] + return ['filters:', *[f' - {item}' for item in active_filters]] + + @staticmethod + def _build_route_item_lines(route_item: dict[str, Any]) -> list[str]: + """ + 构建单条路由记录的文本行。 + + :param route_item: 单条路由记录 + :return: 文本行列表 + """ + methods = ','.join(route_item.get('methods', [])) or '-' + path = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('path', ''), 72) + tags = ','.join(route_item.get('tags', [])) or '-' + summary = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('summary', ''), 60) + name = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('name', ''), 40) + return [ + f'- [{methods}] {path}', + f' name: {name or "-"} | tags: {tags}', + f' summary: {summary or "-"}', + ] + + def _build_grouped_routes_text(self, grouped_routes: dict[str, Any]) -> list[str]: + """ + 将按标签分组后的路由结果渲染为文本行。 + + :param grouped_routes: 按标签分组后的路由映射 + :return: 文本行列表 + """ + if not grouped_routes: + return ['groups: none'] + + lines = [f'groups: {len(grouped_routes)}'] + for tag, routes in grouped_routes.items(): + route_items = routes if isinstance(routes, list) else [] + lines.append(f' {tag}: {len(route_items)}') + for route in route_items: + if isinstance(route, dict): + lines.extend([f' {line}' for line in self._build_route_item_lines(route)]) + return lines + + @staticmethod + def _build_check_status_line(name: str, status_payload: dict[str, Any] | None) -> str: + """ + 构建单项检查结果摘要行。 + + :param name: 检查项名称 + :param status_payload: 检查结果字典 + :return: 摘要行文本 + """ + if not isinstance(status_payload, dict): + return f' {name}: unknown' + + ok = str(status_payload.get('ok', False)).lower() + message = status_payload.get('message', '-') or '-' + error = status_payload.get('error') + if error: + return f' {name}: {ok} | {message} | error: {SHELL_TEXT_FORMATTER.truncate_text(error, 120)}' + return f' {name}: {ok} | {message}' diff --git a/shierfeng-fastapi-backend/cli/groups/cache/__init__.py b/shierfeng-fastapi-backend/cli/groups/cache/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/cache/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/cache/command.py b/shierfeng-fastapi-backend/cli/groups/cache/command.py new file mode 100644 index 0000000..6f803df --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/cache/command.py @@ -0,0 +1,163 @@ +import typer + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption + +from .controller import CacheCommandController + +app = typer.Typer( + help='缓存相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_CACHE_COMMAND_CONTROLLER = CacheCommandController() + + +@app.command('stats', help='查看缓存统计信息') +def stats( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看缓存统计信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _CACHE_COMMAND_CONTROLLER.stats(env, output) + + +@app.command('keys', help='查看指定缓存名称下的键名列表') +def keys( + cache_name: str = typer.Argument( + ..., + help='缓存名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看指定缓存名称下的键名列表。 + + :param cache_name: 缓存名称 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _CACHE_COMMAND_CONTROLLER.keys(cache_name, env, output) + + +@app.command('get', help='查看指定缓存内容') +def get( + cache_name: str = typer.Argument( + ..., + help='缓存名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names, + ), + cache_key: str = typer.Argument( + ..., + help='缓存键名', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看指定缓存内容。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _CACHE_COMMAND_CONTROLLER.get(cache_name, cache_key, env, output) + + +@app.command('clear', help='执行缓存清理') +def clear( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + cache_name: str = typer.Option( + '', + '--cache-name', + help='按缓存名称前缀清理', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names, + ), + cache_key: str = typer.Option('', '--cache-key', help='按缓存键名模糊清理'), + clear_all: bool = typer.Option(False, '--all', help='清理全部缓存并重建系统基础缓存'), +) -> None: + """ + 清理缓存。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param cache_name: 缓存名称前缀 + :param cache_key: 缓存键名 + :param clear_all: 是否清理全部缓存 + :return: None + """ + _CACHE_COMMAND_CONTROLLER.clear( + env, + output, + allow_prod, + yes, + dry_run, + cache_name=cache_name, + cache_key=cache_key, + clear_all=clear_all, + ) + + +@app.command('ttl', help='查看指定缓存键的剩余过期时间') +def ttl( + cache_name: str = typer.Argument( + ..., + help='缓存名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names, + ), + cache_key: str = typer.Argument( + ..., + help='缓存键名', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看指定缓存键的剩余过期时间。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _CACHE_COMMAND_CONTROLLER.ttl(cache_name, cache_key, env, output) + + +@app.command('warmup', help='执行系统缓存预热') +def warmup( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 执行系统缓存预热。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _CACHE_COMMAND_CONTROLLER.warmup(env, output, allow_prod, yes) diff --git a/shierfeng-fastapi-backend/cli/groups/cache/controller.py b/shierfeng-fastapi-backend/cli/groups/cache/controller.py new file mode 100644 index 0000000..92fd656 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/cache/controller.py @@ -0,0 +1,183 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.cache import CACHE_RUNTIME, CacheRuntimeService + +from .presenter import CacheCommandPresenter + + +class CacheCommandController: + """ + 缓存命令控制器。 + + 该控制器负责组织 `cache` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 缓存命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: CacheCommandPresenter | None = None, + runtime_service: CacheRuntimeService | None = None, + ) -> None: + """ + 初始化缓存命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 缓存命令文本渲染器 + :param runtime_service: 缓存运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or CacheCommandPresenter() + self.runtime_service = runtime_service or CACHE_RUNTIME + + def stats(self, env: str, output: str) -> None: + """ + 查看缓存统计信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.get_cache_stats()) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_cache_stats_text, + text_condition=lambda result_data: bool(result_data.get('ok', False)), + ) + + def keys(self, cache_name: str, env: str, output: str) -> None: + """ + 查看指定缓存名称下的键名列表。 + + :param cache_name: 缓存名称 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.list_cache_keys(cache_name)) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_cache_keys_text, + text_condition=lambda result_data: bool(result_data.get('ok', False)), + ) + + def get(self, cache_name: str, cache_key: str, env: str, output: str) -> None: + """ + 查看指定缓存内容。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.get_cache_value(cache_name, cache_key)) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_cache_value_text, + text_condition=lambda result_data: bool(result_data.get('ok', False)), + ) + + def clear( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + cache_name: str, + cache_key: str, + clear_all: bool, + ) -> None: + """ + 清理缓存。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param cache_name: 缓存名称前缀 + :param cache_key: 缓存键名 + :param clear_all: 是否清理全部缓存 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='cache clear', + ) + self.execution_service.complete_payload( + ctx, + self.execution_service.run_async( + self.runtime_service.clear_cache( + cache_name=cache_name, + cache_key=cache_key, + clear_all=clear_all, + dry_run=dry_run, + ) + ), + ) + + def ttl(self, cache_name: str, cache_key: str, env: str, output: str) -> None: + """ + 查看指定缓存键的剩余过期时间。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.get_cache_ttl(cache_name, cache_key)) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_cache_ttl_text, + text_condition=lambda result_data: 'error' not in result_data, + ) + + def warmup(self, env: str, output: str, allow_prod: bool, yes: bool) -> None: + """ + 执行系统缓存预热。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + False, + command_name='cache warmup', + ) + self.execution_service.complete_payload( + ctx, self.execution_service.run_async(self.runtime_service.warmup_cache()) + ) diff --git a/shierfeng-fastapi-backend/cli/groups/cache/presenter.py b/shierfeng-fastapi-backend/cli/groups/cache/presenter.py new file mode 100644 index 0000000..6b96052 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/cache/presenter.py @@ -0,0 +1,150 @@ +from cli.utils import SHELL_TEXT_FORMATTER + + +class CacheCommandPresenter: + """ + 缓存命令文本渲染器。 + + 该渲染器负责将 `cache` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_cache_stats_text(self, payload: dict[str, object]) -> str: + """ + 将缓存统计结果渲染为文本摘要。 + + :param payload: 缓存统计结果 + :return: 文本摘要 + """ + info = payload.get('info') + command_stats = payload.get('commandStats') + cache_names = payload.get('cacheNames') + + redis_version = '-' + connected_clients = '-' + used_memory = '-' + uptime_seconds = '-' + keyspace_hits = '-' + keyspace_misses = '-' + if isinstance(info, dict): + redis_version = info.get('redis_version', '-') + connected_clients = info.get('connected_clients', '-') + used_memory = info.get('used_memory_human', info.get('used_memory', '-')) + uptime_seconds = info.get('uptime_in_seconds', '-') + keyspace_hits = info.get('keyspace_hits', '-') + keyspace_misses = info.get('keyspace_misses', '-') + + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'db_size: {payload.get("dbSize", 0)}', + 'redis:', + f' version: {redis_version}', + f' connected_clients: {connected_clients}', + f' used_memory: {used_memory}', + f' uptime_seconds: {uptime_seconds}', + f' keyspace_hits: {keyspace_hits}', + f' keyspace_misses: {keyspace_misses}', + ] + + if isinstance(command_stats, list) and command_stats: + lines.append('command_stats_top10:') + lines.extend( + f' - {item.get("name", "-")}: {item.get("value", 0)}' + for item in command_stats[:10] + if isinstance(item, dict) + ) + else: + lines.append('command_stats_top10: none') + + if isinstance(cache_names, list) and cache_names: + lines.append(f'cache_names: {len(cache_names)}') + lines.append('cache_name_samples:') + lines.extend( + f' - {item.get("cacheName", "-")}: {self._build_cache_name_remark(item.get("remark", ""))}' + for item in cache_names[:10] + if isinstance(item, dict) + ) + else: + lines.append('cache_names: 0') + + return '\n'.join(lines) + + def build_cache_keys_text(self, payload: dict[str, object]) -> str: + """ + 将缓存键列表结果渲染为文本摘要。 + + :param payload: 缓存键列表结果 + :return: 文本摘要 + """ + keys = payload.get('keys') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'cache_name: {payload.get("cacheName", "")}', + f'count: {payload.get("count", 0)}', + ] + if not isinstance(keys, list) or not keys: + lines.append('keys: none') + return '\n'.join(lines) + + lines.append('keys:') + lines.extend(f' - {key}' for key in keys) + return '\n'.join(lines) + + def build_cache_value_text(self, payload: dict[str, object]) -> str: + """ + 将缓存值读取结果渲染为文本摘要。 + + :param payload: 缓存值读取结果字典 + :return: 文本摘要 + """ + cache_value = '' if payload.get('cacheValue') is None else str(payload.get('cacheValue')) + cache_value_lines = cache_value.splitlines() if cache_value else [] + if not cache_value_lines: + rendered_value_lines = [' -'] + elif len(cache_value_lines) == 1: + rendered_value_lines = [f' {SHELL_TEXT_FORMATTER.truncate_text(cache_value_lines[0], 200)}'] + else: + rendered_value_lines = [' |', *[f' {line}' for line in cache_value_lines]] + + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'cache_name: {payload.get("cacheName", "")}', + f'cache_key: {payload.get("cacheKey", "")}', + f'full_cache_key: {payload.get("fullCacheKey", "")}', + 'cache_value:', + *rendered_value_lines, + ] + ) + + @staticmethod + def build_cache_ttl_text(payload: dict[str, object]) -> str: + """ + 将缓存 TTL 结果渲染为文本摘要。 + + :param payload: 缓存 TTL 结果字典 + :return: 文本摘要 + """ + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'cache_name: {payload.get("cacheName", "")}', + f'cache_key: {payload.get("cacheKey", "")}', + f'full_cache_key: {payload.get("fullCacheKey", "")}', + f'message: {payload.get("message", "-")}', + f'ttl_seconds: {payload.get("ttlSeconds", "-")}', + f'persistent: {str(payload.get("persistent", False)).lower()}', + f'expires: {str(payload.get("expires", False)).lower()}', + ] + ) + + @staticmethod + def _build_cache_name_remark(remark: object) -> str: + """ + 规范化缓存名称备注文本。 + + :param remark: 原始备注 + :return: 规范化后的备注 + """ + text = '' if remark is None else str(remark).strip() + return text or '-' diff --git a/shierfeng-fastapi-backend/cli/groups/config/__init__.py b/shierfeng-fastapi-backend/cli/groups/config/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/config/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/config/command.py b/shierfeng-fastapi-backend/cli/groups/config/command.py new file mode 100644 index 0000000..ed0b695 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/config/command.py @@ -0,0 +1,173 @@ +from typing import Annotated, Literal + +import typer + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption + +from .controller import ConfigCommandController + +app = typer.Typer( + help='参数配置相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_CONFIG_COMMAND_CONTROLLER = ConfigCommandController() + + +@app.command('list', help='查看参数配置列表') +def list_command( + env: EnvOption = 'dev', + output: OutputOption = 'text', + config_name: Annotated[str, typer.Option('--config-name', help='按参数名称过滤')] = '', + config_key: Annotated[ + str, + typer.Option( + '--config-key', + help='按参数键名过滤', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys, + ), + ] = '', + config_type: Annotated[Literal['Y', 'N'] | None, typer.Option('--config-type', help='按系统内置标记过滤')] = None, + begin_date: Annotated[str, typer.Option('--begin-date', help='按创建时间开始日期过滤,格式 YYYY-MM-DD')] = '', + end_date: Annotated[str, typer.Option('--end-date', help='按创建时间结束日期过滤,格式 YYYY-MM-DD')] = '', + paged: Annotated[bool, typer.Option('--paged/--no-paged', help='是否启用分页结果')] = False, + page_num: Annotated[int, typer.Option('--page-num', min=1, help='分页页码')] = 1, + page_size: Annotated[int, typer.Option('--page-size', min=1, help='分页每页数量')] = 20, +) -> None: + """ + 查看参数配置列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param config_name: 参数名称过滤条件 + :param config_key: 参数键名过滤条件 + :param config_type: 参数类型过滤条件 + :param begin_date: 查询开始日期 + :param end_date: 查询结束日期 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + _CONFIG_COMMAND_CONTROLLER.list_configs( + env, + output, + config_name=config_name, + config_key=config_key, + config_type=config_type, + begin_date=begin_date, + end_date=end_date, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + + +@app.command('get', help='查看单个参数配置详情') +def get_command( + config_key: Annotated[ + str, + typer.Argument( + help='需要查询的参数键名', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys, + ), + ], + env: EnvOption = 'dev', + output: OutputOption = 'text', + source: Annotated[Literal['db', 'cache', 'both'], typer.Option('--source', help='读取来源')] = 'both', +) -> None: + """ + 查看单个参数配置详情。 + + :param config_key: 需要查询的参数键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param source: 配置读取来源 + :return: None + """ + _CONFIG_COMMAND_CONTROLLER.get_config(config_key, env, output, source=source) + + +@app.command('set', help='新增或更新单个参数配置') +def set_command( + config_key: Annotated[ + str, + typer.Argument( + help='需要写入的参数键名', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys, + ), + ], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + value: Annotated[str, typer.Option('--value', help='参数键值')] = ..., + name: Annotated[str | None, typer.Option('--name', help='参数名称,新增时必填')] = None, + config_type: Annotated[Literal['Y', 'N'] | None, typer.Option('--config-type', help='系统内置标记')] = None, + remark: Annotated[str | None, typer.Option('--remark', help='参数备注')] = None, +) -> None: + """ + 新增或更新单个参数配置。 + + :param config_key: 需要写入的参数键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param value: 参数键值 + :param name: 参数名称 + :param config_type: 参数类型 + :param remark: 参数备注 + :return: None + """ + _CONFIG_COMMAND_CONTROLLER.set_config( + config_key, + env, + output, + allow_prod, + yes, + dry_run, + value=value, + name=name, + config_type=config_type, + remark=remark, + ) + + +@app.command('sync-cache', help='刷新参数配置缓存') +def sync_cache( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 刷新参数配置缓存。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _CONFIG_COMMAND_CONTROLLER.sync_cache(env, output, allow_prod, yes) + + +@app.command('doctor', help='诊断参数配置数据库与缓存是否一致') +def doctor( + env: EnvOption = 'dev', + output: OutputOption = 'text', + sample_limit: Annotated[int, typer.Option('--sample-limit', min=1, help='问题示例键名输出上限')] = 10, +) -> None: + """ + 诊断参数配置数据库与缓存是否一致。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param sample_limit: 问题示例键名输出上限 + :return: None + """ + _CONFIG_COMMAND_CONTROLLER.doctor(env, output, sample_limit=sample_limit) diff --git a/shierfeng-fastapi-backend/cli/groups/config/controller.py b/shierfeng-fastapi-backend/cli/groups/config/controller.py new file mode 100644 index 0000000..1404898 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/config/controller.py @@ -0,0 +1,225 @@ +from typing import Literal + +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.config import CONFIG_RUNTIME, ConfigRuntimeService + +from .presenter import ConfigCommandPresenter + + +class ConfigCommandController: + """ + 参数配置命令控制器。 + + 该控制器负责组织 `config` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 参数配置命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: ConfigCommandPresenter | None = None, + runtime_service: ConfigRuntimeService | None = None, + ) -> None: + """ + 初始化参数配置命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 参数配置命令文本渲染器 + :param runtime_service: 参数配置运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or ConfigCommandPresenter() + self.runtime_service = runtime_service or CONFIG_RUNTIME + + def list_configs( + self, + env: str, + output: str, + *, + config_name: str, + config_key: str, + config_type: Literal['Y', 'N'] | None, + begin_date: str, + end_date: str, + paged: bool, + page_num: int, + page_size: int, + ) -> None: + """ + 查看参数配置列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param config_name: 参数名称过滤条件 + :param config_key: 参数键名过滤条件 + :param config_type: 参数类型过滤条件 + :param begin_date: 查询开始日期 + :param end_date: 查询结束日期 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async( + self.runtime_service.list_configs( + config_name=config_name, + config_key=config_key, + config_type=config_type, + begin_date=begin_date, + end_date=end_date, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + ) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_config_list_text, + text_condition=lambda data: data.get('ok', False), + ) + + def get_config( + self, + config_key: str, + env: str, + output: str, + *, + source: Literal['db', 'cache', 'both'], + ) -> None: + """ + 查看单个参数配置详情。 + + :param config_key: 需要查询的参数键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param source: 配置读取来源 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.get_config(config_key, source=source)) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_config_get_text, + text_condition=lambda data: data.get('ok', False), + ) + + def set_config( + self, + config_key: str, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + value: str, + name: str | None, + config_type: Literal['Y', 'N'] | None, + remark: str | None, + ) -> None: + """ + 新增或更新单个参数配置。 + + :param config_key: 需要写入的参数键名 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param value: 参数键值 + :param name: 参数名称 + :param config_type: 参数类型 + :param remark: 参数备注 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='config set', + ) + payload = self.execution_service.run_async( + self.runtime_service.set_config( + config_key, + value, + config_name=name, + config_type=config_type, + remark=remark, + dry_run=dry_run, + ) + ) + payload['env'] = ctx.env + self.execution_service.complete_payload(ctx, payload) + + def sync_cache( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + ) -> None: + """ + 刷新参数配置缓存。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + False, + command_name='config sync-cache', + ) + payload = self.execution_service.run_async(self.runtime_service.sync_config_cache()) + payload['env'] = ctx.env + self.execution_service.complete_payload(ctx, payload) + + def doctor( + self, + env: str, + output: str, + *, + sample_limit: int, + ) -> None: + """ + 诊断参数配置数据库与缓存是否一致。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param sample_limit: 问题示例键名输出上限 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.diagnose_config(sample_limit=sample_limit)) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_config_doctor_text, + text_condition=lambda data: 'error' not in data, + ) diff --git a/shierfeng-fastapi-backend/cli/groups/config/presenter.py b/shierfeng-fastapi-backend/cli/groups/config/presenter.py new file mode 100644 index 0000000..cde9693 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/config/presenter.py @@ -0,0 +1,180 @@ +from cli.utils import SHELL_TEXT_FORMATTER + + +class ConfigCommandPresenter: + """ + 参数配置命令文本渲染器。 + + 该渲染器负责将 `config` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_config_list_text(self, payload: dict[str, object]) -> str: + """ + 将参数配置列表结果渲染为文本摘要。 + + :param payload: 参数配置列表结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + ] + + filters = payload.get('filters') + if isinstance(filters, dict): + lines.extend(self._build_config_filter_lines(filters)) + + page_payload = payload.get('page') + if isinstance(page_payload, dict): + rows = page_payload.get('rows') + lines.append( + 'page: ' + f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} ' + f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})' + ) + lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}') + if not isinstance(rows, list) or not rows: + lines.append('configs: none') + return '\n'.join(lines) + lines.append('configs:') + for row in rows: + if isinstance(row, dict): + lines.extend([f' {item}' for item in self._build_config_item_lines(row)]) + return '\n'.join(lines) + + items = payload.get('items') + lines.append(f'count: {payload.get("count", 0)}') + if not isinstance(items, list) or not items: + lines.append('configs: none') + return '\n'.join(lines) + + lines.append('configs:') + for item in items: + if isinstance(item, dict): + lines.extend([f' {line}' for line in self._build_config_item_lines(item)]) + return '\n'.join(lines) + + def build_config_get_text(self, payload: dict[str, object]) -> str: + """ + 将单个参数配置详情结果渲染为文本摘要。 + + :param payload: 参数配置详情结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'key: {payload.get("key", "")}', + f'source: {payload.get("source", "")}', + ] + + if payload.get('source') == 'both': + lines.append(f'in_sync: {str(payload.get("inSync", False)).lower()}') + + if payload.get('source') in {'db', 'both'}: + lines.extend(self._build_config_detail_section('database', payload.get('database'))) + if payload.get('source') in {'cache', 'both'}: + lines.extend(self._build_config_detail_section('cache', payload.get('cache'))) + return '\n'.join(lines) + + def build_config_doctor_text(self, payload: dict[str, object]) -> str: + """ + 将参数配置诊断结果渲染为文本摘要。 + + :param payload: 参数配置诊断结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'message: {payload.get("message", "-")}', + f'database_count: {payload.get("databaseCount", 0)}', + f'cache_count: {payload.get("cacheCount", 0)}', + f'missing_in_cache_count: {payload.get("missingInCacheCount", 0)}', + f'orphan_in_cache_count: {payload.get("orphanInCacheCount", 0)}', + f'mismatch_count: {payload.get("mismatchCount", 0)}', + f'sample_limit: {payload.get("sampleLimit", 0)}', + ] + lines.extend(self._build_config_doctor_items('missing_in_cache', payload.get('missingInCache'))) + lines.extend(self._build_config_doctor_items('orphan_in_cache', payload.get('orphanInCache'))) + lines.extend(self._build_config_doctor_items('mismatch_keys', payload.get('mismatchKeys'))) + return '\n'.join(lines) + + @staticmethod + def _build_config_filter_lines(filters: dict[str, object]) -> list[str]: + """ + 构建参数配置过滤条件文本行。 + + :param filters: 过滤条件字典 + :return: 过滤条件文本行列表 + """ + active_filters = [] + for key, value in filters.items(): + if value in (None, '', False): + continue + active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}') + if not active_filters: + return ['filters: none'] + return ['filters:', *[f' - {item}' for item in active_filters]] + + @staticmethod + def _build_config_item_lines(config_item: dict[str, object]) -> list[str]: + """ + 构建单条参数配置记录的文本行。 + + :param config_item: 单条参数配置记录 + :return: 文本行列表 + """ + config_id = config_item.get('configId', '-') + config_key = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configKey', ''), 50) + config_name = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configName', ''), 24) + config_type = config_item.get('configType', '-') + config_value = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configValue', ''), 80) + remark = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', ''), 60) + return [ + f'- [{config_id}] {config_key} | 名称: {config_name} | 内置: {config_type}', + f' value: {config_value or "-"}', + f' remark: {remark or "-"}', + ] + + @staticmethod + def _build_config_detail_section(title: str, config_item: dict[str, object] | None) -> list[str]: + """ + 构建单个配置来源的详情文本段落。 + + :param title: 段落标题 + :param config_item: 配置详情字典 + :return: 文本行列表 + """ + if not isinstance(config_item, dict): + return [f'{title}: none'] + + config_key = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configKey', ''), 60) + config_name = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configName', ''), 30) + config_value = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configValue', ''), 120) + config_type = config_item.get('configType', '-') + remark = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', ''), 80) + config_id = config_item.get('configId', '-') + return [ + f'{title}:', + f' id: {config_id}', + f' key: {config_key or "-"}', + f' name: {config_name or "-"}', + f' value: {config_value or "-"}', + f' type: {config_type}', + f' remark: {remark or "-"}', + ] + + @staticmethod + def _build_config_doctor_items(title: str, items: list[object] | object) -> list[str]: + """ + 构建配置诊断问题示例段落。 + + :param title: 段落标题 + :param items: 问题键名列表 + :return: 文本行列表 + """ + if not isinstance(items, list) or not items: + return [f'{title}: none'] + return [f'{title}:', *[f' - {item}' for item in items]] diff --git a/shierfeng-fastapi-backend/cli/groups/crypto/__init__.py b/shierfeng-fastapi-backend/cli/groups/crypto/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/crypto/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/crypto/command.py b/shierfeng-fastapi-backend/cli/groups/crypto/command.py new file mode 100644 index 0000000..391199b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/crypto/command.py @@ -0,0 +1,94 @@ +import typer + +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption + +from .controller import CryptoCommandController + +app = typer.Typer( + help='传输加密相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_CRYPTO_COMMAND_CONTROLLER = CryptoCommandController() + + +@app.command('validate', help='校验传输加密配置') +def validate( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 校验传输加密配置。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _CRYPTO_COMMAND_CONTROLLER.validate(env, output) + + +@app.command('keygen', help='生成新的传输加密密钥对') +def keygen( + env: EnvOption = 'dev', + output: OutputOption = 'text', + kid: str = typer.Option('default', '--kid', help='生成的密钥版本标识'), + key_size: int = typer.Option(2048, '--key-size', help='RSA 密钥长度'), +) -> None: + """ + 生成新的传输加密密钥对。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param kid: 生成的密钥版本标识 + :param key_size: RSA 密钥长度 + :return: None + """ + _CRYPTO_COMMAND_CONTROLLER.keygen(env, output, kid=kid, key_size=key_size) + + +@app.command('rotate', help='生成传输加密密钥轮换辅助结果') +def rotate( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + next_kid: str = typer.Option('rotated', '--next-kid', help='轮换后的新密钥版本标识'), + key_size: int = typer.Option(2048, '--key-size', help='新密钥的 RSA 长度'), +) -> None: + """ + 生成传输加密密钥轮换辅助结果。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param next_kid: 轮换后的新密钥版本标识 + :param key_size: 新密钥的 RSA 长度 + :return: None + """ + _CRYPTO_COMMAND_CONTROLLER.rotate( + env, + output, + allow_prod, + yes, + dry_run, + next_kid=next_kid, + key_size=key_size, + ) + + +@app.command('export-public', help='查看当前运行环境的公钥信息') +def export_public( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 导出当前运行环境的公钥信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _CRYPTO_COMMAND_CONTROLLER.export_public(env, output) diff --git a/shierfeng-fastapi-backend/cli/groups/crypto/controller.py b/shierfeng-fastapi-backend/cli/groups/crypto/controller.py new file mode 100644 index 0000000..919e4f4 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/crypto/controller.py @@ -0,0 +1,125 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService + +from .presenter import CryptoCommandPresenter + + +class CryptoCommandController: + """ + 传输加密命令控制器。 + + 该控制器负责组织 `crypto` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 传输加密命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: CryptoCommandPresenter | None = None, + runtime_service: CryptoRuntimeService | None = None, + ) -> None: + """ + 初始化传输加密命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 传输加密命令文本渲染器 + :param runtime_service: 传输加密运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or CryptoCommandPresenter() + self.runtime_service = runtime_service or CRYPTO_RUNTIME + + def validate(self, env: str, output: str) -> None: + """ + 校验传输加密配置。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload(ctx, self.runtime_service.validate_crypto_config()) + + def keygen(self, env: str, output: str, *, kid: str, key_size: int) -> None: + """ + 生成新的传输加密密钥对。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param kid: 生成的密钥版本标识 + :param key_size: RSA 密钥长度 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.generate_crypto_key_pair(kid, key_size) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_crypto_keygen_text, + text_condition=lambda data: data.get('ok', False), + ) + + def rotate( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + next_kid: str, + key_size: int, + ) -> None: + """ + 生成传输加密密钥轮换辅助结果。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param next_kid: 轮换后的新密钥版本标识 + :param key_size: 新密钥的 RSA 长度 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='crypto rotate', + ) + self.execution_service.complete_payload(ctx, self.runtime_service.build_rotation_payload(next_kid, key_size)) + + def export_public(self, env: str, output: str) -> None: + """ + 导出当前运行环境的公钥信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.export_public_key() + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_export_public_text, + text_condition=lambda data: data.get('ok', False), + ) diff --git a/shierfeng-fastapi-backend/cli/groups/crypto/presenter.py b/shierfeng-fastapi-backend/cli/groups/crypto/presenter.py new file mode 100644 index 0000000..cf5f542 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/crypto/presenter.py @@ -0,0 +1,79 @@ +class CryptoCommandPresenter: + """ + 传输加密命令文本渲染器。 + + 该渲染器负责将 `crypto` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_crypto_keygen_text(self, payload: dict[str, object]) -> str: + """ + 将密钥生成结果渲染为文本摘要。 + + :param payload: 密钥生成结果字典 + :return: 文本摘要 + """ + env_patch = payload.get('envPatch') + env_patch_keys = sorted(env_patch.keys()) if isinstance(env_patch, dict) else [] + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'kid: {payload.get("kid", "-")}', + f'key_size: {payload.get("keySize", "-")}', + ] + lines.extend(self._build_multiline_block('public_key', payload.get('publicKey'))) + lines.extend(self._build_multiline_block('private_key', payload.get('privateKey'))) + if env_patch_keys: + lines.append('env_patch_keys:') + lines.extend(f' - {key}' for key in env_patch_keys) + else: + lines.append('env_patch_keys: none') + return '\n'.join(lines) + + def build_export_public_text(self, payload: dict[str, object]) -> str: + """ + 将公钥导出结果渲染为文本摘要。 + + :param payload: 公钥导出结果字典 + :return: 文本摘要 + """ + public_key_payload = payload.get('publicKey') + if not isinstance(public_key_payload, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'public_key: none', + ] + ) + + supported_kids = public_key_payload.get('supportedKids') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'kid: {public_key_payload.get("kid", "-")}', + f'alg: {public_key_payload.get("alg", "-")}', + f'envelope_version: {public_key_payload.get("envelopeVersion", "-")}', + f'expire_at: {public_key_payload.get("expireAt", "-")}', + ] + if isinstance(supported_kids, list) and supported_kids: + lines.append('supported_kids:') + lines.extend(f' - {kid}' for kid in supported_kids) + else: + lines.append('supported_kids: none') + lines.extend(self._build_multiline_block('public_key_pem', public_key_payload.get('publicKey'))) + return '\n'.join(lines) + + @staticmethod + def _build_multiline_block(title: str, value: object) -> list[str]: + """ + 将多行文本构建为带标题的块级文本。 + + :param title: 块标题 + :param value: 原始文本值 + :return: 文本行列表 + """ + text = '' if value is None else str(value).strip() + if not text: + return [f'{title}: -'] + return [f'{title}:', ' |', *[f' {line}' for line in text.splitlines()]] diff --git a/shierfeng-fastapi-backend/cli/groups/db/__init__.py b/shierfeng-fastapi-backend/cli/groups/db/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/db/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/db/command.py b/shierfeng-fastapi-backend/cli/groups/db/command.py new file mode 100644 index 0000000..80063de --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/db/command.py @@ -0,0 +1,191 @@ +from typing import Annotated + +import typer + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption + +from .controller import DbCommandController + +app = typer.Typer( + help='数据库相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_DB_COMMAND_CONTROLLER = DbCommandController() + + +@app.command('check', help='检查数据库连接状态') +def check( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 检查数据库连接状态。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _DB_COMMAND_CONTROLLER.check(env, output) + + +@app.command('current', help='查看当前数据库迁移版本') +def current( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看数据库当前迁移版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _DB_COMMAND_CONTROLLER.current(env, output) + + +@app.command('upgrade', help='执行数据库升级') +def upgrade( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + revision: Annotated[ + str, + typer.Option( + '--revision', + help='目标迁移版本', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_alembic_revisions, + ), + ] = 'head', +) -> None: + """ + 执行数据库升级。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param revision: 目标迁移版本 + :return: None + """ + _DB_COMMAND_CONTROLLER.upgrade(env, output, allow_prod, yes, dry_run, revision=revision) + + +@app.command('init', help='初始化数据库到最新迁移版本') +def init( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, +) -> None: + """ + 初始化数据库到最新迁移版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :return: None + """ + _DB_COMMAND_CONTROLLER.init(env, output, allow_prod, yes, dry_run) + + +@app.command('downgrade', help='执行数据库回退') +def downgrade( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + revision: Annotated[ + str, + typer.Option( + '--revision', + help='目标回退版本', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_alembic_revisions, + ), + ] = '-1', +) -> None: + """ + 执行数据库回退。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param revision: 目标回退版本 + :return: None + """ + _DB_COMMAND_CONTROLLER.downgrade(env, output, allow_prod, yes, dry_run, revision=revision) + + +@app.command('revision', help='创建新的数据库迁移版本文件') +def revision( + message: Annotated[str, typer.Option('--message', '-m', help='迁移说明')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + autogenerate: bool = typer.Option(False, '--autogenerate/--no-autogenerate', help='是否自动生成迁移内容'), +) -> None: + """ + 创建新的数据库迁移版本文件。 + + :param message: 迁移说明 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param autogenerate: 是否自动生成迁移内容 + :return: None + """ + _DB_COMMAND_CONTROLLER.revision( + message, + env, + output, + allow_prod, + yes, + dry_run, + autogenerate=autogenerate, + ) + + +@app.command('heads', help='查看当前代码仓库中的 Alembic heads') +def heads( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看当前代码仓库中的 Alembic heads。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _DB_COMMAND_CONTROLLER.heads(env, output) + + +@app.command('history', help='查看当前代码仓库中的 Alembic 历史版本') +def history( + env: EnvOption = 'dev', + output: OutputOption = 'text', + limit: Annotated[int, typer.Option('--limit', min=1, help='输出的最大历史记录数量')] = 20, +) -> None: + """ + 查看当前代码仓库中的 Alembic 历史版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param limit: 输出的最大历史记录数量 + :return: None + """ + _DB_COMMAND_CONTROLLER.history(env, output, limit=limit) diff --git a/shierfeng-fastapi-backend/cli/groups/db/controller.py b/shierfeng-fastapi-backend/cli/groups/db/controller.py new file mode 100644 index 0000000..7e4bb3c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/db/controller.py @@ -0,0 +1,237 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService + +from .presenter import DbCommandPresenter + + +class DbCommandController: + """ + 数据库命令控制器。 + + 该控制器负责组织 `db` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 数据库命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: DbCommandPresenter | None = None, + runtime_service: DatabaseRuntimeService | None = None, + ) -> None: + """ + 初始化数据库命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 数据库命令文本渲染器 + :param runtime_service: 数据库运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or DbCommandPresenter() + self.runtime_service = runtime_service or DATABASE_RUNTIME + + def check(self, env: str, output: str) -> None: + """ + 检查数据库连接状态。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload( + ctx, self.execution_service.run_async(self.runtime_service.ping_database()) + ) + + def current(self, env: str, output: str) -> None: + """ + 查看数据库当前迁移版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.get_current_revision() + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_current_revision_text, + text_condition=lambda result_data: bool(result_data.get('ok', False)), + ) + + def upgrade( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + revision: str, + ) -> None: + """ + 执行数据库升级。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param revision: 目标迁移版本 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='db upgrade', + ) + self.execution_service.complete_payload(ctx, self.runtime_service.upgrade_database(revision, dry_run=dry_run)) + + def init( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 初始化数据库到最新迁移版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='db init', + ) + self.execution_service.complete_payload(ctx, self.runtime_service.init_database(dry_run=dry_run)) + + def downgrade( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + revision: str, + ) -> None: + """ + 执行数据库回退。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param revision: 目标回退版本 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='db downgrade', + ) + self.execution_service.complete_payload(ctx, self.runtime_service.downgrade_database(revision, dry_run=dry_run)) + + def revision( + self, + message: str, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + autogenerate: bool, + ) -> None: + """ + 创建新的数据库迁移版本文件。 + + :param message: 迁移说明 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param autogenerate: 是否自动生成迁移内容 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='db revision', + ) + self.execution_service.complete_payload( + ctx, + self.runtime_service.create_revision(message, autogenerate=autogenerate, dry_run=dry_run), + ) + + def heads(self, env: str, output: str) -> None: + """ + 查看当前代码仓库中的 Alembic heads。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.get_alembic_heads() + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_alembic_revisions_text, + text_condition=lambda result_data: 'error' not in result_data, + ) + + def history(self, env: str, output: str, *, limit: int) -> None: + """ + 查看当前代码仓库中的 Alembic 历史版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param limit: 输出的最大历史记录数量 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.get_alembic_history(limit=limit) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_alembic_revisions_text, + text_condition=lambda result_data: 'error' not in result_data, + ) diff --git a/shierfeng-fastapi-backend/cli/groups/db/presenter.py b/shierfeng-fastapi-backend/cli/groups/db/presenter.py new file mode 100644 index 0000000..72d04b1 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/db/presenter.py @@ -0,0 +1,71 @@ +class DbCommandPresenter: + """ + 数据库命令文本渲染器。 + + 该渲染器负责将 `db` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + @staticmethod + def build_current_revision_text(payload: dict[str, object]) -> str: + """ + 将数据库当前迁移版本结果渲染为文本摘要。 + + :param payload: 当前迁移版本结果字典 + :return: 文本摘要 + """ + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'current_revision: {payload.get("currentRevision", "-")}', + ] + ) + + def build_alembic_revisions_text(self, payload: dict[str, object]) -> str: + """ + 将 Alembic 修订版本结果渲染为文本摘要。 + + :param payload: 修订版本结果字典 + :return: 文本摘要 + """ + items = payload.get('items') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'message: {payload.get("message", "-")}', + f'count: {payload.get("count", 0)}', + ] + if 'totalCount' in payload: + lines.append(f'total_count: {payload.get("totalCount", 0)}') + if 'limit' in payload: + lines.append(f'limit: {payload.get("limit", 0)}') + if not isinstance(items, list) or not items: + lines.append('items: none') + return '\n'.join(lines) + + lines.append('items:') + for item in items: + if isinstance(item, dict): + lines.extend([f' {line}' for line in self._build_alembic_revision_item_lines(item)]) + return '\n'.join(lines) + + @staticmethod + def _build_alembic_revision_item_lines(revision_item: dict[str, object]) -> list[str]: + """ + 构建单个 Alembic 修订版本的文本行。 + + :param revision_item: 修订版本结果字典 + :return: 文本行列表 + """ + down_revisions = revision_item.get('downRevisions') + branch_labels = revision_item.get('branchLabels') + depends_on = revision_item.get('dependsOn') + return [ + f'- revision: {revision_item.get("revision", "-")}', + f' down_revisions: {",".join(down_revisions) if isinstance(down_revisions, list) and down_revisions else "-"}', + f' branch_labels: {",".join(branch_labels) if isinstance(branch_labels, list) and branch_labels else "-"}', + f' depends_on: {",".join(depends_on) if isinstance(depends_on, list) and depends_on else "-"}', + f' doc: {revision_item.get("doc", "") or "-"}', + f' path: {revision_item.get("path", "-")}', + ] diff --git a/shierfeng-fastapi-backend/cli/groups/dev/__init__.py b/shierfeng-fastapi-backend/cli/groups/dev/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/dev/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/dev/command.py b/shierfeng-fastapi-backend/cli/groups/dev/command.py new file mode 100644 index 0000000..0a0f905 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/dev/command.py @@ -0,0 +1,74 @@ +from typing import Annotated + +import typer + +from cli.context import EnvOption, OutputOption + +from .controller import DevCommandController + +app = typer.Typer( + help='开发相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_DEV_COMMAND_CONTROLLER = DevCommandController() + + +@app.command('lint', help='执行 Ruff 格式化与静态检查') +def lint( + targets: Annotated[list[str] | None, typer.Argument(help='待检查路径,可传多个')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + check_only: bool = typer.Option(False, '--check-only', help='仅检查,不写回格式化结果'), + fix: bool = typer.Option(False, '--fix', help='执行 ruff check --fix'), + unsafe_fixes: bool = typer.Option(False, '--unsafe-fixes', help='允许 Ruff 不安全修复'), +) -> None: + """ + 执行 Ruff 格式化与静态检查。 + + :param targets: 待检查路径列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param check_only: 是否仅检查不写回 + :param fix: 是否自动修复可修复问题 + :param unsafe_fixes: 是否允许不安全修复 + :return: None + """ + _DEV_COMMAND_CONTROLLER.lint( + targets, + env, + output, + check_only=check_only, + fix=fix, + unsafe_fixes=unsafe_fixes, + ) + + +@app.command('test', help='执行项目测试') +def test( + targets: Annotated[list[str] | None, typer.Argument(help='待执行的测试路径,可传多个')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + keyword: str = typer.Option('', '--keyword', '-k', help='pytest -k 过滤表达式'), + maxfail: int = typer.Option(0, '--maxfail', min=0, help='最大失败数,0 表示不限制'), + quiet: bool = typer.Option(False, '--quiet', '-q', help='启用简洁输出'), +) -> None: + """ + 执行项目测试。 + + :param targets: 测试目标路径列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param keyword: pytest 关键字过滤表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :return: None + """ + _DEV_COMMAND_CONTROLLER.test( + targets, + env, + output, + keyword=keyword, + maxfail=maxfail, + quiet=quiet, + ) diff --git a/shierfeng-fastapi-backend/cli/groups/dev/controller.py b/shierfeng-fastapi-backend/cli/groups/dev/controller.py new file mode 100644 index 0000000..ccd8682 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/dev/controller.py @@ -0,0 +1,103 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.dev import DEVELOPMENT_RUNTIME, DevelopmentRuntimeService + +from .presenter import DevCommandPresenter + + +class DevCommandController: + """ + 开发命令控制器。 + + 该控制器负责组织 `dev` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 开发命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: DevCommandPresenter | None = None, + runtime_service: DevelopmentRuntimeService | None = None, + ) -> None: + """ + 初始化开发命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 开发命令文本渲染器 + :param runtime_service: 开发运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or DevCommandPresenter() + self.runtime_service = runtime_service or DEVELOPMENT_RUNTIME + + def lint( + self, + targets: list[str] | None, + env: str, + output: str, + *, + check_only: bool, + fix: bool, + unsafe_fixes: bool, + ) -> None: + """ + 执行 Ruff 格式化与静态检查。 + + :param targets: 待检查路径列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param check_only: 是否仅检查不写回 + :param fix: 是否自动修复可修复问题 + :param unsafe_fixes: 是否允许不安全修复 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.run_lint(targets, check_only=check_only, fix=fix, unsafe_fixes=unsafe_fixes) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_dev_lint_text, + ) + + def test( + self, + targets: list[str] | None, + env: str, + output: str, + *, + keyword: str, + maxfail: int, + quiet: bool, + ) -> None: + """ + 执行项目测试。 + + :param targets: 测试目标路径列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param keyword: pytest 关键字过滤表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.runtime_service.run_tests(targets, keyword=keyword, maxfail=maxfail, quiet=quiet) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_dev_test_text, + ) diff --git a/shierfeng-fastapi-backend/cli/groups/dev/presenter.py b/shierfeng-fastapi-backend/cli/groups/dev/presenter.py new file mode 100644 index 0000000..b5fc144 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/dev/presenter.py @@ -0,0 +1,96 @@ +class DevCommandPresenter: + """ + 开发命令文本渲染器。 + + 该渲染器负责将 `dev` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_dev_lint_text(self, payload: dict[str, object]) -> str: + """ + 将 lint 执行结果渲染为文本摘要。 + + :param payload: lint 执行结果字典 + :return: 文本摘要 + """ + targets = payload.get('targets') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'check_only: {str(payload.get("checkOnly", False)).lower()}', + f'fix: {str(payload.get("fix", False)).lower()}', + f'unsafe_fixes: {str(payload.get("unsafeFixes", False)).lower()}', + ] + if isinstance(targets, list) and targets: + lines.append('targets:') + lines.extend(f' - {target}' for target in targets) + else: + lines.append('targets: none') + lines.extend(self._build_command_result_section('format', payload.get('format'))) + lines.extend(self._build_command_result_section('check', payload.get('check'))) + return '\n'.join(lines) + + def build_dev_test_text(self, payload: dict[str, object]) -> str: + """ + 将测试执行结果渲染为文本摘要。 + + :param payload: 测试执行结果字典 + :return: 文本摘要 + """ + targets = payload.get('targets') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'keyword: {payload.get("keyword", "") or "-"}', + f'maxfail: {payload.get("maxfail", 0)}', + f'quiet: {str(payload.get("quiet", False)).lower()}', + ] + if isinstance(targets, list) and targets: + lines.append('targets:') + lines.extend(f' - {target}' for target in targets) + else: + lines.append('targets: none') + lines.extend(self._build_command_result_section('test', payload.get('test'))) + return '\n'.join(lines) + + @staticmethod + def _build_command_result_section(title: str, payload: dict[str, object] | None) -> list[str]: + """ + 构建单个子命令执行结果段落。 + + :param title: 段落标题 + :param payload: 子命令执行结果 + :return: 文本行列表 + """ + if not isinstance(payload, dict): + return [f'{title}: none'] + + lines = [ + f'{title}:', + f' ok: {str(payload.get("ok", False)).lower()}', + f' return_code: {payload.get("returnCode", "-")}', + f' command: {DevCommandPresenter._format_command_text(payload.get("command"))}', + ] + stdout = payload.get('stdout') + stderr = payload.get('stderr') + if stdout: + lines.append(' stdout:') + lines.append(' |') + lines.extend(f' {line}' for line in str(stdout).splitlines()) + if stderr: + lines.append(' stderr:') + lines.append(' |') + lines.extend(f' {line}' for line in str(stderr).splitlines()) + return lines + + @staticmethod + def _format_command_text(command: object) -> str: + """ + 将命令参数列表格式化为可读文本。 + + :param command: 命令参数列表 + :return: 格式化后的命令文本 + """ + if not isinstance(command, list): + return '-' + return ' '.join(str(item) for item in command) or '-' diff --git a/shierfeng-fastapi-backend/cli/groups/gen/__init__.py b/shierfeng-fastapi-backend/cli/groups/gen/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/gen/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/gen/command.py b/shierfeng-fastapi-backend/cli/groups/gen/command.py new file mode 100644 index 0000000..e4aac33 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/gen/command.py @@ -0,0 +1,273 @@ +from typing import Annotated, Literal + +import typer + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption + +from .controller import GenCommandController + +app = typer.Typer( + help='代码生成相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_GEN_COMMAND_CONTROLLER = GenCommandController() + + +@app.command('import-table', help='导入数据库表到代码生成业务表') +def import_table( + table_names: Annotated[ + list[str], + typer.Argument( + help='待导入的数据库表名,可传多个', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_db_table_names, + ), + ], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, +) -> None: + """ + 导入数据库表到代码生成业务表。 + + :param table_names: 待导入表名列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :return: None + """ + _GEN_COMMAND_CONTROLLER.import_table( + table_names, + env, + output, + allow_prod, + yes, + dry_run, + ) + + +@app.command('list', help='查看代码生成业务表列表') +def list_command( + env: EnvOption = 'dev', + output: OutputOption = 'text', + table_name: str = typer.Option( + '', + '--table-name', + help='按表名称过滤', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_table_names, + ), + table_comment: str = typer.Option('', '--table-comment', help='按表描述过滤'), + paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'), + page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'), + page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'), +) -> None: + """ + 查看代码生成业务表列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param table_name: 表名称过滤条件 + :param table_comment: 表描述过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + _GEN_COMMAND_CONTROLLER.list_tables( + env, + output, + table_name=table_name, + table_comment=table_comment, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + + +@app.command('db-list', help='查看数据库中可导入的物理表列表') +def db_list( + env: EnvOption = 'dev', + output: OutputOption = 'text', + table_name: str = typer.Option( + '', + '--table-name', + help='按表名称过滤', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_db_table_names, + ), + table_comment: str = typer.Option('', '--table-comment', help='按表描述过滤'), + paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'), + page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'), + page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'), +) -> None: + """ + 查看数据库中可导入的物理表列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param table_name: 表名称过滤条件 + :param table_comment: 表描述过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + _GEN_COMMAND_CONTROLLER.list_db_tables( + env, + output, + table_name=table_name, + table_comment=table_comment, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + + +@app.command('detail', help='查看单个代码生成业务表详情') +def detail( + table_id: Annotated[int, typer.Argument(help='业务表 ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看单个代码生成业务表详情。 + + :param table_id: 业务表 ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _GEN_COMMAND_CONTROLLER.show_detail(table_id, env, output) + + +@app.command('create-table', help='根据建表 SQL 创建表结构并导入代码生成业务表') +def create_table( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + sql: str = typer.Option('', '--sql', help='直接传入建表 SQL 文本'), + sql_file: str = typer.Option( + '', + '--sql-file', + help='从文件读取建表 SQL', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_sql_files, + ), +) -> None: + """ + 根据建表 SQL 创建表结构并导入代码生成业务表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param sql: 直接传入的 SQL 文本 + :param sql_file: SQL 文件路径 + :return: None + """ + _GEN_COMMAND_CONTROLLER.create_table( + env, + output, + allow_prod, + yes, + dry_run, + sql=sql, + sql_file=sql_file, + ) + + +@app.command('preview', help='查看指定业务表的代码生成结果预览') +def preview( + table_id: Annotated[int, typer.Argument(help='业务表 ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 预览指定业务表的代码生成结果。 + + :param table_id: 业务表 ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _GEN_COMMAND_CONTROLLER.preview(table_id, env, output) + + +@app.command('export', help='导出代码生成结果') +def export( + table_names: Annotated[ + list[str], + typer.Argument( + help='待导出的业务表名,可传多个', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_table_names, + ), + ], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + mode: Annotated[Literal['zip', 'local'], typer.Option('--mode', help='导出模式')] = 'zip', + output_file: str = typer.Option( + '', + '--output-file', + help='zip 导出目标文件路径', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_output_paths, + ), +) -> None: + """ + 导出代码生成结果。 + + :param table_names: 业务表名称列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param mode: 导出模式 + :param output_file: zip 导出目标文件路径 + :return: None + """ + _GEN_COMMAND_CONTROLLER.export( + table_names, + env, + output, + allow_prod, + yes, + dry_run, + mode=mode, + output_file=output_file, + ) + + +@app.command('sync-db', help='同步指定业务表的数据库表结构') +def sync_db( + table_name: Annotated[ + str, + typer.Argument( + help='业务表名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_table_names, + ), + ], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 同步指定业务表的数据库表结构。 + + :param table_name: 业务表名称 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _GEN_COMMAND_CONTROLLER.sync_db(table_name, env, output, allow_prod, yes) diff --git a/shierfeng-fastapi-backend/cli/groups/gen/controller.py b/shierfeng-fastapi-backend/cli/groups/gen/controller.py new file mode 100644 index 0000000..84fb446 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/gen/controller.py @@ -0,0 +1,304 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.gen import GEN_RUNTIME, GenRuntimeService + +from .presenter import GenCommandPresenter + + +class GenCommandController: + """ + 代码生成命令控制器。 + + 该控制器负责组织 `gen` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 代码生成命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: GenCommandPresenter | None = None, + runtime_service: GenRuntimeService | None = None, + ) -> None: + """ + 初始化代码生成命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 代码生成命令文本渲染器 + :param runtime_service: 代码生成运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or GenCommandPresenter() + self.runtime_service = runtime_service or GEN_RUNTIME + + def import_table( + self, + table_names: list[str], + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 导入数据库表到代码生成业务表。 + + :param table_names: 待导入表名列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='gen import-table', + ) + payload = self.execution_service.run_async(self.runtime_service.import_tables(table_names, dry_run=dry_run)) + payload['env'] = ctx.env + self.execution_service.complete_payload(ctx, payload) + + def list_tables( + self, + env: str, + output: str, + *, + table_name: str, + table_comment: str, + paged: bool, + page_num: int, + page_size: int, + ) -> None: + """ + 查看代码生成业务表列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param table_name: 表名称过滤条件 + :param table_comment: 表描述过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async( + self.runtime_service.list_gen_tables( + table_name=table_name, + table_comment=table_comment, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + ) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=lambda data: self.presenter.build_gen_table_list_text(data, db_mode=False), + text_condition=lambda data: data.get('ok', False), + ) + + def list_db_tables( + self, + env: str, + output: str, + *, + table_name: str, + table_comment: str, + paged: bool, + page_num: int, + page_size: int, + ) -> None: + """ + 查看数据库中可导入的物理表列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param table_name: 表名称过滤条件 + :param table_comment: 表描述过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async( + self.runtime_service.list_gen_db_tables( + table_name=table_name, + table_comment=table_comment, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + ) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=lambda data: self.presenter.build_gen_table_list_text(data, db_mode=True), + text_condition=lambda data: data.get('ok', False), + ) + + def show_detail(self, table_id: int, env: str, output: str) -> None: + """ + 查看单个代码生成业务表详情。 + + :param table_id: 业务表 ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.get_gen_table_detail(table_id)) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_gen_detail_text, + text_condition=lambda data: 'error' not in data, + ) + + def create_table( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + sql: str, + sql_file: str, + ) -> None: + """ + 根据建表 SQL 创建表结构并导入代码生成业务表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param sql: 直接传入的 SQL 文本 + :param sql_file: SQL 文件路径 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='gen create-table', + ) + payload = self.execution_service.run_async(self.runtime_service.create_tables(sql, sql_file, dry_run=dry_run)) + payload['env'] = ctx.env + self.execution_service.complete_payload(ctx, payload) + + def preview(self, table_id: int, env: str, output: str) -> None: + """ + 预览指定业务表的代码生成结果。 + + :param table_id: 业务表 ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.runtime_service.preview_code(table_id)) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_gen_preview_text, + text_condition=lambda data: data.get('ok', False), + ) + + def export( + self, + table_names: list[str], + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + mode: str, + output_file: str, + ) -> None: + """ + 导出代码生成结果。 + + :param table_names: 业务表名称列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否演练执行 + :param mode: 导出模式 + :param output_file: zip 导出目标文件路径 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='gen export', + ) + payload = self.execution_service.run_async( + self.runtime_service.export_code(table_names, mode=mode, output_file=output_file, dry_run=dry_run) + ) + payload['env'] = ctx.env + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_gen_export_text, + text_condition=lambda data: data.get('ok', False), + ) + + def sync_db( + self, + table_name: str, + env: str, + output: str, + allow_prod: bool, + yes: bool, + ) -> None: + """ + 同步指定业务表的数据库表结构。 + + :param table_name: 业务表名称 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + False, + command_name='gen sync-db', + ) + payload = self.execution_service.run_async(self.runtime_service.sync_gen_table_from_db(table_name)) + payload['env'] = ctx.env + self.execution_service.complete_payload(ctx, payload) diff --git a/shierfeng-fastapi-backend/cli/groups/gen/presenter.py b/shierfeng-fastapi-backend/cli/groups/gen/presenter.py new file mode 100644 index 0000000..92844bf --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/gen/presenter.py @@ -0,0 +1,231 @@ +from cli.utils import SHELL_TEXT_FORMATTER + + +class GenCommandPresenter: + """ + 代码生成命令文本渲染器。 + + 该渲染器负责将 `gen` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_gen_preview_text(self, payload: dict[str, object]) -> str: + """ + 将代码预览结果渲染为文本摘要。 + + :param payload: 代码预览结果字典 + :return: 文本摘要 + """ + preview_payload = payload.get('preview') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'table_id: {payload.get("tableId", "-")}', + f'template_count: {payload.get("templateCount", 0)}', + ] + if not isinstance(preview_payload, dict) or not preview_payload: + lines.append('templates: none') + return '\n'.join(lines) + + lines.append('templates:') + for template_name, template_content in preview_payload.items(): + lines.extend([f' {line}' for line in self._build_text_block(str(template_name), template_content)]) + return '\n'.join(lines) + + def build_gen_export_text(self, payload: dict[str, object]) -> str: + """ + 将代码导出结果渲染为文本摘要。 + + :param payload: 代码导出结果字典 + :return: 文本摘要 + """ + table_names = payload.get('tableNames') + results = payload.get('results') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + f'mode: {payload.get("mode", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'message: {payload.get("message", "-")}', + ] + if isinstance(table_names, list) and table_names: + lines.append('table_names:') + lines.extend(f' - {table_name}' for table_name in table_names) + else: + lines.append('table_names: none') + + if payload.get('outputFile'): + lines.append(f'output_file: {payload.get("outputFile")}') + if payload.get('genPath'): + lines.append(f'gen_path: {payload.get("genPath")}') + if payload.get('size') is not None: + lines.append(f'size: {payload.get("size")}') + + if isinstance(results, list) and results: + lines.append('results:') + lines.extend( + f' - {item.get("tableName", "-")}: {str(item.get("ok", False)).lower()} | {item.get("message", "-")}' + for item in results + if isinstance(item, dict) + ) + return '\n'.join(lines) + + def build_gen_table_list_text(self, payload: dict[str, object], *, db_mode: bool = False) -> str: + """ + 将代码生成表列表或数据库表列表渲染为文本摘要。 + + :param payload: 列表结果字典 + :param db_mode: 是否为数据库物理表列表模式 + :return: 文本摘要 + """ + lines = [f'ok: {str(payload.get("ok", False)).lower()}'] + filters = payload.get('filters') + if isinstance(filters, dict): + lines.extend(self._build_gen_filter_lines(filters)) + + page_payload = payload.get('page') + if isinstance(page_payload, dict): + rows = page_payload.get('rows', []) + lines.append( + 'page: ' + f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} ' + f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})' + ) + lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}') + if not isinstance(rows, list) or not rows: + lines.append('items: none') + return '\n'.join(lines) + lines.append('items:') + for row in rows: + if isinstance(row, dict): + item_lines = ( + self._build_gen_db_table_item_lines(row) if db_mode else self._build_gen_table_item_lines(row) + ) + lines.extend([f' {item}' for item in item_lines]) + return '\n'.join(lines) + + items = payload.get('items') + lines.append(f'count: {payload.get("count", 0)}') + if not isinstance(items, list) or not items: + lines.append('items: none') + return '\n'.join(lines) + + lines.append('items:') + for item in items: + if isinstance(item, dict): + item_lines = ( + self._build_gen_db_table_item_lines(item) if db_mode else self._build_gen_table_item_lines(item) + ) + lines.extend([f' {line}' for line in item_lines]) + return '\n'.join(lines) + + def build_gen_detail_text(self, payload: dict[str, object]) -> str: + """ + 将代码生成业务表详情渲染为文本摘要。 + + :param payload: 详情结果字典 + :return: 文本摘要 + """ + detail = payload.get('detail') + if not isinstance(detail, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'table_id: {payload.get("tableId", "-")}', + 'detail: none', + ] + ) + + info = detail.get('info') + if not isinstance(info, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'table_id: {payload.get("tableId", "-")}', + 'info: none', + ] + ) + + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'table_id: {payload.get("tableId", "-")}', + f'table_name: {payload.get("tableName", "-")}', + f'column_count: {payload.get("columnCount", 0)}', + f'table_count: {payload.get("tableCount", 0)}', + f'table_comment: {SHELL_TEXT_FORMATTER.truncate_text(info.get("tableComment", ""), 80) or "-"}', + f'class_name: {info.get("className", "-")}', + f'tpl_category: {info.get("tplCategory", "-")}', + f'tpl_web_type: {info.get("tplWebType", "-")}', + f'package_name: {SHELL_TEXT_FORMATTER.truncate_text(info.get("packageName", ""), 80) or "-"}', + f'module_name: {info.get("moduleName", "-")}', + f'business_name: {info.get("businessName", "-")}', + f'function_name: {info.get("functionName", "-")}', + f'function_author: {info.get("functionAuthor", "-")}', + f'gen_type: {info.get("genType", "-")}', + f'gen_path: {SHELL_TEXT_FORMATTER.truncate_text(info.get("genPath", ""), 120) or "-"}', + f'remark: {SHELL_TEXT_FORMATTER.truncate_text(info.get("remark", ""), 160) or "-"}', + ] + ) + + @staticmethod + def _build_text_block(title: str, value: object) -> list[str]: + """ + 将多行文本构建为带标题的块级文本。 + + :param title: 块标题 + :param value: 原始文本值 + :return: 文本行列表 + """ + text = '' if value is None else str(value) + if not text.strip(): + return [f'{title}: -'] + return [f'{title}:', ' |', *[f' {line}' for line in text.splitlines()]] + + @staticmethod + def _build_gen_filter_lines(filters: dict[str, object]) -> list[str]: + """ + 构建代码生成表过滤条件文本行。 + + :param filters: 过滤条件字典 + :return: 过滤条件文本行列表 + """ + active_filters = [] + for key, value in filters.items(): + if value in (None, '', False): + continue + active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}') + if not active_filters: + return ['filters: none'] + return ['filters:', *[f' - {item}' for item in active_filters]] + + @staticmethod + def _build_gen_table_item_lines(table_item: dict[str, object]) -> list[str]: + """ + 构建单条代码生成表记录的文本行。 + + :param table_item: 代码生成表记录 + :return: 文本行列表 + """ + return [ + f'- [{table_item.get("tableId", "-")}] {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableName", ""), 40) or "-"}', + f' comment: {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableComment", ""), 60) or "-"}', + f' class_name: {table_item.get("className", "-")}', + f' tpl_category: {table_item.get("tplCategory", "-")} | module_name: {table_item.get("moduleName", "-")}', + f' business_name: {table_item.get("businessName", "-")} | function_name: {table_item.get("functionName", "-")}', + ] + + @staticmethod + def _build_gen_db_table_item_lines(table_item: dict[str, object]) -> list[str]: + """ + 构建单条数据库物理表记录的文本行。 + + :param table_item: 数据库物理表记录 + :return: 文本行列表 + """ + return [ + f'- {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableName", ""), 48) or "-"}', + f' comment: {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableComment", ""), 80) or "-"}', + f' create_time: {table_item.get("createTime", "-")}', + f' update_time: {table_item.get("updateTime", "-")}', + ] diff --git a/shierfeng-fastapi-backend/cli/groups/job/__init__.py b/shierfeng-fastapi-backend/cli/groups/job/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/job/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/job/command.py b/shierfeng-fastapi-backend/cli/groups/job/command.py new file mode 100644 index 0000000..af62ea7 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/job/command.py @@ -0,0 +1,216 @@ +import typer + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY +from cli.context import AllowProdOption, EnvOption, OutputOption, YesOption + +from .controller import JobCommandController + +app = typer.Typer( + help='定时任务相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_JOB_COMMAND_CONTROLLER = JobCommandController() + + +@app.command('list', help='查看定时任务列表') +def list_command( + env: EnvOption = 'dev', + output: OutputOption = 'text', + job_name: str = typer.Option( + '', + '--job-name', + help='按任务名称过滤', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_names, + ), + job_group: str = typer.Option('', '--job-group', help='按任务组过滤'), + status: str | None = typer.Option(None, '--status', help='按任务状态过滤,0正常 1暂停'), + paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'), + page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'), + page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'), +) -> None: + """ + 查看定时任务列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param job_name: 任务名称过滤条件 + :param job_group: 任务组过滤条件 + :param status: 状态过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + _JOB_COMMAND_CONTROLLER.list( + env, + output, + job_name=job_name, + job_group=job_group, + status=status, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + + +@app.command('run-once', help='执行一次定时任务') +def run_once( + job_id: int = typer.Argument( + ..., + help='任务ID', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 执行一次定时任务。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _JOB_COMMAND_CONTROLLER.run_once(job_id, env, output, allow_prod, yes) + + +@app.command('detail', help='查看单个定时任务详情') +def detail( + job_id: int = typer.Argument( + ..., + help='任务ID', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 查看单个定时任务详情。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _JOB_COMMAND_CONTROLLER.detail(job_id, env, output) + + +@app.command('logs', help='查看定时任务执行日志列表') +def logs( + env: EnvOption = 'dev', + output: OutputOption = 'text', + job_name: str = typer.Option( + '', + '--job-name', + help='按任务名称过滤', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_names, + ), + job_group: str = typer.Option('', '--job-group', help='按任务组过滤'), + status: str | None = typer.Option(None, '--status', help='按执行状态过滤,0成功 1失败'), + begin_date: str = typer.Option('', '--begin-date', help='按创建时间开始日期过滤,格式 YYYY-MM-DD'), + end_date: str = typer.Option('', '--end-date', help='按创建时间结束日期过滤,格式 YYYY-MM-DD'), + paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'), + page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'), + page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'), +) -> None: + """ + 查看定时任务执行日志列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param job_name: 任务名称过滤条件 + :param job_group: 任务组过滤条件 + :param status: 执行状态过滤条件 + :param begin_date: 查询开始日期 + :param end_date: 查询结束日期 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + _JOB_COMMAND_CONTROLLER.logs( + env, + output, + job_name=job_name, + job_group=job_group, + status=status, + begin_date=begin_date, + end_date=end_date, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + + +@app.command('pause', help='暂停定时任务') +def pause( + job_id: int = typer.Argument( + ..., + help='任务ID', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 暂停定时任务。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _JOB_COMMAND_CONTROLLER.pause(job_id, env, output, allow_prod, yes) + + +@app.command('resume', help='恢复定时任务') +def resume( + job_id: int = typer.Argument( + ..., + help='任务ID', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids, + ), + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 恢复定时任务。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _JOB_COMMAND_CONTROLLER.resume(job_id, env, output, allow_prod, yes) + + +@app.command('sync', help='同步调度任务配置') +def sync( + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, +) -> None: + """ + 同步调度任务配置。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + _JOB_COMMAND_CONTROLLER.sync(env, output, allow_prod, yes) diff --git a/shierfeng-fastapi-backend/cli/groups/job/controller.py b/shierfeng-fastapi-backend/cli/groups/job/controller.py new file mode 100644 index 0000000..92bc252 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/job/controller.py @@ -0,0 +1,221 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.runtime.job import JOB_RUNTIME, JobRuntimeService +from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService + +from .presenter import JobCommandPresenter + + +class JobCommandController: + """ + 定时任务命令控制器。 + + 该控制器负责组织 `job` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 定时任务命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: JobCommandPresenter | None = None, + operations_runtime: OperationsRuntimeService | None = None, + job_runtime: JobRuntimeService | None = None, + ) -> None: + """ + 初始化定时任务命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 定时任务命令文本渲染器 + :param operations_runtime: 运维运行时服务 + :param job_runtime: 定时任务运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or JobCommandPresenter() + self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME + self.job_runtime = job_runtime or JOB_RUNTIME + + def list( + self, + env: str, + output: str, + *, + job_name: str, + job_group: str, + status: str | None, + paged: bool, + page_num: int, + page_size: int, + ) -> None: + """ + 查看定时任务列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param job_name: 任务名称过滤条件 + :param job_group: 任务组过滤条件 + :param status: 状态过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async( + self.job_runtime.list_jobs( + job_name=job_name, + job_group=job_group, + status=status, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + ) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_job_list_text, + text_condition=lambda result_data: bool(result_data.get('ok', False)), + ) + + def run_once(self, job_id: int, env: str, output: str, allow_prod: bool, yes: bool) -> None: + """ + 执行一次定时任务。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job run-once') + self.execution_service.complete_payload( + ctx, self.execution_service.run_async(self.job_runtime.run_job_once(job_id)) + ) + + def detail(self, job_id: int, env: str, output: str) -> None: + """ + 查看单个定时任务详情。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.job_runtime.get_job_detail(job_id)) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_job_detail_text, + text_condition=lambda result_data: 'error' not in result_data, + ) + + def logs( + self, + env: str, + output: str, + *, + job_name: str, + job_group: str, + status: str | None, + begin_date: str, + end_date: str, + paged: bool, + page_num: int, + page_size: int, + ) -> None: + """ + 查看定时任务执行日志列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param job_name: 任务名称过滤条件 + :param job_group: 任务组过滤条件 + :param status: 执行状态过滤条件 + :param begin_date: 查询开始日期 + :param end_date: 查询结束日期 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async( + self.job_runtime.list_job_logs( + job_name=job_name, + job_group=job_group, + status=status, + begin_date=begin_date, + end_date=end_date, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + ) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_job_logs_text, + text_condition=lambda result_data: bool(result_data.get('ok', False)), + ) + + def pause(self, job_id: int, env: str, output: str, allow_prod: bool, yes: bool) -> None: + """ + 暂停定时任务。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job pause') + self.execution_service.complete_payload( + ctx, self.execution_service.run_async(self.job_runtime.pause_job(job_id)) + ) + + def resume(self, job_id: int, env: str, output: str, allow_prod: bool, yes: bool) -> None: + """ + 恢复定时任务。 + + :param job_id: 任务ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job resume') + self.execution_service.complete_payload( + ctx, self.execution_service.run_async(self.job_runtime.resume_job(job_id)) + ) + + def sync(self, env: str, output: str, allow_prod: bool, yes: bool) -> None: + """ + 同步调度任务配置。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job sync') + self.execution_service.complete_payload( + ctx, + self.execution_service.run_async(self.operations_runtime.sync_jobs()), + ) diff --git a/shierfeng-fastapi-backend/cli/groups/job/presenter.py b/shierfeng-fastapi-backend/cli/groups/job/presenter.py new file mode 100644 index 0000000..e52f570 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/job/presenter.py @@ -0,0 +1,197 @@ +from cli.utils import SHELL_TEXT_FORMATTER + + +class JobCommandPresenter: + """ + 定时任务命令文本渲染器。 + + 该渲染器负责将 `job` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_job_list_text(self, payload: dict[str, object]) -> str: + """ + 将任务列表结果渲染为文本摘要。 + + :param payload: 任务列表结果字典 + :return: 文本摘要 + """ + lines = [f'ok: {str(payload.get("ok", False)).lower()}'] + + filters = payload.get('filters') + if isinstance(filters, dict): + lines.extend(self._build_job_filter_lines(filters)) + + page_payload = payload.get('page') + if isinstance(page_payload, dict): + rows = page_payload.get('rows', []) + lines.append( + 'page: ' + f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} ' + f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})' + ) + lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}') + if not isinstance(rows, list) or not rows: + lines.append('jobs: none') + return '\n'.join(lines) + lines.append('jobs:') + for row in rows: + if isinstance(row, dict): + lines.extend([f' {item}' for item in self._build_job_item_lines(row)]) + return '\n'.join(lines) + + items = payload.get('items') + lines.append(f'count: {payload.get("count", 0)}') + if not isinstance(items, list) or not items: + lines.append('jobs: none') + return '\n'.join(lines) + + lines.append('jobs:') + for item in items: + if isinstance(item, dict): + lines.extend([f' {line}' for line in self._build_job_item_lines(item)]) + return '\n'.join(lines) + + def build_job_detail_text(self, payload: dict[str, object]) -> str: + """ + 将单个定时任务详情渲染为文本摘要。 + + :param payload: 定时任务详情结果字典 + :return: 文本摘要 + """ + job = payload.get('job') + if not isinstance(job, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'job_id: {payload.get("jobId", "-")}', + 'job: none', + ] + ) + + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'job_id: {job.get("jobId", "-")}', + f'job_name: {job.get("jobName", "-")}', + f'job_group: {job.get("jobGroup", "-")}', + f'job_executor: {job.get("jobExecutor", "-")}', + f'status: {job.get("status", "-")}', + f'cron_expression: {job.get("cronExpression", "-")}', + f'invoke_target: {SHELL_TEXT_FORMATTER.truncate_text(job.get("invokeTarget", ""), 160) or "-"}', + f'job_args: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobArgs", ""), 120) or "-"}', + f'job_kwargs: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobKwargs", ""), 120) or "-"}', + f'misfire_policy: {job.get("misfirePolicy", "-")}', + f'concurrent: {job.get("concurrent", "-")}', + f'remark: {SHELL_TEXT_FORMATTER.truncate_text(job.get("remark", ""), 160) or "-"}', + f'create_by: {job.get("createBy", "-")}', + f'create_time: {job.get("createTime", "-")}', + f'update_by: {job.get("updateBy", "-")}', + f'update_time: {job.get("updateTime", "-")}', + ] + ) + + def build_job_logs_text(self, payload: dict[str, object]) -> str: + """ + 将定时任务日志列表结果渲染为文本摘要。 + + :param payload: 定时任务日志列表结果字典 + :return: 文本摘要 + """ + lines = [f'ok: {str(payload.get("ok", False)).lower()}'] + + filters = payload.get('filters') + if isinstance(filters, dict): + lines.extend(self._build_job_filter_lines(filters)) + + page_payload = payload.get('page') + if isinstance(page_payload, dict): + rows = page_payload.get('rows', []) + lines.append( + 'page: ' + f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} ' + f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})' + ) + lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}') + if not isinstance(rows, list) or not rows: + lines.append('logs: none') + return '\n'.join(lines) + lines.append('logs:') + for row in rows: + if isinstance(row, dict): + lines.extend([f' {item}' for item in self._build_job_log_item_lines(row)]) + return '\n'.join(lines) + + items = payload.get('items') + lines.append(f'count: {payload.get("count", 0)}') + if not isinstance(items, list) or not items: + lines.append('logs: none') + return '\n'.join(lines) + + lines.append('logs:') + for item in items: + if isinstance(item, dict): + lines.extend([f' {line}' for line in self._build_job_log_item_lines(item)]) + return '\n'.join(lines) + + @staticmethod + def _build_job_filter_lines(filters: dict[str, object]) -> list[str]: + """ + 构建任务列表过滤条件文本行。 + + :param filters: 过滤条件字典 + :return: 过滤条件文本行列表 + """ + active_filters = [] + for key, value in filters.items(): + if value in (None, '', False): + continue + active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}') + if not active_filters: + return ['filters: none'] + return ['filters:', *[f' - {item}' for item in active_filters]] + + @staticmethod + def _build_job_item_lines(job_item: dict[str, object]) -> list[str]: + """ + 构建单条任务记录的文本行。 + + :param job_item: 单条任务记录 + :return: 任务记录文本行列表 + """ + job_id = job_item.get('jobId', '-') + job_name = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('jobName', ''), 30) + job_group = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('jobGroup', ''), 16) + status = job_item.get('status', '-') + cron_expression = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('cronExpression', ''), 40) + invoke_target = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('invokeTarget', ''), 80) + executor = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('jobExecutor', ''), 24) + return [ + f'- [{job_id}] {job_name} | 组: {job_group} | 状态: {status}', + f' cron: {cron_expression or "-"}', + f' invoke: {invoke_target or "-"}', + f' executor: {executor or "-"}', + ] + + @staticmethod + def _build_job_log_item_lines(job_log_item: dict[str, object]) -> list[str]: + """ + 构建单条定时任务日志记录的文本行。 + + :param job_log_item: 单条定时任务日志记录 + :return: 文本行列表 + """ + job_log_id = job_log_item.get('jobLogId', '-') + job_name = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobName', ''), 30) + job_group = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobGroup', ''), 16) + status = job_log_item.get('status', '-') + job_trigger = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobTrigger', ''), 40) + job_message = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobMessage', ''), 80) + exception_info = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('exceptionInfo', ''), 120) + return [ + f'- [{job_log_id}] {job_name} | 组: {job_group} | 状态: {status}', + f' trigger: {job_trigger or "-"}', + f' message: {job_message or "-"}', + f' exception: {exception_info or "-"}', + f' create_time: {job_log_item.get("createTime", "-")}', + ] diff --git a/shierfeng-fastapi-backend/cli/groups/ops/__init__.py b/shierfeng-fastapi-backend/cli/groups/ops/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/ops/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/shierfeng-fastapi-backend/cli/groups/ops/command.py b/shierfeng-fastapi-backend/cli/groups/ops/command.py new file mode 100644 index 0000000..c12530d --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/ops/command.py @@ -0,0 +1,89 @@ +import typer + +from cli.context import EnvOption, OutputOption + +from .controller import OpsCommandController + +app = typer.Typer( + help='运维相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) +_OPS_COMMAND_CONTROLLER = OpsCommandController() + + +@app.command('ping-db', help='检查数据库连接') +def ping_db( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 检查数据库连接。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _OPS_COMMAND_CONTROLLER.ping_db(env, output) + + +@app.command('ping-redis', help='检查 Redis 连接') +def ping_redis_command( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 检查 Redis 连接。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _OPS_COMMAND_CONTROLLER.ping_redis(env, output) + + +@app.command('health', help='查看基础健康检查结果') +def health( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 输出基础健康检查结果。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _OPS_COMMAND_CONTROLLER.health(env, output) + + +@app.command('deps', help='查看当前 CLI 和后端运行依赖版本') +def deps( + env: EnvOption = 'dev', + output: OutputOption = 'text', + include_dev: bool = typer.Option(False, '--include-dev', help='附带输出开发依赖'), +) -> None: + """ + 检查当前 CLI 和后端运行依赖版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param include_dev: 是否附带输出开发依赖 + :return: None + """ + _OPS_COMMAND_CONTROLLER.deps(env, output, include_dev=include_dev) + + +@app.command('server-info', help='查看服务器运行时信息') +def server_info( + env: EnvOption = 'dev', + output: OutputOption = 'text', +) -> None: + """ + 输出服务器运行时信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + _OPS_COMMAND_CONTROLLER.server_info(env, output) diff --git a/shierfeng-fastapi-backend/cli/groups/ops/controller.py b/shierfeng-fastapi-backend/cli/groups/ops/controller.py new file mode 100644 index 0000000..346abeb --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/ops/controller.py @@ -0,0 +1,137 @@ +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliContextFactory, + CliExecutionService, +) +from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS +from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService +from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService + +from .presenter import OpsCommandPresenter + + +class OpsCommandController: + """ + 运维命令控制器。 + + 该控制器负责组织 `ops` 命令组的上下文准备、runtime 调用、 + payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 运维命令文本渲染器 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: OpsCommandPresenter | None = None, + database_runtime: DatabaseRuntimeService | None = None, + operations_runtime: OperationsRuntimeService | None = None, + ) -> None: + """ + 初始化运维命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 运维命令文本渲染器 + :param database_runtime: 数据库运行时服务 + :param operations_runtime: 运维运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or OpsCommandPresenter() + self.database_runtime = database_runtime or DATABASE_RUNTIME + self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME + + def ping_db(self, env: str, output: str) -> None: + """ + 检查数据库连接。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload( + ctx, self.execution_service.run_async(self.database_runtime.ping_database()) + ) + + def ping_redis(self, env: str, output: str) -> None: + """ + 检查 Redis 连接。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload( + ctx, + self.execution_service.run_async(self.operations_runtime.ping_redis()), + ) + + def health(self, env: str, output: str) -> None: + """ + 输出基础健康检查结果。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + db_status = self.execution_service.run_async(self.database_runtime.ping_database()) + redis_status = self.execution_service.run_async(self.operations_runtime.ping_redis()) + payload = { + 'env': ctx.env, + 'database': db_status, + 'redis': redis_status, + 'ok': db_status.get('ok', False) and redis_status.get('ok', False), + } + exit_code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_health_text, + default_exit_code=exit_code, + ) + + def deps(self, env: str, output: str, *, include_dev: bool) -> None: + """ + 检查当前 CLI 和后端运行依赖版本。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param include_dev: 是否附带输出开发依赖 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.operations_runtime.get_dependency_versions(include_dev=include_dev) + exit_code = SUCCESS if payload.get('ok', False) else DEPENDENCY_ERROR + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_dependencies_text, + default_exit_code=exit_code, + ) + + def server_info(self, env: str, output: str) -> None: + """ + 输出服务器运行时信息。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.operations_runtime.get_server_info()) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_server_info_text, + default_exit_code=SUCCESS, + text_condition=lambda data: data.get('ok', False), + ) diff --git a/shierfeng-fastapi-backend/cli/groups/ops/presenter.py b/shierfeng-fastapi-backend/cli/groups/ops/presenter.py new file mode 100644 index 0000000..31aa1ab --- /dev/null +++ b/shierfeng-fastapi-backend/cli/groups/ops/presenter.py @@ -0,0 +1,177 @@ +from cli.utils import SHELL_TEXT_FORMATTER + + +class OpsCommandPresenter: + """ + 运维命令文本渲染器。 + + 该渲染器负责将 `ops` 命令组产生的结构化 payload 转换为稳定的文本摘要, + 同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。 + """ + + def build_health_text(self, payload: dict[str, object]) -> str: + """ + 将基础健康检查结果渲染为文本摘要。 + + :param payload: 健康检查结果字典 + :return: 文本摘要 + """ + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "")}', + 'checks:', + self._build_check_status_line('database', payload.get('database')), + self._build_check_status_line('redis', payload.get('redis')), + ] + ) + + def build_server_info_text(self, payload: dict[str, object]) -> str: + """ + 将服务器运行时信息渲染为文本摘要。 + + :param payload: 服务器运行时信息结果字典 + :return: 文本摘要 + """ + server = payload.get('server') + if not isinstance(server, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + 'server: none', + ] + ) + + sys_info = server.get('sys') + cpu_info = server.get('cpu') + mem_info = server.get('mem') + py_info = server.get('py') + sys_files = server.get('sysFiles') + + lines = [f'ok: {str(payload.get("ok", False)).lower()}'] + if isinstance(sys_info, dict): + lines.extend( + [ + 'host:', + f' name: {sys_info.get("computerName", "-")}', + f' ip: {sys_info.get("computerIp", "-")}', + f' os: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("osName", "-"), 90)}', + f' arch: {sys_info.get("osArch", "-")}', + f' user_dir: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("userDir", "-"), 100)}', + ] + ) + + if isinstance(cpu_info, dict): + lines.extend( + [ + 'cpu:', + f' cores: {cpu_info.get("cpuNum", "-")}', + f' used: {cpu_info.get("used", "-")}%', + f' sys: {cpu_info.get("sys", "-")}%', + f' free: {cpu_info.get("free", "-")}%', + ] + ) + + if isinstance(mem_info, dict): + lines.extend( + [ + 'memory:', + f' total: {mem_info.get("total", "-")}', + f' used: {mem_info.get("used", "-")}', + f' free: {mem_info.get("free", "-")}', + f' usage: {mem_info.get("usage", "-")}%', + ] + ) + + if isinstance(py_info, dict): + lines.extend( + [ + 'python:', + f' name: {py_info.get("name", "-")}', + f' version: {py_info.get("version", "-")}', + f' start_time: {py_info.get("startTime", "-")}', + f' run_time: {py_info.get("runTime", "-")}', + f' home: {SHELL_TEXT_FORMATTER.truncate_text(py_info.get("home", "-"), 100)}', + f' process_memory: {py_info.get("used", "-")} / {py_info.get("total", "-")} ({py_info.get("usage", "-")}%)', + ] + ) + + if isinstance(sys_files, list): + lines.append(f'disks: {len(sys_files)}') + if sys_files: + lines.append('disk_samples:') + lines.extend( + f' - {item.get("dirName", "-")} | used: {item.get("used", "-")} / {item.get("total", "-")} | usage: {item.get("usage", "-")}' + for item in sys_files[:10] + if isinstance(item, dict) + ) + + return '\n'.join(lines) + + def build_dependencies_text(self, payload: dict[str, object]) -> str: + """ + 将依赖版本检查结果渲染为文本摘要。 + + :param payload: 依赖版本检查结果字典 + :return: 文本摘要 + """ + packages = payload.get('packages') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'include_dev: {str(payload.get("includeDev", False)).lower()}', + ] + missing_required = payload.get('missingRequired') + if isinstance(missing_required, list) and missing_required: + lines.append('missing_required:') + lines.extend(f' - {item}' for item in missing_required) + else: + lines.append('missing_required: none') + + if not isinstance(packages, dict) or not packages: + lines.append('packages: none') + return '\n'.join(lines) + + lines.append('packages:') + for package_name in sorted(packages): + dependency_payload = packages.get(package_name) + if isinstance(dependency_payload, dict): + lines.append(self._build_dependency_line(package_name, dependency_payload)) + return '\n'.join(lines) + + @staticmethod + def _build_check_status_line(name: str, status_payload: dict[str, object] | None) -> str: + """ + 构建单项检查结果摘要行。 + + :param name: 检查项名称 + :param status_payload: 检查结果字典 + :return: 摘要行文本 + """ + if not isinstance(status_payload, dict): + return f' {name}: unknown' + + ok = str(status_payload.get('ok', False)).lower() + message = status_payload.get('message', '-') or '-' + error = status_payload.get('error') + if error: + return f' {name}: {ok} | {message} | error: {SHELL_TEXT_FORMATTER.truncate_text(error, 120)}' + return f' {name}: {ok} | {message}' + + @staticmethod + def _build_dependency_line(name: str, dependency_payload: dict[str, object] | None) -> str: + """ + 构建单个依赖项的文本摘要行。 + + :param name: 依赖项名称 + :param dependency_payload: 依赖项结果字典 + :return: 文本摘要行 + """ + if not isinstance(dependency_payload, dict): + return f' {name}: not-installed' + + installed = str(dependency_payload.get('installed', False)).lower() + version = dependency_payload.get('version', '-') or '-' + required = str(dependency_payload.get('required', False)).lower() + distribution = dependency_payload.get('distribution', '') or name + return f' {name}: {installed} | version: {version} | required: {required} | dist: {distribution}' diff --git a/shierfeng-fastapi-backend/cli/guards.py b/shierfeng-fastapi-backend/cli/guards.py new file mode 100644 index 0000000..8f1c708 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/guards.py @@ -0,0 +1,210 @@ +import sys +from dataclasses import dataclass, field +from typing import Literal + +import click +import typer + +from cli.context import CliContext +from cli.exit_codes import GUARD_REJECTED +from cli.output import CommandResult + +DangerousCommandRiskLevel = Literal['high', 'normal'] + + +@dataclass(frozen=True) +class DangerousCommandRule: + """ + 危险命令保护规则。 + + :param command_name: 命令唯一标识 + :param risk_level: 风险级别 + :param supports_dry_run: 是否支持演练执行 + """ + + command_name: str + risk_level: DangerousCommandRiskLevel + supports_dry_run: bool + + +@dataclass(frozen=True) +class DangerousCommandRuleRegistry: + """ + 危险命令规则注册表。 + + 该注册表负责维护 CLI 内所有危险命令的风险元数据,并提供查询与 + 强制获取能力,作为上下文工厂、风险元数据与测试的统一入口。 + + :param rules: 按命令名索引的危险命令规则表 + """ + + rules: dict[str, DangerousCommandRule] + + def get_rule(self, command_name: str) -> DangerousCommandRule | None: + """ + 获取指定命令的危险命令规则。 + + :param command_name: 命令唯一标识 + :return: 命令规则,不存在时返回 None + """ + return self.rules.get(command_name) + + def require_rule(self, command_name: str) -> DangerousCommandRule: + """ + 获取指定命令的危险命令规则,不存在时抛出异常。 + + :param command_name: 命令唯一标识 + :return: 命令规则 + :raises ValueError: 命令未注册危险命令规则时抛出 + """ + rule = self.get_rule(command_name) + if rule is None: + raise ValueError(f'危险命令未注册保护规则:{command_name}') + return rule + + +@dataclass(frozen=True) +class DangerousCommandResultBuilder: + """ + 危险命令结果构建器。 + + 该对象负责统一构建危险命令拒绝结果,避免保护服务内部继续拼装 + 结构化负载细节。 + """ + + @staticmethod + def build_guard_reject_result(message: str, hint: str) -> CommandResult: + """ + 构建危险命令拒绝结果。 + + :param message: 拒绝原因 + :param hint: 补充提示 + :return: 命令拒绝结果 + """ + return CommandResult( + data={ + 'ok': False, + 'message': message, + 'hint': hint, + }, + exit_code=GUARD_REJECTED, + ) + + +@dataclass(frozen=True) +class DangerousCommandConfirmationService: + """ + 危险命令确认服务。 + + 该对象负责处理 TTY 检测、确认提示与交互取消异常收口。 + + :param result_builder: 危险命令结果构建器 + """ + + result_builder: DangerousCommandResultBuilder + + def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None: + """ + 执行危险命令交互确认。 + + :param ctx: CLI 上下文 + :param command_name: 命令唯一标识 + :return: 拒绝结果或 None + """ + if ctx.yes: + return None + + if not sys.stdin.isatty(): + return self.result_builder.build_guard_reject_result( + f'已取消危险命令执行:{command_name}', + '当前命令需要交互确认;如需非交互执行,请传入 --yes', + ) + + try: + confirmed = typer.confirm( + f'确认执行危险命令 `{command_name}` 吗? 当前环境:{ctx.env}{"(dry-run)" if ctx.dry_run else ""}', + default=False, + ) + except (click.Abort, EOFError, KeyboardInterrupt): + return self.result_builder.build_guard_reject_result( + f'已取消危险命令执行:{command_name}', + '当前命令需要交互确认;如需非交互执行,请传入 --yes', + ) + if confirmed: + return None + + return self.result_builder.build_guard_reject_result( + f'已取消危险命令执行:{command_name}', + '如需跳过确认,请传入 --yes', + ) + + +@dataclass +class DangerousCommandGuardService: + """ + 危险命令保护执行服务。 + + 该服务负责根据命令规则和 CLI 上下文执行生产环境保护、交互确认 + 与拒绝结果收口。 + + :param rule_registry: 危险命令规则注册表 + :param result_builder: 危险命令结果构建器 + :param confirmation_service: 危险命令确认服务 + """ + + rule_registry: DangerousCommandRuleRegistry + result_builder: DangerousCommandResultBuilder = field(default_factory=DangerousCommandResultBuilder) + confirmation_service: DangerousCommandConfirmationService = field(init=False) + + def __post_init__(self) -> None: + """ + 初始化危险命令保护服务依赖。 + + :return: None + """ + self.confirmation_service = DangerousCommandConfirmationService(self.result_builder) + + def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult | None: + """ + 执行危险命令保护与确认。 + + :param ctx: CLI 上下文 + :param rule: 危险命令规则 + :return: 拒绝结果或 None + """ + command_name = rule.command_name + if ctx.env == 'prod' and not ctx.allow_prod: + return self.result_builder.build_guard_reject_result( + f'生产环境默认禁止直接执行危险命令:{command_name}', + '如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes', + ) + + return self.confirmation_service.confirm(ctx, command_name=command_name) + + +DEFAULT_DANGEROUS_COMMAND_RULES: dict[str, DangerousCommandRule] = { + 'cache clear': DangerousCommandRule(command_name='cache clear', risk_level='high', supports_dry_run=True), + 'cache warmup': DangerousCommandRule(command_name='cache warmup', risk_level='normal', supports_dry_run=False), + 'db upgrade': DangerousCommandRule(command_name='db upgrade', risk_level='high', supports_dry_run=True), + 'db init': DangerousCommandRule(command_name='db init', risk_level='high', supports_dry_run=True), + 'db downgrade': DangerousCommandRule(command_name='db downgrade', risk_level='high', supports_dry_run=True), + 'db revision': DangerousCommandRule(command_name='db revision', risk_level='high', supports_dry_run=True), + 'config set': DangerousCommandRule(command_name='config set', risk_level='high', supports_dry_run=True), + 'config sync-cache': DangerousCommandRule( + command_name='config sync-cache', + risk_level='normal', + supports_dry_run=False, + ), + 'crypto rotate': DangerousCommandRule(command_name='crypto rotate', risk_level='high', supports_dry_run=True), + 'job run-once': DangerousCommandRule(command_name='job run-once', risk_level='normal', supports_dry_run=False), + 'job pause': DangerousCommandRule(command_name='job pause', risk_level='normal', supports_dry_run=False), + 'job resume': DangerousCommandRule(command_name='job resume', risk_level='normal', supports_dry_run=False), + 'job sync': DangerousCommandRule(command_name='job sync', risk_level='normal', supports_dry_run=False), + 'gen import-table': DangerousCommandRule(command_name='gen import-table', risk_level='high', supports_dry_run=True), + 'gen create-table': DangerousCommandRule(command_name='gen create-table', risk_level='high', supports_dry_run=True), + 'gen export': DangerousCommandRule(command_name='gen export', risk_level='high', supports_dry_run=True), + 'gen sync-db': DangerousCommandRule(command_name='gen sync-db', risk_level='normal', supports_dry_run=False), +} + +DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = DangerousCommandRuleRegistry(rules=DEFAULT_DANGEROUS_COMMAND_RULES) +DEFAULT_DANGEROUS_COMMAND_GUARD = DangerousCommandGuardService(rule_registry=DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY) diff --git a/shierfeng-fastapi-backend/cli/main.py b/shierfeng-fastapi-backend/cli/main.py new file mode 100644 index 0000000..32c59bf --- /dev/null +++ b/shierfeng-fastapi-backend/cli/main.py @@ -0,0 +1,100 @@ +import sys +from dataclasses import dataclass + +from cli.core import ( + DEFAULT_CORE_SERVICES, + CliApplicationBuilder, + CompletionDispatcher, + ProjectRuntimeLocator, +) + + +@dataclass(frozen=True) +class CliImportArgvScope: + """ + CLI 导入期参数作用域。 + + 该对象负责在根应用构建阶段暂时切换 `sys.argv`,只保留导入期 + 需要的最小参数集,避免该过程继续以内联 `try/finally` 形式散落 + 在入口运行器中。 + + :param project_runtime_locator: CLI 项目运行时定位器 + """ + + project_runtime_locator: ProjectRuntimeLocator + + def run(self, callback: object) -> object: + """ + 在导入期参数作用域内执行回调。 + + :param callback: 待执行回调 + :return: 回调执行结果 + """ + original_argv = list(sys.argv) + try: + sys.argv = self.project_runtime_locator.extract_import_argv(original_argv) + return callback() + finally: + sys.argv = original_argv + + +@dataclass(frozen=True) +class CliMainRunner: + """ + CLI 根入口运行器。 + + 该运行器负责串联项目目录定位、导入期参数裁剪、根应用构建、 + completion 分发与 Typer 应用启动,避免 `main.py` 继续停留在 + 模块级散乱编排状态。 + + :param project_runtime_locator: CLI 项目运行时定位器 + :param completion_dispatcher: shell completion 分发器 + :param application_builder: 根应用构建器 + """ + + project_runtime_locator: ProjectRuntimeLocator + completion_dispatcher: CompletionDispatcher + application_builder: CliApplicationBuilder + import_argv_scope: CliImportArgvScope + + def build_cli(self) -> object: + """ + 构建 CLI 根应用,并在导入期仅保留最小化参数集。 + + :return: Typer 根应用 + """ + self.project_runtime_locator.ensure_backend_dir_on_sys_path() + return self.import_argv_scope.run(self.application_builder.build) + + def run(self) -> None: + """ + 执行 Typer CLI 根应用。 + + :return: None + """ + cli = self.build_cli() + self.completion_dispatcher.dispatch(cli) + cli(prog_name='shierfeng') + + +CLI_APPLICATION_BUILDER = CliApplicationBuilder(output_renderer=DEFAULT_CORE_SERVICES.output_renderer) +PROJECT_RUNTIME_LOCATOR = ProjectRuntimeLocator() +CLI_MAIN_RUNNER = CliMainRunner( + project_runtime_locator=PROJECT_RUNTIME_LOCATOR, + completion_dispatcher=CompletionDispatcher(), + application_builder=CLI_APPLICATION_BUILDER, + import_argv_scope=CliImportArgvScope(project_runtime_locator=PROJECT_RUNTIME_LOCATOR), +) + + +def main() -> None: + """ + 执行 CLI 根入口运行器。 + + :return: None + """ + CLI_MAIN_RUNNER.run() + + +if __name__ == '__main__': + main() diff --git a/shierfeng-fastapi-backend/cli/metadata/__init__.py b/shierfeng-fastapi-backend/cli/metadata/__init__.py new file mode 100644 index 0000000..0d626c8 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/metadata/__init__.py @@ -0,0 +1,10 @@ +from cli.metadata.command_specs import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry +from cli.metadata.option_specs import ENVIRONMENT_OPTION_SERVICE, EnvironmentOptionService + +__all__ = [ + 'COMPLETION_SHELL_SPEC_REGISTRY', + 'ENVIRONMENT_OPTION_SERVICE', + 'CompletionShellSpec', + 'CompletionShellSpecRegistry', + 'EnvironmentOptionService', +] diff --git a/shierfeng-fastapi-backend/cli/metadata/command_specs.py b/shierfeng-fastapi-backend/cli/metadata/command_specs.py new file mode 100644 index 0000000..0b1fa5b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/metadata/command_specs.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class CompletionShellSpec: + """ + Shell completion 元数据定义。 + + name: shell 名称 + description: shell 描述文本 + generator: 脚本生成方式 + default_target: 默认脚本落盘路径,相对于用户 home 目录 + default_rc_file: 默认 rc 文件路径,相对于用户 home 目录 + auto_discovery: 是否会被 shell 自动发现 + supported: 当前版本是否已支持 + """ + + name: str + description: str + generator: Literal['click', 'custom', 'unsupported'] + default_target: str + default_rc_file: str | None + auto_discovery: bool + supported: bool + + +@dataclass(frozen=True) +class CompletionShellSpecRegistry: + """ + completion shell 元数据注册表。 + + 该注册表集中维护 CLI 当前支持的 shell completion 元数据, + 供 installer、doctor 和 provider 等场景统一查询。 + + :param specs: 按 shell 名称索引的元数据表 + """ + + specs: dict[str, CompletionShellSpec] + + def get_spec(self, shell_name: str) -> CompletionShellSpec | None: + """ + 获取指定 shell 的 completion 元数据。 + + :param shell_name: shell 名称 + :return: shell 元数据,不存在时返回 None + """ + return self.specs.get(shell_name) + + def list_shell_names(self) -> list[str]: + """ + 获取已注册的 shell 名称列表。 + + :return: shell 名称列表 + """ + return list(self.specs) + + +DEFAULT_COMPLETION_SHELL_SPECS: dict[str, CompletionShellSpec] = { + 'bash': CompletionShellSpec( + name='bash', + description='GNU Bash shell completion', + generator='click', + default_target='.local/share/shierfeng/completion/shierfeng.bash', + default_rc_file='.bashrc', + auto_discovery=False, + supported=True, + ), + 'zsh': CompletionShellSpec( + name='zsh', + description='Zsh shell completion', + generator='click', + default_target='.local/share/shierfeng/completion/shierfeng.zsh', + default_rc_file='.zshrc', + auto_discovery=False, + supported=True, + ), + 'fish': CompletionShellSpec( + name='fish', + description='Fish shell completion', + generator='click', + default_target='.config/fish/completions/shierfeng.fish', + default_rc_file=None, + auto_discovery=True, + supported=True, + ), + 'powershell': CompletionShellSpec( + name='powershell', + description='Windows PowerShell / PowerShell 7 completion', + generator='custom', + default_target='Documents/PowerShell/Profile/shierfeng.ps1', + default_rc_file='Documents/PowerShell/Microsoft.PowerShell_profile.ps1', + auto_discovery=False, + supported=True, + ), +} + +COMPLETION_SHELL_SPEC_REGISTRY = CompletionShellSpecRegistry(specs=DEFAULT_COMPLETION_SHELL_SPECS) diff --git a/shierfeng-fastapi-backend/cli/metadata/option_specs.py b/shierfeng-fastapi-backend/cli/metadata/option_specs.py new file mode 100644 index 0000000..557e986 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/metadata/option_specs.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_ENVIRONMENTS = ('dev', 'prod', 'dockermy', 'dockerpg') + + +@dataclass(frozen=True) +class EnvironmentOptionService: + """ + CLI 环境选项服务。 + + 该服务负责发现当前项目下可用的环境名称集合,供 completion、 + wizard 和诊断信息等场景复用。 + + :param default_environments: 默认内置环境名称列表 + """ + + default_environments: tuple[str, ...] = DEFAULT_ENVIRONMENTS + + def discover_env_names(self, project_dir: Path | None = None) -> list[str]: + """ + 发现当前项目可用的环境名称列表。 + + :param project_dir: 后端项目目录,默认使用当前工作目录 + :return: 去重且排序后的环境名称列表 + """ + resolved_project_dir = (project_dir or Path.cwd()).resolve() + env_names = set(self.default_environments) + for env_file in resolved_project_dir.glob('.env.*'): + suffix = env_file.name.removeprefix('.env.').strip() + if suffix: + env_names.add(suffix) + return sorted(env_names) + + +ENVIRONMENT_OPTION_SERVICE = EnvironmentOptionService() diff --git a/shierfeng-fastapi-backend/cli/metadata/risk_specs.py b/shierfeng-fastapi-backend/cli/metadata/risk_specs.py new file mode 100644 index 0000000..d009304 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/metadata/risk_specs.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from typing import Literal + +from cli.guards import DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY, DangerousCommandRuleRegistry + +CommandRiskLevel = Literal['readonly', 'normal', 'high'] + + +@dataclass(frozen=True) +class CommandRiskSpec: + """ + CLI 命令风险元数据定义。 + + :param command_name: 命令唯一标识 + :param risk_level: 风险级别 + :param supports_dry_run: 是否支持演练执行 + """ + + command_name: str + risk_level: CommandRiskLevel + supports_dry_run: bool + + +@dataclass(frozen=True) +class CommandRiskSpecRegistry: + """ + CLI 命令风险元数据注册表。 + + 该注册表负责从危险命令规则注册表派生风险元数据,供 TUI、文档、 + 诊断提示等只读场景统一查询。 + + :param specs: 按命令名索引的风险元数据表 + """ + + specs: dict[str, CommandRiskSpec] + + def get_spec(self, command_name: str) -> CommandRiskSpec | None: + """ + 获取指定命令的风险元数据。 + + :param command_name: 命令唯一标识 + :return: 风险元数据,不存在时返回 None + """ + return self.specs.get(command_name) + + +class CommandRiskSpecRegistryBuilder: + """ + CLI 命令风险元数据注册表构建器。 + + 该构建器负责基于危险命令规则注册表生成命令风险元数据注册表, + 避免模块导入阶段散落重复的字典推导逻辑。 + """ + + @staticmethod + def build(rule_registry: DangerousCommandRuleRegistry) -> CommandRiskSpecRegistry: + """ + 基于危险命令规则注册表构建风险元数据注册表。 + + :param rule_registry: 危险命令规则注册表 + :return: 风险元数据注册表 + """ + return CommandRiskSpecRegistry( + specs={ + command_name: CommandRiskSpec( + command_name=rule.command_name, + risk_level=rule.risk_level, + supports_dry_run=rule.supports_dry_run, + ) + for command_name, rule in rule_registry.rules.items() + } + ) + + +COMMAND_RISK_SPEC_REGISTRY = CommandRiskSpecRegistryBuilder.build(DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY) diff --git a/shierfeng-fastapi-backend/cli/output.py b/shierfeng-fastapi-backend/cli/output.py new file mode 100644 index 0000000..121ff17 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/output.py @@ -0,0 +1,873 @@ +import json +import os +import re +import sys +from dataclasses import dataclass +from typing import Any + +import typer + +from cli.context import CliContext + + +@dataclass +class OutputSettings: + """ + 输出渲染配置。 + + :param color_mode: 颜色模式 + :param icon_mode: 图标模式 + """ + + color_mode: str = 'always' + icon_mode: str = 'emoji' + + +@dataclass(frozen=True) +class StatusTokenDefinition: + """ + 状态标签定义。 + + :param emoji: emoji 模式文案 + :param ascii: ASCII 模式文案 + :param none: 无图标模式文案 + :param color: 默认前景色 + """ + + emoji: str + ascii: str + none: str + color: str + + +@dataclass +class CommandResult: + """ + 统一命令执行结果。 + + :param data: 命令结果负载 + :param exit_code: 命令退出码 + :param already_printed: 是否已由上层提前输出 + """ + + data: Any = None + exit_code: int = 0 + already_printed: bool = False + + +class OutputStatusStyler: + """ + 统一负责状态标签与单行文本样式。 + + :param settings: 输出配置 + """ + + _STATUS_DEFINITIONS = { + 'ok': StatusTokenDefinition(emoji='✅ OK', ascii='[OK]', none='OK', color='green'), + 'fail': StatusTokenDefinition(emoji='❌ FAIL', ascii='[FAIL]', none='FAIL', color='red'), + 'warn': StatusTokenDefinition(emoji='💡 HINT', ascii='[HINT]', none='HINT', color='yellow'), + 'error': StatusTokenDefinition(emoji='🚨 ERROR', ascii='[ERROR]', none='ERROR', color='red'), + 'info': StatusTokenDefinition(emoji='💬 INFO', ascii='[INFO]', none='INFO', color='blue'), + } + _STATUS_FIELD_KIND_MAPPING = { + 'message': 'info', + 'info': 'info', + 'hint': 'warn', + 'warn': 'warn', + 'warning': 'warn', + 'error': 'error', + } + + def __init__(self, settings: OutputSettings) -> None: + """ + 初始化状态样式器。 + + :param settings: 输出配置 + :return: None + """ + self.settings = settings + + def supports_color(self, stream: Any) -> bool: + """ + 判断当前输出流是否应启用颜色。 + + :param stream: 输出流对象 + :return: 是否启用颜色 + """ + if self.settings.color_mode == 'always': + return True + if self.settings.color_mode == 'never': + return False + if os.environ.get('NO_COLOR'): + return False + if os.environ.get('TERM') == 'dumb': + return False + return bool(getattr(stream, 'isatty', lambda: False)()) + + @staticmethod + def style_text(text: str, *, fg: str | None = None, bold: bool = False, dim: bool = False) -> str: + """ + 为文本附加终端样式。 + + :param text: 原始文本 + :param fg: 前景色 + :param bold: 是否加粗 + :param dim: 是否弱化显示 + :return: 样式化后的文本 + """ + return typer.style(text, fg=fg, bold=bold, dim=dim) + + def get_status_definition(self, kind: str) -> StatusTokenDefinition: + """ + 获取状态标签定义。 + + :param kind: 状态类型 + :return: 状态标签定义 + """ + return self._STATUS_DEFINITIONS.get(kind, self._STATUS_DEFINITIONS['info']) + + def build_status_token(self, kind: str) -> str: + """ + 根据当前图标模式构建状态标签文本。 + + :param kind: 标签类型 + :return: 标签文本 + """ + definition = self.get_status_definition(kind) + return getattr(definition, self.settings.icon_mode, definition.none) + + def style_status_token(self, kind: str, *, fg: str | None = None) -> str: + """ + 将状态标签渲染为高亮样式。 + + :param kind: 标签类型 + :param fg: 前景色 + :return: 样式化后的标签文本 + """ + definition = self.get_status_definition(kind) + return self.style_text(self.build_status_token(kind), fg=fg or definition.color, bold=True) + + def style_status_message(self, kind: str, text: str) -> str: + """ + 使用统一状态图标和颜色渲染消息文本。 + + :param kind: 状态类型 + :param text: 原始消息文本 + :return: 样式化后的消息文本 + """ + color = self.get_status_definition(kind).color + return f'{self.style_status_token(kind, fg=color)} {self.style_text(text, fg=color, bold=kind == "error")}' + + def build_result_header(self, is_ok: bool) -> str: + """ + 构建统一的结果头部文本。 + + :param is_ok: 是否成功 + :return: 结果头部文本 + """ + if is_ok: + return f'{self.build_status_token("ok")} SUCCESS' + return f'{self.build_status_token("fail")} FAILED' + + def style_scalar_text(self, value: str) -> str: + """ + 为标量文本值应用颜色样式。 + + :param value: 原始标量文本 + :return: 样式化后的文本 + """ + normalized_value = value.strip().lower() + if normalized_value == 'true': + return self.style_text(value, fg='green', bold=True) + if normalized_value == 'false': + return self.style_text(value, fg='red', bold=True) + if normalized_value in {'null', 'none', '-'}: + return self.style_text(value, fg='bright_black') + return value + + def style_inline_status_segment(self, segment: str) -> str: + """ + 为行内状态片段应用样式。 + + :param segment: 原始片段文本 + :return: 样式化后的片段文本 + """ + normalized_segment = segment.strip().lower() + if normalized_segment == 'true': + return self.style_status_token('ok', fg='green') + if normalized_segment == 'false': + return self.style_status_token('fail', fg='red') + for prefix, kind in self._STATUS_FIELD_KIND_MAPPING.items(): + if normalized_segment.startswith(f'{prefix}:'): + status_message = segment.split(':', 1)[1].strip() + return self.style_status_message(kind, status_message) + if ': ' in segment: + segment_key, segment_value = segment.split(': ', 1) + styled_segment_value = self.style_inline_status_value(segment_value) + if styled_segment_value != segment_value: + return f'{segment_key}: {styled_segment_value}' + return segment + + def style_inline_status_value(self, value: str) -> str: + """ + 为带分隔符的行内状态值应用样式。 + + :param value: 原始字段值 + :return: 样式化后的字段值 + """ + if ' | ' not in value: + styled_value = self.style_inline_status_segment(value) + if styled_value != value: + return styled_value + return self.style_scalar_text(value) + return ' | '.join(self.style_inline_status_segment(segment) for segment in value.split(' | ')) + + def style_named_value(self, key: str, value: str) -> str: + """ + 按字段语义为值应用样式。 + + :param key: 字段名 + :param value: 原始字段值 + :return: 样式化后的字段值 + """ + normalized_key = key.strip().lower() + normalized_value = value.strip().lower() + + if normalized_key == 'ok': + if normalized_value == 'true': + return self.style_status_token('ok', fg='green') + if normalized_value == 'false': + return self.style_status_token('fail', fg='red') + + status_kind = self._STATUS_FIELD_KIND_MAPPING.get(normalized_key) + if status_kind: + return self.style_status_message(status_kind, value) + + return self.style_inline_status_value(value) + + def style_line(self, line: str) -> str: + """ + 对单行文本输出应用可读性增强样式。 + + :param line: 原始输出行 + :return: 样式化后的输出行 + """ + if not line.strip(): + return line + + leading_spaces = len(line) - len(line.lstrip(' ')) + indent = line[:leading_spaces] + content = line[leading_spaces:] + + if content == self.build_result_header(True): + return ( + f'{indent}{self.style_status_token("ok", fg="green")} ' + f'{self.style_text("SUCCESS", fg="green", bold=True)}' + ) + if content == self.build_result_header(False): + return ( + f'{indent}{self.style_status_token("fail", fg="red")} {self.style_text("FAILED", fg="red", bold=True)}' + ) + + if content.startswith('- '): + return ( + f'{indent}{self.style_text("-", fg="bright_black", bold=True)} ' + f'{self.style_inline_status_value(content[2:])}' + ) + + if ': ' in content: + key, value = content.split(': ', 1) + styled_key = self.style_text(f'{key}:', fg='cyan', bold=leading_spaces == 0) + return f'{indent}{styled_key} {self.style_named_value(key, value)}' + + if content.endswith(':'): + return f'{indent}{self.style_text(content, fg="blue", bold=True)}' + + if content == '|': + return f'{indent}{self.style_text(content, fg="bright_black")}' + + return line + + def render_error_text(self, message: str, exit_code: int, stream: Any) -> str: + """ + 渲染标准错误输出文本。 + + :param message: 错误消息 + :param exit_code: 退出码 + :param stream: 输出流对象 + :return: 渲染后的错误文本 + """ + error_prefix = f'{self.build_status_token("error")}[{exit_code}]:' + if not self.supports_color(stream): + return f'{error_prefix} {message}' + return ( + f'{self.style_status_token("error", fg="red")}[{exit_code}]: ' + f'{self.style_text(message, fg="red", bold=True)}' + ) + + +class StructuredTextRenderer: + """ + 负责结构化数据到文本的转换与装饰。 + + :param status_styler: 状态样式器 + """ + + _SNAKE_CASE_BOUNDARY_PATTERN = re.compile(r'(? None: + """ + 初始化结构化文本渲染器。 + + :param status_styler: 状态样式器 + :return: None + """ + self.status_styler = status_styler + + def render_text_output(self, data: Any) -> str: + """ + 将任意数据渲染为纯文本输出。 + + :param data: 待渲染数据 + :return: 文本输出结果 + """ + if isinstance(data, str): + return data + return '\n'.join(self.render_text_lines(data)) + + def colorize_text_output(self, text: str, stream: Any) -> str: + """ + 按终端能力为文本输出增加颜色。 + + :param text: 原始文本输出 + :param stream: 输出流对象 + :return: 样式化后的文本输出 + """ + if not self.status_styler.supports_color(stream): + return text + return '\n'.join(self.status_styler.style_line(line) for line in text.splitlines()) + + def decorate_text_output(self, text: str) -> str: + """ + 对文本输出进行结构化装饰。 + + :param text: 原始文本输出 + :return: 装饰后的文本输出 + """ + lines = text.splitlines() + if not lines: + return text + + first_non_empty_index = next((index for index, line in enumerate(lines) if line.strip()), None) + if first_non_empty_index is None: + return text + + first_line = lines[first_non_empty_index].strip().lower() + if first_line == 'ok: true': + decorated_lines = list(lines) + decorated_lines[first_non_empty_index] = self.status_styler.build_result_header(True) + return '\n'.join(decorated_lines) + if first_line == 'ok: false': + decorated_lines = list(lines) + decorated_lines[first_non_empty_index] = self.status_styler.build_result_header(False) + return '\n'.join(decorated_lines) + return text + + @staticmethod + def format_scalar(value: Any) -> str: + """ + 将标量值格式化为文本输出。 + + :param value: 待格式化的值 + :return: 文本格式化结果 + """ + if value is None: + return 'null' + if isinstance(value, bool): + return 'true' if value else 'false' + return str(value) + + @classmethod + def format_field_name(cls, key: object) -> str: + """ + 将字段名标准化为文本输出使用的 `snake_case` 形式。 + + :param key: 原始字段名 + :return: 标准化后的字段名 + """ + text = str(key).strip() + if not text: + return '' + normalized_text = text.replace('-', '_').replace(' ', '_') + return cls._SNAKE_CASE_BOUNDARY_PATTERN.sub('_', normalized_text).lower() + + def append_multiline_text(self, lines: list[str], prefix: str, value: str, indent: str) -> None: + """ + 将多行文本追加到输出行列表中。 + + :param lines: 输出行列表 + :param prefix: 当前字段前缀 + :param value: 多行文本内容 + :param indent: 子级缩进 + :return: None + """ + lines.append(f'{prefix} |') + lines.extend(f'{indent}{line}' for line in value.splitlines()) + + def render_nested_lines(self, prefix: str, nested_data: dict[str, Any] | list[Any], indent_level: int) -> list[str]: + """ + 渲染嵌套字典或列表字段。 + + :param prefix: 当前字段前缀 + :param nested_data: 嵌套数据 + :param indent_level: 当前缩进层级 + :return: 渲染后的文本行列表 + """ + nested_lines = self.render_text_lines(nested_data, indent_level=indent_level + 1) + if len(nested_lines) == 1 and nested_lines[0].strip() in {'{}', '[]'}: + return [f'{prefix} {nested_lines[0].strip()}'] + return [prefix, *nested_lines] + + def render_mapping_lines(self, data: dict[str, Any], *, indent_level: int) -> list[str]: + """ + 将字典渲染为层级化文本输出。 + + :param data: 待渲染字典 + :param indent_level: 当前缩进层级 + :return: 文本输出行列表 + """ + indent = ' ' * indent_level + child_indent = ' ' * (indent_level + 1) + if not data: + return [f'{indent}{{}}'] + + lines: list[str] = [] + for key, value in data.items(): + field_name = self.format_field_name(key) + prefix = f'{indent}{field_name}:' + if isinstance(value, str) and '\n' in value: + self.append_multiline_text(lines, prefix, value, child_indent) + continue + if isinstance(value, dict | list): + lines.extend(self.render_nested_lines(prefix, value, indent_level)) + continue + lines.append(f'{prefix} {self.format_scalar(value)}') + return lines + + def render_list_lines(self, data: list[Any], *, indent_level: int) -> list[str]: + """ + 将列表渲染为层级化文本输出。 + + :param data: 待渲染列表 + :param indent_level: 当前缩进层级 + :return: 文本输出行列表 + """ + indent = ' ' * indent_level + child_indent = ' ' * (indent_level + 1) + if not data: + return [f'{indent}[]'] + + lines: list[str] = [] + for item in data: + item_prefix = f'{indent}-' + if isinstance(item, str) and '\n' in item: + self.append_multiline_text(lines, item_prefix, item, child_indent) + continue + if isinstance(item, dict | list): + lines.extend(self.render_nested_lines(item_prefix, item, indent_level)) + continue + lines.append(f'{item_prefix} {self.format_scalar(item)}') + return lines + + def render_text_lines(self, data: Any, *, indent_level: int = 0) -> list[str]: + """ + 将任意数据结构渲染为层级化文本输出。 + + :param data: 待渲染数据 + :param indent_level: 当前缩进层级 + :return: 文本输出行列表 + """ + if isinstance(data, dict): + return self.render_mapping_lines(data, indent_level=indent_level) + + if isinstance(data, list): + return self.render_list_lines(data, indent_level=indent_level) + + indent = ' ' * indent_level + if isinstance(data, str) and '\n' in data: + return [f'{indent}{line}' for line in data.splitlines()] + + return [f'{indent}{self.format_scalar(data)}'] + + +class OutputEmitter: + """ + 负责标准输出、标准错误与命令退出收口。 + + :param text_renderer: 结构化文本渲染器 + :param status_styler: 状态样式器 + """ + + def __init__(self, text_renderer: StructuredTextRenderer, status_styler: OutputStatusStyler) -> None: + """ + 初始化输出发射器。 + + :param text_renderer: 结构化文本渲染器 + :param status_styler: 状态样式器 + :return: None + """ + self.text_renderer = text_renderer + self.status_styler = status_styler + + def emit_output(self, data: Any, output_format: str) -> None: + """ + 输出命令结果。 + + :param data: 输出数据 + :param output_format: 输出格式 + :return: None + """ + if data is None: + return + if output_format == 'json': + print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) + return + rendered_text = self.text_renderer.decorate_text_output(self.text_renderer.render_text_output(data)) + print(self.text_renderer.colorize_text_output(rendered_text, sys.stdout)) + + def emit_error(self, message: str, output_format: str, *, exit_code: int) -> None: + """ + 输出错误信息。 + + :param message: 错误消息 + :param output_format: 输出格式 + :param exit_code: 退出码 + :return: None + """ + if output_format == 'json': + print( + json.dumps({'ok': False, 'error': message, 'exitCode': exit_code}, ensure_ascii=False), + file=sys.stderr, + ) + return + print(self.status_styler.render_error_text(message, exit_code, sys.stderr), file=sys.stderr) + + def complete_command(self, result: CommandResult, ctx: CliContext) -> None: + """ + 输出命令结果并结束当前命令。 + + :param result: 命令执行结果 + :param ctx: CLI 上下文 + :return: None + :raises typer.Exit: 始终通过 Typer 退出并返回对应退出码 + """ + if not result.already_printed: + self.emit_output(result.data, ctx.output) + raise typer.Exit(code=result.exit_code) + + +class OutputRenderer: + """ + CLI 输出渲染门面。 + + :param color_mode: 颜色模式 + :param icon_mode: 图标模式 + """ + + def __init__(self, *, color_mode: str = 'always', icon_mode: str = 'emoji') -> None: + """ + 初始化输出渲染器。 + + :param color_mode: 颜色模式,支持 `auto`、`always`、`never` + :param icon_mode: 图标模式,支持 `emoji`、`ascii`、`none` + :return: None + """ + self.settings = OutputSettings(color_mode=color_mode, icon_mode=icon_mode) + self.status_styler = OutputStatusStyler(self.settings) + self.text_renderer = StructuredTextRenderer(self.status_styler) + self.emitter = OutputEmitter(self.text_renderer, self.status_styler) + + @property + def color_mode(self) -> str: + """ + 返回当前颜色模式。 + + :return: 颜色模式 + """ + return self.settings.color_mode + + @property + def icon_mode(self) -> str: + """ + 返回当前图标模式。 + + :return: 图标模式 + """ + return self.settings.icon_mode + + def set_color_mode(self, color_mode: str) -> None: + """ + 设置颜色模式。 + + :param color_mode: 颜色模式 + :return: None + """ + self.settings.color_mode = color_mode + + def set_icon_mode(self, icon_mode: str) -> None: + """ + 设置图标模式。 + + :param icon_mode: 图标模式 + :return: None + """ + self.settings.icon_mode = icon_mode + + def supports_color(self, stream: Any) -> bool: + """ + 判断当前输出流是否应启用颜色。 + + :param stream: 输出流对象 + :return: 是否启用颜色 + """ + return self.status_styler.supports_color(stream) + + @staticmethod + def style_text(text: str, *, fg: str | None = None, bold: bool = False, dim: bool = False) -> str: + """ + 为文本附加终端样式。 + + :param text: 原始文本 + :param fg: 前景色 + :param bold: 是否加粗 + :param dim: 是否弱化显示 + :return: 样式化后的文本 + """ + return OutputStatusStyler.style_text(text, fg=fg, bold=bold, dim=dim) + + def build_status_token(self, kind: str) -> str: + """ + 根据当前图标模式构建状态标签文本。 + + :param kind: 标签类型 + :return: 标签文本 + """ + return self.status_styler.build_status_token(kind) + + def style_status_token(self, kind: str, *, fg: str) -> str: + """ + 将状态标签渲染为高亮样式。 + + :param kind: 标签类型 + :param fg: 前景色 + :return: 样式化后的标签文本 + """ + return self.status_styler.style_status_token(kind, fg=fg) + + def style_status_message(self, kind: str, text: str) -> str: + """ + 使用统一状态图标和颜色渲染消息文本。 + + :param kind: 状态类型 + :param text: 原始消息文本 + :return: 样式化后的消息文本 + """ + return self.status_styler.style_status_message(kind, text) + + def build_result_header(self, is_ok: bool) -> str: + """ + 构建统一的结果头部文本。 + + :param is_ok: 是否成功 + :return: 结果头部文本 + """ + return self.status_styler.build_result_header(is_ok) + + def style_scalar_text(self, value: str) -> str: + """ + 为标量文本值应用颜色样式。 + + :param value: 原始标量文本 + :return: 样式化后的文本 + """ + return self.status_styler.style_scalar_text(value) + + def style_inline_status_segment(self, segment: str) -> str: + """ + 为行内状态片段应用样式。 + + :param segment: 原始片段文本 + :return: 样式化后的片段文本 + """ + return self.status_styler.style_inline_status_segment(segment) + + def style_inline_status_value(self, value: str) -> str: + """ + 为带分隔符的行内状态值应用样式。 + + :param value: 原始字段值 + :return: 样式化后的字段值 + """ + return self.status_styler.style_inline_status_value(value) + + def style_named_value(self, key: str, value: str) -> str: + """ + 按字段语义为值应用样式。 + + :param key: 字段名 + :param value: 原始字段值 + :return: 样式化后的字段值 + """ + return self.status_styler.style_named_value(key, value) + + def style_line(self, line: str) -> str: + """ + 对单行文本输出应用可读性增强样式。 + + :param line: 原始输出行 + :return: 样式化后的输出行 + """ + return self.status_styler.style_line(line) + + def render_text_output(self, data: Any) -> str: + """ + 将任意数据渲染为纯文本输出。 + + :param data: 待渲染数据 + :return: 文本输出结果 + """ + return self.text_renderer.render_text_output(data) + + def colorize_text_output(self, text: str, stream: Any) -> str: + """ + 按终端能力为文本输出增加颜色。 + + :param text: 原始文本输出 + :param stream: 输出流对象 + :return: 样式化后的文本输出 + """ + return self.text_renderer.colorize_text_output(text, stream) + + def decorate_text_output(self, text: str) -> str: + """ + 对文本输出进行结构化装饰。 + + :param text: 原始文本输出 + :return: 装饰后的文本输出 + """ + return self.text_renderer.decorate_text_output(text) + + @staticmethod + def format_scalar(value: Any) -> str: + """ + 将标量值格式化为文本输出。 + + :param value: 待格式化的值 + :return: 文本格式化结果 + """ + return StructuredTextRenderer.format_scalar(value) + + @staticmethod + def format_field_name(key: object) -> str: + """ + 将字段名标准化为文本输出使用的 `snake_case` 形式。 + + :param key: 原始字段名 + :return: 标准化后的字段名 + """ + return StructuredTextRenderer.format_field_name(key) + + def append_multiline_text(self, lines: list[str], prefix: str, value: str, indent: str) -> None: + """ + 将多行文本追加到输出行列表中。 + + :param lines: 输出行列表 + :param prefix: 当前字段前缀 + :param value: 多行文本内容 + :param indent: 子级缩进 + :return: None + """ + self.text_renderer.append_multiline_text(lines, prefix, value, indent) + + def render_nested_lines(self, prefix: str, nested_data: dict[str, Any] | list[Any], indent_level: int) -> list[str]: + """ + 渲染嵌套字典或列表字段。 + + :param prefix: 当前字段前缀 + :param nested_data: 嵌套数据 + :param indent_level: 当前缩进层级 + :return: 渲染后的文本行列表 + """ + return self.text_renderer.render_nested_lines(prefix, nested_data, indent_level) + + def render_mapping_lines(self, data: dict[str, Any], *, indent_level: int) -> list[str]: + """ + 将字典渲染为层级化文本输出。 + + :param data: 待渲染字典 + :param indent_level: 当前缩进层级 + :return: 文本输出行列表 + """ + return self.text_renderer.render_mapping_lines(data, indent_level=indent_level) + + def render_list_lines(self, data: list[Any], *, indent_level: int) -> list[str]: + """ + 将列表渲染为层级化文本输出。 + + :param data: 待渲染列表 + :param indent_level: 当前缩进层级 + :return: 文本输出行列表 + """ + return self.text_renderer.render_list_lines(data, indent_level=indent_level) + + def render_text_lines(self, data: Any, *, indent_level: int = 0) -> list[str]: + """ + 将任意数据结构渲染为层级化文本输出。 + + :param data: 待渲染数据 + :param indent_level: 当前缩进层级 + :return: 文本输出行列表 + """ + return self.text_renderer.render_text_lines(data, indent_level=indent_level) + + def emit_output(self, data: Any, output_format: str) -> None: + """ + 输出命令结果。 + + :param data: 输出数据 + :param output_format: 输出格式 + :return: None + """ + self.emitter.emit_output(data, output_format) + + def render_error_text(self, message: str, exit_code: int, stream: Any) -> str: + """ + 渲染标准错误输出文本。 + + :param message: 错误消息 + :param exit_code: 退出码 + :param stream: 输出流对象 + :return: 渲染后的错误文本 + """ + return self.status_styler.render_error_text(message, exit_code, stream) + + def emit_error(self, message: str, output_format: str, *, exit_code: int) -> None: + """ + 输出错误信息。 + + :param message: 错误消息 + :param output_format: 输出格式 + :param exit_code: 退出码 + :return: None + """ + self.emitter.emit_error(message, output_format, exit_code=exit_code) + + def complete_command(self, result: CommandResult, ctx: CliContext) -> None: + """ + 输出命令结果并结束当前命令。 + + :param result: 命令执行结果 + :param ctx: CLI 上下文 + :return: None + """ + self.emitter.complete_command(result, ctx) diff --git a/shierfeng-fastapi-backend/cli/runtime/__init__.py b/shierfeng-fastapi-backend/cli/runtime/__init__.py new file mode 100644 index 0000000..efb1257 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/__init__.py @@ -0,0 +1,3 @@ +from cli.runtime.base import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService + +__all__ = ['RUNTIME_ENVIRONMENT', 'RuntimeEnvironmentService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/app/__init__.py b/shierfeng-fastapi-backend/cli/runtime/app/__init__.py new file mode 100644 index 0000000..47ddfc7 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/app/__init__.py @@ -0,0 +1,3 @@ +from .service import APP_RUNTIME, AppRuntimeService + +__all__ = ['APP_RUNTIME', 'AppRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/app/gateway.py b/shierfeng-fastapi-backend/cli/runtime/app/gateway.py new file mode 100644 index 0000000..2c46c3a --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/app/gateway.py @@ -0,0 +1,29 @@ +from importlib import import_module +from types import ModuleType + + +class AppInfrastructureGateway: + """ + 应用基础设施网关。 + + 该对象负责延迟加载应用实例构建和环境配置模块, + 供应用运行时 facade 与其协作对象统一复用。 + """ + + @staticmethod + def get_server_module() -> ModuleType: + """ + 获取应用 server 模块。 + + :return: server 模块 + """ + return import_module('server') + + @staticmethod + def get_env_module() -> ModuleType: + """ + 获取环境配置模块。 + + :return: 环境配置模块 + """ + return import_module('config.env') diff --git a/shierfeng-fastapi-backend/cli/runtime/app/service.py b/shierfeng-fastapi-backend/cli/runtime/app/service.py new file mode 100644 index 0000000..5cf5225 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/app/service.py @@ -0,0 +1,69 @@ +from typing import Any + +from cli.runtime.base import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService + +from .gateway import AppInfrastructureGateway +from .support import AppSnapshotSupport + + +class AppRuntimeService: + """ + 应用运行时服务。 + + 该服务作为应用运行时 facade,对外统一暴露应用实例构建、 + 应用配置快照与环境信息快照入口。 + + :param runtime_environment: 运行时环境服务 + :param infrastructure_gateway: 应用基础设施网关 + :param snapshot_support: 应用快照支持对象 + """ + + def __init__( + self, + *, + runtime_environment: RuntimeEnvironmentService | None = None, + infrastructure_gateway: AppInfrastructureGateway | None = None, + snapshot_support: AppSnapshotSupport | None = None, + ) -> None: + """ + 初始化应用运行时服务。 + + :param runtime_environment: 运行时环境服务 + :param infrastructure_gateway: 应用基础设施网关 + :param snapshot_support: 应用快照支持对象 + :return: None + """ + self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT + self.infrastructure_gateway = infrastructure_gateway or AppInfrastructureGateway() + self.snapshot_support = snapshot_support or AppSnapshotSupport( + self.infrastructure_gateway, + self.runtime_environment, + ) + + def build_app_instance(self) -> Any: + """ + 构建当前环境下的 FastAPI 应用实例。 + + :return: FastAPI 应用实例 + """ + server_module = self.infrastructure_gateway.get_server_module() + return server_module.create_app() + + def get_app_config_snapshot(self) -> dict[str, Any]: + """ + 读取当前运行环境的应用配置快照。 + + :return: 应用配置快照 + """ + return self.snapshot_support.build_app_config_snapshot() + + def get_app_env_snapshot(self) -> dict[str, Any]: + """ + 读取当前 CLI 进程的环境解析结果快照。 + + :return: 环境解析结果快照 + """ + return self.snapshot_support.build_app_env_snapshot() + + +APP_RUNTIME = AppRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/app/support.py b/shierfeng-fastapi-backend/cli/runtime/app/support.py new file mode 100644 index 0000000..169054c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/app/support.py @@ -0,0 +1,90 @@ +import os +from pathlib import Path +from typing import Any + +from cli.runtime.base import RuntimeEnvironmentService + +from .gateway import AppInfrastructureGateway + + +class AppSnapshotSupport: + """ + 应用快照支持对象。 + + 该对象负责应用配置快照和环境快照的构建, + 避免主运行时服务继续承载快照字段拼装细节。 + + :param infrastructure_gateway: 应用基础设施网关 + :param runtime_environment: 运行时环境服务 + """ + + def __init__( + self, + infrastructure_gateway: AppInfrastructureGateway, + runtime_environment: RuntimeEnvironmentService, + ) -> None: + """ + 初始化应用快照支持对象。 + + :param infrastructure_gateway: 应用基础设施网关 + :param runtime_environment: 运行时环境服务 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + self.runtime_environment = runtime_environment + + def build_app_config_snapshot(self) -> dict[str, Any]: + """ + 读取当前运行环境的应用配置快照。 + + :return: 应用配置快照 + """ + env_module = self.infrastructure_gateway.get_env_module() + app_config = env_module.AppConfig + database_config = env_module.DataBaseConfig + log_config = env_module.LogConfig + redis_config = env_module.RedisConfig + transport_crypto_config = env_module.TransportCryptoConfig + return { + 'env': app_config.app_env, + 'name': app_config.app_name, + 'host': app_config.app_host, + 'port': app_config.app_port, + 'rootPath': app_config.app_root_path, + 'reload': app_config.app_reload, + 'workers': app_config.app_workers, + 'disableSwagger': app_config.app_disable_swagger, + 'disableRedoc': app_config.app_disable_redoc, + 'dbType': database_config.db_type, + 'dbHost': database_config.db_host, + 'dbPort': database_config.db_port, + 'dbDatabase': database_config.db_database, + 'redisHost': redis_config.redis_host, + 'redisPort': redis_config.redis_port, + 'logLevel': log_config.loguru_level, + 'transportCryptoEnabled': transport_crypto_config.transport_crypto_enabled, + 'transportCryptoMode': transport_crypto_config.transport_crypto_mode, + } + + def build_app_env_snapshot(self) -> dict[str, Any]: + """ + 读取当前 CLI 进程的环境解析结果快照。 + + :return: 环境解析结果快照 + """ + env_module = self.infrastructure_gateway.get_env_module() + app_config = env_module.AppConfig + backend_dir = Path(self.runtime_environment.get_backend_dir()) + resolved_env = os.environ.get('APP_ENV', '') or 'dev' + env_file_name = f'.env.{resolved_env}' + env_file_path = backend_dir / env_file_name + return { + 'cliEnv': resolved_env, + 'configEnv': app_config.app_env, + 'appEnv': os.environ.get('APP_ENV', ''), + 'envFile': env_file_name, + 'envFilePath': str(env_file_path), + 'envFileExists': env_file_path.exists(), + 'backendDir': str(backend_dir), + 'pythonExecutable': self.runtime_environment.get_python_executable(), + } diff --git a/shierfeng-fastapi-backend/cli/runtime/base.py b/shierfeng-fastapi-backend/cli/runtime/base.py new file mode 100644 index 0000000..c9b37f5 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/base.py @@ -0,0 +1,72 @@ +import getpass +import os +import sys +from pathlib import Path + + +class RuntimeEnvironmentService: + """ + CLI 运行时环境服务。 + + 该服务集中管理后端根目录解析、Python 解释器定位等稳定运行时能力, + 避免调用方继续依赖零散模块级函数。 + """ + + @staticmethod + def is_backend_project_dir(project_dir: Path) -> bool: + """ + 判断目录是否为后端项目根目录。 + + :param project_dir: 待检查目录 + :return: 是否为后端项目根目录 + """ + return ( + (project_dir / 'app.py').exists() + and (project_dir / 'config' / 'env.py').exists() + and (project_dir / 'cli').is_dir() + ) + + def get_backend_dir(self) -> str: + """ + 获取后端根目录绝对路径。 + + :return: 后端根目录绝对路径 + """ + current_dir = Path.cwd().resolve() + if self.is_backend_project_dir(current_dir): + return str(current_dir) + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + @staticmethod + def get_python_executable() -> str: + """ + 获取当前 CLI 进程使用的 Python 解释器。 + + :return: Python 解释器路径或命令名 + """ + return sys.executable or 'python' + + +class RuntimeOperatorService: + """ + CLI 运行时操作者解析服务。 + + 该服务集中提供当前 CLI 进程的操作者名称解析能力, + 避免各领域运行时对象继续复制相同的 `getpass.getuser()` 回退逻辑。 + """ + + @staticmethod + def resolve_operator() -> str: + """ + 解析当前 CLI 操作者名称。 + + :return: 操作者名称 + """ + try: + return getpass.getuser() or 'shierfeng-cli' + except Exception: + return 'shierfeng-cli' + + +RUNTIME_ENVIRONMENT = RuntimeEnvironmentService() +RUNTIME_OPERATOR = RuntimeOperatorService() diff --git a/shierfeng-fastapi-backend/cli/runtime/cache/__init__.py b/shierfeng-fastapi-backend/cli/runtime/cache/__init__.py new file mode 100644 index 0000000..2b6dbb9 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/cache/__init__.py @@ -0,0 +1,3 @@ +from .service import CACHE_RUNTIME, CacheRuntimeService + +__all__ = ['CACHE_RUNTIME', 'CacheRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/cache/gateway.py b/shierfeng-fastapi-backend/cli/runtime/cache/gateway.py new file mode 100644 index 0000000..9dd40a1 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/cache/gateway.py @@ -0,0 +1,41 @@ +from importlib import import_module +from typing import Any + +REDIS_TTL_KEY_NOT_FOUND = -2 +REDIS_TTL_PERSISTENT = -1 + + +class CacheInfrastructureGateway: + """ + 缓存基础设施网关。 + + 该对象负责延迟加载 Redis 相关基础设施依赖, + 供缓存运行时 facade 和其协作对象统一复用。 + """ + + @staticmethod + def get_redis_error_class() -> type[Exception]: + """ + 获取 Redis 异常类型。 + + :return: Redis 异常类型 + """ + return import_module('redis.exceptions').RedisError + + @staticmethod + def get_redis_init_key_config() -> Any: + """ + 获取系统缓存初始化键枚举。 + + :return: 缓存初始化键枚举 + """ + return import_module('common.enums').RedisInitKeyConfig + + @staticmethod + def get_redis_util() -> Any: + """ + 获取 Redis 工具类。 + + :return: Redis 工具类 + """ + return import_module('config.get_redis').RedisUtil diff --git a/shierfeng-fastapi-backend/cli/runtime/cache/service.py b/shierfeng-fastapi-backend/cli/runtime/cache/service.py new file mode 100644 index 0000000..b6a9cf7 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/cache/service.py @@ -0,0 +1,216 @@ +from typing import Any + +from cli.exit_codes import RUNTIME_ERROR + +from .gateway import REDIS_TTL_KEY_NOT_FOUND, REDIS_TTL_PERSISTENT, CacheInfrastructureGateway +from .support import CacheDomainSupport, CacheRedisSupport + + +class CacheRuntimeService: + """ + 缓存运行时服务。 + + 该服务作为缓存运行时 facade,对外统一暴露缓存统计、键列表、键值、 + TTL 读取,以及缓存清理和预热入口。 + + :param infrastructure_gateway: 缓存基础设施网关 + :param domain_support: 缓存领域支持对象 + :param redis_support: 缓存 Redis 访问支持对象 + """ + + def __init__( + self, + *, + infrastructure_gateway: CacheInfrastructureGateway | None = None, + domain_support: CacheDomainSupport | None = None, + redis_support: CacheRedisSupport | None = None, + ) -> None: + """ + 初始化缓存运行时服务。 + + :param infrastructure_gateway: 缓存基础设施网关 + :param domain_support: 缓存领域支持对象 + :param redis_support: 缓存 Redis 访问支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway or CacheInfrastructureGateway() + self.domain_support = domain_support or CacheDomainSupport(self.infrastructure_gateway) + self.redis_support = redis_support or CacheRedisSupport(self.infrastructure_gateway, self.domain_support) + + async def get_cache_stats(self) -> dict[str, Any]: + """ + 获取缓存统计信息。 + + :return: 缓存统计结果 + """ + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + async with self.redis_support.redis_session() as (redis, _redis_util): + info = await redis.info() + db_size = await redis.dbsize() + command_stats_dict = await redis.info('commandstats') + command_stats = [ + {'name': key.split('_', 1)[1], 'value': int(value.get('calls', 0))} + for key, value in sorted(command_stats_dict.items(), key=lambda item: item[0]) + ] + return { + 'ok': True, + 'dbSize': db_size, + 'commandStats': command_stats, + 'cacheNames': self.domain_support.build_cache_name_items(), + 'info': info, + } + except redis_error as exc: + return self.redis_support.build_redis_error_result('读取缓存统计失败', exc) + + async def list_cache_keys(self, cache_name: str) -> dict[str, Any]: + """ + 列出指定缓存名称下的键名。 + + :param cache_name: 缓存名称 + :return: 缓存键名列表结果 + """ + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + async with self.redis_support.redis_session() as (redis, _redis_util): + cache_keys: list[str] = await redis.keys(self.redis_support.build_cache_name_keys_pattern(cache_name)) + key_items = self.domain_support.extract_cache_key_items(cache_name, cache_keys) + return {'ok': True, 'cacheName': cache_name, 'count': len(key_items), 'keys': key_items} + except redis_error as exc: + return self.redis_support.build_redis_error_result('读取缓存键名失败', exc) + + async def get_cache_value(self, cache_name: str, cache_key: str) -> dict[str, Any]: + """ + 读取指定缓存键值。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :return: 缓存值结果 + """ + redis_error = self.infrastructure_gateway.get_redis_error_class() + full_cache_key = self.domain_support.build_full_cache_key(cache_name, cache_key) + try: + async with self.redis_support.redis_session() as (redis, _redis_util): + cache_value = await redis.get(full_cache_key) + if cache_value is None: + return self.domain_support.build_missing_cache_result(cache_name, cache_key) + return { + 'ok': True, + 'cacheName': cache_name, + 'cacheKey': cache_key, + 'fullCacheKey': full_cache_key, + 'cacheValue': cache_value, + } + except redis_error as exc: + return self.redis_support.build_redis_error_result('读取缓存内容失败', exc) + + async def get_cache_ttl(self, cache_name: str, cache_key: str) -> dict[str, Any]: + """ + 读取指定缓存键的剩余过期时间。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :return: 缓存 TTL 结果 + """ + redis_error = self.infrastructure_gateway.get_redis_error_class() + full_cache_key = self.domain_support.build_full_cache_key(cache_name, cache_key) + try: + async with self.redis_support.redis_session() as (redis, _redis_util): + ttl_seconds = await redis.ttl(full_cache_key) + if ttl_seconds == REDIS_TTL_KEY_NOT_FOUND: + return self.domain_support.build_missing_cache_result(cache_name, cache_key) + return { + 'ok': True, + 'message': '缓存剩余过期时间读取成功' if ttl_seconds >= 0 else '缓存存在且未设置过期时间', + 'cacheName': cache_name, + 'cacheKey': cache_key, + 'fullCacheKey': full_cache_key, + 'ttlSeconds': ttl_seconds, + 'persistent': ttl_seconds == REDIS_TTL_PERSISTENT, + 'expires': ttl_seconds >= 0, + } + except redis_error as exc: + return self.redis_support.build_redis_error_result('读取缓存剩余过期时间失败', exc) + + async def clear_cache( + self, + *, + cache_name: str = '', + cache_key: str = '', + clear_all: bool = False, + dry_run: bool = False, + ) -> dict[str, Any]: + """ + 清理缓存。 + + :param cache_name: 需要按缓存名称清理的前缀 + :param cache_key: 需要按缓存键名模糊清理的键 + :param clear_all: 是否清理全部缓存 + :param dry_run: 是否仅演练执行 + :return: 缓存清理结果 + """ + selected_modes = [bool(cache_name), bool(cache_key), clear_all] + if sum(selected_modes) != 1: + return { + 'ok': False, + 'message': '必须且只能指定一种清理方式:--cache-name、--cache-key 或 --all', + 'exit_code': RUNTIME_ERROR, + } + + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + async with self.redis_support.redis_session() as (redis, redis_util): + target_pattern = self.redis_support.build_clear_target_pattern( + cache_name=cache_name, + cache_key=cache_key, + clear_all=clear_all, + ) + if target_pattern is None: + target_keys = sorted(await redis.keys()) + else: + target_keys = sorted(await redis.keys(target_pattern)) + + result = { + 'ok': True, + 'dryRun': dry_run, + 'matchedCount': len(target_keys), + 'matchedKeys': target_keys, + 'scope': self.domain_support.build_clear_scope( + cache_name=cache_name, + cache_key=cache_key, + clear_all=clear_all, + ), + } + if dry_run: + result['message'] = '缓存清理演练完成,未执行实际删除' + return result + + if target_keys: + await redis.delete(*target_keys) + + if clear_all: + await redis_util.init_sys_dict(redis) + await redis_util.init_sys_config(redis) + + result['message'] = '缓存清理完成' + return result + except redis_error as exc: + return self.redis_support.build_redis_error_result('清理缓存失败', exc) + + async def warmup_cache(self) -> dict[str, Any]: + """ + 预热系统缓存。 + + :return: 缓存预热执行结果 + """ + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + async with self.redis_support.redis_session() as (redis, redis_util): + await redis_util.init_sys_dict(redis) + await redis_util.init_sys_config(redis) + return {'ok': True, 'message': '缓存预热完成'} + except redis_error as exc: + return self.redis_support.build_redis_error_result('缓存预热失败', exc) + + +CACHE_RUNTIME = CacheRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/cache/support.py b/shierfeng-fastapi-backend/cli/runtime/cache/support.py new file mode 100644 index 0000000..72db4df --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/cache/support.py @@ -0,0 +1,201 @@ +from contextlib import asynccontextmanager +from typing import Any + +from cli.exit_codes import REDIS_ERROR, RUNTIME_ERROR + +from .gateway import CacheInfrastructureGateway + + +class CacheDomainSupport: + """ + 缓存领域支持对象。 + + 该对象负责缓存名称列表构建、键范围解析和 TTL 结果判定等 + 局部缓存规则,避免主运行时服务继续承载细碎领域判断。 + + :param infrastructure_gateway: 缓存基础设施网关 + """ + + def __init__(self, infrastructure_gateway: CacheInfrastructureGateway) -> None: + """ + 初始化缓存领域支持对象。 + + :param infrastructure_gateway: 缓存基础设施网关 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + + def build_cache_name_items(self) -> list[dict[str, Any]]: + """ + 构建系统内置缓存名称列表。 + + :return: 缓存名称列表 + """ + redis_init_key_config = self.infrastructure_gateway.get_redis_init_key_config() + return [ + { + 'cacheName': key_config.key, + 'remark': key_config.remark, + } + for key_config in redis_init_key_config + ] + + @staticmethod + def build_full_cache_key(cache_name: str, cache_key: str) -> str: + """ + 构建缓存完整键名。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :return: 完整缓存键名 + """ + return f'{cache_name}:{cache_key}' + + def build_missing_cache_result(self, cache_name: str, cache_key: str) -> dict[str, Any]: + """ + 构建缓存不存在的统一结果。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :return: 缓存不存在结果 + """ + full_cache_key = self.build_full_cache_key(cache_name, cache_key) + return { + 'ok': False, + 'message': f'缓存不存在:{full_cache_key}', + 'cacheName': cache_name, + 'cacheKey': cache_key, + 'fullCacheKey': full_cache_key, + 'exit_code': RUNTIME_ERROR, + } + + @staticmethod + def build_cache_name_pattern(cache_name: str) -> str: + """ + 构建按缓存名称扫描时使用的 Redis 键模式。 + + :param cache_name: 缓存名称 + :return: Redis 键匹配模式 + """ + return f'{cache_name}:*' + + @staticmethod + def extract_cache_key_items(cache_name: str, cache_keys: list[str]) -> list[str]: + """ + 从缓存完整键名列表中提取相对键名。 + + :param cache_name: 缓存名称 + :param cache_keys: 完整缓存键名列表 + :return: 相对键名列表 + """ + return [key.split(':', 1)[1] for key in sorted(cache_keys) if key.startswith(f'{cache_name}:')] + + @staticmethod + def build_clear_scope( + *, + cache_name: str, + cache_key: str, + clear_all: bool, + ) -> dict[str, str]: + """ + 构建缓存清理范围定义。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名片段 + :param clear_all: 是否清理全部缓存 + :return: 清理范围定义 + """ + if clear_all: + return {'mode': 'all'} + if cache_name: + return {'mode': 'cacheName', 'cacheName': cache_name} + return {'mode': 'cacheKey', 'cacheKey': cache_key} + + +class CacheRedisSupport: + """ + 缓存 Redis 访问支持对象。 + + 该对象负责 Redis 连接生命周期、错误结果规整和缓存键模式拼装, + 避免主运行时服务继续承载基础设施桥接细节。 + + :param infrastructure_gateway: 缓存基础设施网关 + :param domain_support: 缓存领域支持对象 + """ + + def __init__( + self, + infrastructure_gateway: CacheInfrastructureGateway, + domain_support: CacheDomainSupport, + ) -> None: + """ + 初始化缓存 Redis 访问支持对象。 + + :param infrastructure_gateway: 缓存基础设施网关 + :param domain_support: 缓存领域支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + self.domain_support = domain_support + + @asynccontextmanager + async def redis_session(self) -> Any: + """ + 创建并托管 Redis 会话。 + + :return: `(redis, redis_util)` 元组 + """ + redis = None + redis_util = self.infrastructure_gateway.get_redis_util() + try: + redis = await redis_util.create_redis_pool(log_enabled=False) + yield redis, redis_util + finally: + if redis is not None: + await redis.close() + + @staticmethod + def build_redis_error_result(message: str, exc: Exception) -> dict[str, Any]: + """ + 构建统一 Redis 异常结果。 + + :param message: 失败消息 + :param exc: 原始异常 + :return: 标准失败结果 + """ + return { + 'ok': False, + 'message': message, + 'error': str(exc), + 'exit_code': REDIS_ERROR, + } + + def build_cache_name_keys_pattern(self, cache_name: str) -> str: + """ + 构建按缓存名称读取键列表时使用的模式。 + + :param cache_name: 缓存名称 + :return: Redis 键匹配模式 + """ + return self.domain_support.build_cache_name_pattern(cache_name) + + def build_clear_target_pattern( + self, + *, + cache_name: str, + cache_key: str, + clear_all: bool, + ) -> str | None: + """ + 构建缓存清理目标的 Redis 键匹配模式。 + + :param cache_name: 缓存名称 + :param cache_key: 缓存键名片段 + :param clear_all: 是否清理全部缓存 + :return: Redis 键匹配模式;全部清理时返回 `None` + """ + if clear_all: + return None + if cache_name: + return self.domain_support.build_cache_name_pattern(cache_name) + return f'*{cache_key}' diff --git a/shierfeng-fastapi-backend/cli/runtime/config/__init__.py b/shierfeng-fastapi-backend/cli/runtime/config/__init__.py new file mode 100644 index 0000000..3f60116 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/config/__init__.py @@ -0,0 +1,3 @@ +from .service import CONFIG_RUNTIME, ConfigRuntimeService + +__all__ = ['CONFIG_RUNTIME', 'ConfigRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/config/gateway.py b/shierfeng-fastapi-backend/cli/runtime/config/gateway.py new file mode 100644 index 0000000..bd4eb6c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/config/gateway.py @@ -0,0 +1,101 @@ +from importlib import import_module +from typing import Any + + +class ConfigInfrastructureGateway: + """ + 参数配置基础设施网关。 + + 该对象负责延迟加载数据库会话、Redis、DAO、Service 与脱敏工具依赖, + 供参数配置运行时 facade 和其协作对象统一复用。 + """ + + @staticmethod + def get_redis_error_class() -> type[Exception]: + """ + 获取 Redis 异常类型。 + + :return: Redis 异常类型 + """ + return import_module('redis.exceptions').RedisError + + @staticmethod + def get_common_constant() -> Any: + """ + 获取公共常量定义。 + + :return: 公共常量定义模块 + """ + return import_module('common.constant').CommonConstant + + @staticmethod + def get_redis_init_key_config() -> Any: + """ + 获取 Redis 初始化键枚举。 + + :return: Redis 初始化键枚举 + """ + return import_module('common.enums').RedisInitKeyConfig + + @staticmethod + def get_page_model() -> Any: + """ + 获取分页模型类型。 + + :return: 分页模型类型 + """ + return import_module('common.vo').PageModel + + @staticmethod + def get_async_session_local() -> Any: + """ + 获取异步数据库会话工厂。 + + :return: 异步数据库会话工厂 + """ + return import_module('config.database').AsyncSessionLocal + + @staticmethod + def get_redis_util() -> Any: + """ + 获取 Redis 工具类。 + + :return: Redis 工具类 + """ + return import_module('config.get_redis').RedisUtil + + @staticmethod + def get_config_dao() -> Any: + """ + 获取参数配置 DAO。 + + :return: 参数配置 DAO + """ + return import_module('module_admin.dao.config_dao').ConfigDao + + @staticmethod + def get_config_vo_module() -> Any: + """ + 获取参数配置 VO 模块。 + + :return: 参数配置 VO 模块 + """ + return import_module('module_admin.entity.vo.config_vo') + + @staticmethod + def get_config_service() -> Any: + """ + 获取参数配置服务类。 + + :return: 参数配置服务类 + """ + return import_module('module_admin.service.config_service').ConfigService + + @staticmethod + def get_log_sanitizer() -> Any: + """ + 获取日志脱敏工具类。 + + :return: 日志脱敏工具类 + """ + return import_module('utils.log_util').LogSanitizer diff --git a/shierfeng-fastapi-backend/cli/runtime/config/service.py b/shierfeng-fastapi-backend/cli/runtime/config/service.py new file mode 100644 index 0000000..af5331a --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/config/service.py @@ -0,0 +1,406 @@ +from typing import Any, Literal + +from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, REDIS_ERROR + +from .gateway import ConfigInfrastructureGateway +from .support import ConfigDomainSupport + + +class ConfigRuntimeService: + """ + 参数配置运行时服务。 + + 该服务作为参数配置运行时 facade,对外统一暴露参数配置列表、详情、 + 写入、缓存刷新与一致性诊断入口。 + + :param infrastructure_gateway: 参数配置基础设施网关 + :param domain_support: 参数配置领域支持对象 + """ + + def __init__( + self, + *, + infrastructure_gateway: ConfigInfrastructureGateway | None = None, + domain_support: ConfigDomainSupport | None = None, + ) -> None: + """ + 初始化参数配置运行时服务。 + + :param infrastructure_gateway: 参数配置基础设施网关 + :param domain_support: 参数配置领域支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway or ConfigInfrastructureGateway() + self.domain_support = domain_support or ConfigDomainSupport(self.infrastructure_gateway) + + @staticmethod + def build_cli_config_model(config_vo_module: Any, config_record: Any) -> Any | None: + """ + 将 ORM 配置记录显式转换为 CLI 可用的配置模型。 + + `ConfigModel` 当前未统一开启 `populate_by_name`,因此 CLI 侧在处理 + ORM 蛇形字段对象时,需要主动映射为驼峰 alias 输入,避免详情查询结果 + 被静默序列化为空。 + + :param config_vo_module: 配置 VO 模块 + :param config_record: ORM 配置记录 + :return: CLI 可用配置模型 + """ + if config_record is None: + return None + return config_vo_module.ConfigModel( + configId=getattr(config_record, 'config_id', None), + configName=getattr(config_record, 'config_name', None), + configKey=getattr(config_record, 'config_key', None), + configValue=getattr(config_record, 'config_value', None), + configType=getattr(config_record, 'config_type', None), + createBy=getattr(config_record, 'create_by', None), + createTime=getattr(config_record, 'create_time', None), + updateBy=getattr(config_record, 'update_by', None), + updateTime=getattr(config_record, 'update_time', None), + remark=getattr(config_record, 'remark', None), + ) + + async def load_config_from_database( + self, + config_key: str, + ) -> tuple[Any | None, dict[str, Any] | None] | dict[str, Any]: + """ + 从数据库加载参数配置。 + + :param config_key: 参数键名 + :return: 成功时返回配置模型与序列化结果,失败时返回错误结果字典 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + config_dao = self.infrastructure_gateway.get_config_dao() + config_vo_module = self.infrastructure_gateway.get_config_vo_module() + try: + async with async_session_local() as session: + config_record = await config_dao.get_config_detail_by_info( + session, + config_vo_module.ConfigModel(configKey=config_key), + ) + except Exception as exc: + return {'ok': False, 'message': '读取参数配置失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + database_model = self.build_cli_config_model(config_vo_module, config_record) + return database_model, self.domain_support.serialize_config_model(database_model) + + async def load_config_from_cache( + self, + config_key: str, + ) -> tuple[str | None, dict[str, Any] | None] | dict[str, Any]: + """ + 从缓存加载参数配置。 + + :param config_key: 参数键名 + :return: 成功时返回原始缓存值与序列化结果,失败时返回错误结果字典 + """ + redis = None + redis_util = self.infrastructure_gateway.get_redis_util() + config_service = self.infrastructure_gateway.get_config_service() + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + redis = await redis_util.create_redis_pool(log_enabled=False) + cache_value = await config_service.query_config_list_from_cache_services(redis, config_key) + return cache_value, self.domain_support.serialize_cache_payload(config_key, cache_value) + except redis_error as exc: + return {'ok': False, 'message': '读取参数缓存失败', 'error': str(exc), 'exit_code': REDIS_ERROR} + finally: + if redis is not None: + await redis.close() + + async def list_configs( + self, + config_name: str = '', + config_key: str = '', + config_type: Literal['Y', 'N'] | None = None, + begin_date: str = '', + end_date: str = '', + *, + paged: bool = False, + page_num: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """ + 查询参数配置列表。 + + :param config_name: 参数名称过滤条件 + :param config_key: 参数键名过滤条件 + :param config_type: 参数类型过滤条件 + :param begin_date: 开始日期 + :param end_date: 结束日期 + :param paged: 是否开启分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: CLI 标准结果字典 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + config_service = self.infrastructure_gateway.get_config_service() + query_model = self.domain_support.build_config_query( + config_name, + config_key, + config_type, + begin_date, + end_date, + page_num, + page_size, + ) + try: + async with async_session_local() as session: + result = await config_service.get_config_list_services(session, query_model, is_page=paged) + except Exception as exc: + return {'ok': False, 'message': '读取参数配置列表失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + filters = self.domain_support.build_list_filters( + config_name=config_name, + config_key=config_key, + config_type=config_type, + begin_date=begin_date, + end_date=end_date, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + if paged and isinstance(result, self.infrastructure_gateway.get_page_model()): + page_payload = result.model_dump(by_alias=True) + page_payload['rows'] = [ + self.domain_support.sanitize_config_mapping(item) for item in page_payload.get('rows', []) + ] + return {'ok': True, 'filters': filters, 'page': page_payload} + + items = [self.domain_support.sanitize_config_mapping(item) for item in result] + return {'ok': True, 'filters': filters, 'count': len(items), 'items': items} + + async def get_config( + self, + config_key: str, + *, + source: Literal['db', 'cache', 'both'] = 'both', + ) -> dict[str, Any]: + """ + 按参数键读取配置详情。 + + :param config_key: 参数键名 + :param source: 读取来源 + :return: CLI 标准结果字典 + """ + database_model: Any | None = None + database_payload: dict[str, Any] | None = None + cache_value: str | None = None + cache_payload: dict[str, Any] | None = None + + if source in {'db', 'both'}: + database_result = await self.load_config_from_database(config_key) + if isinstance(database_result, dict): + return database_result + database_model, database_payload = database_result + + if source in {'cache', 'both'}: + cache_result = await self.load_config_from_cache(config_key) + if isinstance(cache_result, dict): + error_payload = {**cache_result, 'source': source} + if database_payload is not None: + error_payload['database'] = database_payload + return error_payload + cache_value, cache_payload = cache_result + + if source == 'db' and database_payload is None: + return self.domain_support.build_missing_config_result(config_key, source) + if source == 'cache' and cache_payload is None: + return self.domain_support.build_missing_config_result(config_key, source) + if source == 'both' and database_payload is None and cache_payload is None: + return self.domain_support.build_missing_config_result(config_key, source) + + payload = {'ok': True, 'key': config_key, 'source': source} + if source in {'db', 'both'}: + payload['database'] = database_payload + if source in {'cache', 'both'}: + payload['cache'] = cache_payload + if source == 'both': + payload['inSync'] = (database_model.config_value if database_model else None) == cache_value + return payload + + async def set_config( + self, + config_key: str, + config_value: str, + *, + config_name: str | None = None, + config_type: Literal['Y', 'N'] | None = None, + remark: str | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """ + 新增或更新参数配置,并同步缓存。 + + :param config_key: 参数键名 + :param config_value: 参数键值 + :param config_name: 参数名称 + :param config_type: 参数类型 + :param remark: 备注 + :param dry_run: 是否仅执行演练 + :return: CLI 标准结果字典 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + config_dao = self.infrastructure_gateway.get_config_dao() + config_vo_module = self.infrastructure_gateway.get_config_vo_module() + redis_util = self.infrastructure_gateway.get_redis_util() + redis_init_key_config = self.infrastructure_gateway.get_redis_init_key_config() + redis_error = self.infrastructure_gateway.get_redis_error_class() + existing_config = None + target_config = None + try: + async with async_session_local() as session: + config_record = await config_dao.get_config_detail_by_info( + session, + config_vo_module.ConfigModel(configKey=config_key), + ) + existing_config = self.build_cli_config_model(config_vo_module, config_record) + if existing_config is None and not config_name: + return { + 'ok': False, + 'message': '新增参数配置时必须传入 --name', + 'exit_code': ARGUMENT_ERROR, + } + + target_config = self.domain_support.build_target_config_model( + config_key, + config_value, + config_name, + config_type, + remark, + existing_config, + ) + if dry_run: + return { + 'ok': True, + 'message': '参数配置演练完成,未执行实际写入', + 'dryRun': True, + 'action': 'update' if existing_config else 'create', + 'config': self.domain_support.serialize_config_model(target_config), + } + + if existing_config: + await config_dao.edit_config_dao(session, target_config.model_dump(exclude_none=True)) + else: + await config_dao.add_config_dao(session, target_config) + await session.commit() + except Exception as exc: + return {'ok': False, 'message': '写入参数配置失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + redis = None + try: + redis = await redis_util.create_redis_pool(log_enabled=False) + await redis.set(f'{redis_init_key_config.SYS_CONFIG.key}:{config_key}', config_value) + except redis_error as exc: + return { + 'ok': False, + 'message': '参数配置已写入数据库,但同步缓存失败', + 'error': str(exc), + 'config': self.domain_support.serialize_config_model(target_config), + 'databaseCommitted': True, + 'exit_code': REDIS_ERROR, + } + finally: + if redis is not None: + await redis.close() + + return { + 'ok': True, + 'message': '参数配置已同步到数据库和缓存', + 'action': 'update' if existing_config else 'create', + 'config': self.domain_support.serialize_config_model(target_config), + } + + async def sync_config_cache(self) -> dict[str, Any]: + """ + 刷新参数配置缓存。 + + :return: CLI 标准结果字典 + """ + redis = None + async_session_local = self.infrastructure_gateway.get_async_session_local() + config_service = self.infrastructure_gateway.get_config_service() + config_vo_module = self.infrastructure_gateway.get_config_vo_module() + redis_util = self.infrastructure_gateway.get_redis_util() + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + async with async_session_local() as session: + config_list = await config_service.get_config_list_services( + session, + config_vo_module.ConfigPageQueryModel(), + is_page=False, + ) + redis = await redis_util.create_redis_pool(log_enabled=False) + await config_service.init_cache_sys_config_services(session, redis) + except redis_error as exc: + return {'ok': False, 'message': '刷新参数缓存失败', 'error': str(exc), 'exit_code': REDIS_ERROR} + except Exception as exc: + return {'ok': False, 'message': '刷新参数缓存失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + finally: + if redis is not None: + await redis.close() + + return {'ok': True, 'message': '参数缓存刷新成功', 'count': len(config_list)} + + async def diagnose_config(self, *, sample_limit: int = 10) -> dict[str, Any]: + """ + 诊断数据库参数配置与 Redis 缓存的一致性状态。 + + :param sample_limit: 示例键名输出上限 + :return: CLI 标准结果字典 + """ + redis = None + async_session_local = self.infrastructure_gateway.get_async_session_local() + config_service = self.infrastructure_gateway.get_config_service() + config_vo_module = self.infrastructure_gateway.get_config_vo_module() + redis_util = self.infrastructure_gateway.get_redis_util() + redis_init_key_config = self.infrastructure_gateway.get_redis_init_key_config() + redis_error = self.infrastructure_gateway.get_redis_error_class() + try: + async with async_session_local() as session: + config_list = await config_service.get_config_list_services( + session, + config_vo_module.ConfigPageQueryModel(), + is_page=False, + ) + redis = await redis_util.create_redis_pool(log_enabled=False) + cache_prefix = f'{redis_init_key_config.SYS_CONFIG.key}:' + cache_keys = sorted(await redis.keys(f'{cache_prefix}*')) + cache_values = await redis.mget(*cache_keys) if cache_keys else [] + except redis_error as exc: + return {'ok': False, 'message': '读取参数缓存诊断信息失败', 'error': str(exc), 'exit_code': REDIS_ERROR} + except Exception as exc: + return {'ok': False, 'message': '读取参数配置诊断信息失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + finally: + if redis is not None: + await redis.close() + + database_map = {str(item.get('configKey')): str(item.get('configValue', '')) for item in config_list} + cache_map = { + cache_key.replace(cache_prefix, '', 1): '' if cache_value is None else str(cache_value) + for cache_key, cache_value in zip(cache_keys, cache_values, strict=False) + } + missing_in_cache = sorted(key for key in database_map if key not in cache_map) + orphan_in_cache = sorted(key for key in cache_map if key not in database_map) + mismatch_keys = sorted( + key for key, database_value in database_map.items() if key in cache_map and database_value != cache_map[key] + ) + in_sync = not missing_in_cache and not orphan_in_cache and not mismatch_keys + return { + 'ok': in_sync, + 'message': '参数配置数据库与缓存一致' if in_sync else '参数配置存在数据库与缓存不一致项', + 'databaseCount': len(database_map), + 'cacheCount': len(cache_map), + 'missingInCacheCount': len(missing_in_cache), + 'orphanInCacheCount': len(orphan_in_cache), + 'mismatchCount': len(mismatch_keys), + 'sampleLimit': sample_limit, + 'missingInCache': missing_in_cache[:sample_limit], + 'orphanInCache': orphan_in_cache[:sample_limit], + 'mismatchKeys': mismatch_keys[:sample_limit], + } + + +CONFIG_RUNTIME = ConfigRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/config/support.py b/shierfeng-fastapi-backend/cli/runtime/config/support.py new file mode 100644 index 0000000..268b1fd --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/config/support.py @@ -0,0 +1,191 @@ +from datetime import datetime +from typing import Any, Literal + +from cli.exit_codes import RUNTIME_ERROR +from cli.runtime.base import RUNTIME_OPERATOR, RuntimeOperatorService + +from .gateway import ConfigInfrastructureGateway + + +class ConfigDomainSupport: + """ + 参数配置领域支持对象。 + + 该对象负责操作者解析、配置脱敏、序列化、查询模型构建与缺失结果构建, + 避免主运行时服务继续承载大量领域规则。 + + :param infrastructure_gateway: 参数配置基础设施网关 + """ + + def __init__( + self, + infrastructure_gateway: ConfigInfrastructureGateway, + operator_service: RuntimeOperatorService = RUNTIME_OPERATOR, + ) -> None: + """ + 初始化参数配置领域支持对象。 + + :param infrastructure_gateway: 参数配置基础设施网关 + :param operator_service: 运行时操作者解析服务 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + self.operator_service = operator_service + + def sanitize_config_mapping(self, config_mapping: dict[str, Any]) -> dict[str, Any]: + """ + 对配置字典进行脱敏处理。 + + :param config_mapping: 原始配置字典 + :return: 脱敏后的配置字典 + """ + return self.infrastructure_gateway.get_log_sanitizer().sanitize_data(config_mapping) + + def serialize_config_model(self, config_model: Any | None) -> dict[str, Any] | None: + """ + 将配置模型序列化为 CLI 输出字典。 + + :param config_model: 配置模型对象 + :return: 序列化后的配置字典 + """ + if config_model is None or config_model.config_id is None: + return None + return self.sanitize_config_mapping(config_model.model_dump(by_alias=True, exclude_none=True)) + + def serialize_cache_payload(self, config_key: str, config_value: str | None) -> dict[str, Any] | None: + """ + 将缓存中的配置值序列化为 CLI 输出字典。 + + :param config_key: 参数键名 + :param config_value: 参数键值 + :return: 序列化后的缓存配置字典 + """ + if config_value is None: + return None + return self.sanitize_config_mapping({'configKey': config_key, 'configValue': config_value}) + + @staticmethod + def build_missing_config_result( + config_key: str, + source: Literal['db', 'cache', 'both'], + ) -> dict[str, Any]: + """ + 构建配置不存在的统一结果。 + + :param config_key: 参数键名 + :param source: 读取来源 + :return: 配置不存在的结果字典 + """ + message = f'参数缓存不存在:{config_key}' if source == 'cache' else f'参数配置不存在:{config_key}' + return {'ok': False, 'message': message, 'source': source, 'exit_code': RUNTIME_ERROR} + + def build_config_query( + self, + config_name: str, + config_key: str, + config_type: Literal['Y', 'N'] | None, + begin_date: str, + end_date: str, + page_num: int, + page_size: int, + ) -> Any: + """ + 构建配置列表查询对象。 + + :param config_name: 参数名称过滤条件 + :param config_key: 参数键名过滤条件 + :param config_type: 参数类型过滤条件 + :param begin_date: 开始日期 + :param end_date: 结束日期 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 配置分页查询模型 + """ + config_vo_module = self.infrastructure_gateway.get_config_vo_module() + return config_vo_module.ConfigPageQueryModel( + configName=config_name or None, + configKey=config_key or None, + configType=config_type, + beginTime=begin_date or None, + endTime=end_date or None, + pageNum=page_num, + pageSize=page_size, + ) + + def build_target_config_model( + self, + config_key: str, + config_value: str, + config_name: str | None, + config_type: Literal['Y', 'N'] | None, + remark: str | None, + existing_config: Any | None, + ) -> Any: + """ + 基于当前状态构建目标配置模型。 + + :param config_key: 参数键名 + :param config_value: 参数键值 + :param config_name: 参数名称 + :param config_type: 参数类型 + :param remark: 参数备注 + :param existing_config: 已存在的配置模型 + :return: 目标配置模型 + """ + operator = self.operator_service.resolve_operator() + current_time = datetime.now() + common_constant = self.infrastructure_gateway.get_common_constant() + config_vo_module = self.infrastructure_gateway.get_config_vo_module() + default_config_type = existing_config.config_type if existing_config else common_constant.NO + target_config = config_vo_module.ConfigModel( + configId=existing_config.config_id if existing_config else None, + configName=config_name + if config_name is not None + else (existing_config.config_name if existing_config else None), + configKey=config_key, + configValue=config_value, + configType=config_type if config_type is not None else default_config_type, + createBy=existing_config.create_by if existing_config else operator, + createTime=existing_config.create_time if existing_config else current_time, + updateBy=operator, + updateTime=current_time, + remark=remark if remark is not None else (existing_config.remark if existing_config else None), + ) + target_config.validate_fields() + return target_config + + def build_list_filters( + self, + *, + config_name: str, + config_key: str, + config_type: Literal['Y', 'N'] | None, + begin_date: str, + end_date: str, + paged: bool, + page_num: int, + page_size: int, + ) -> dict[str, Any]: + """ + 构建配置列表过滤条件。 + + :param config_name: 参数名称 + :param config_key: 参数键名 + :param config_type: 参数类型 + :param begin_date: 开始日期 + :param end_date: 结束日期 + :param paged: 是否分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 过滤条件字典 + """ + return { + 'configName': config_name, + 'configKey': config_key, + 'configType': config_type, + 'beginDate': begin_date, + 'endDate': end_date, + 'paged': paged, + 'pageNum': page_num, + 'pageSize': page_size, + } diff --git a/shierfeng-fastapi-backend/cli/runtime/crypto/__init__.py b/shierfeng-fastapi-backend/cli/runtime/crypto/__init__.py new file mode 100644 index 0000000..17dc068 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/crypto/__init__.py @@ -0,0 +1,3 @@ +from .service import CRYPTO_RUNTIME, CryptoRuntimeService + +__all__ = ['CRYPTO_RUNTIME', 'CryptoRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/crypto/gateway.py b/shierfeng-fastapi-backend/cli/runtime/crypto/gateway.py new file mode 100644 index 0000000..85b5ad5 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/crypto/gateway.py @@ -0,0 +1,56 @@ +from importlib import import_module +from typing import Any + + +class CryptoInfrastructureGateway: + """ + 传输加密基础设施网关。 + + 该对象负责延迟加载 cryptography、环境配置和传输加密工具依赖, + 供传输加密运行时 facade 与其协作对象统一复用。 + """ + + @staticmethod + def get_serialization_module() -> Any: + """ + 获取 cryptography 序列化模块。 + + :return: 序列化模块 + """ + return import_module('cryptography.hazmat.primitives.serialization') + + @staticmethod + def get_rsa_module() -> Any: + """ + 获取 RSA 非对称算法模块。 + + :return: RSA 模块 + """ + return import_module('cryptography.hazmat.primitives.asymmetric.rsa') + + @staticmethod + def get_transport_crypto_config() -> Any: + """ + 获取传输加密配置对象。 + + :return: 传输加密配置对象 + """ + return import_module('config.env').TransportCryptoConfig + + @staticmethod + def get_transport_crypto_util() -> Any: + """ + 获取传输加密工具类。 + + :return: 传输加密工具类 + """ + return import_module('utils.transport_crypto_util').TransportCryptoUtil + + @staticmethod + def get_transport_key_provider() -> Any: + """ + 获取传输加密密钥提供者。 + + :return: 传输加密密钥提供者 + """ + return import_module('utils.transport_crypto_util').TransportKeyProvider diff --git a/shierfeng-fastapi-backend/cli/runtime/crypto/service.py b/shierfeng-fastapi-backend/cli/runtime/crypto/service.py new file mode 100644 index 0000000..1e6ed69 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/crypto/service.py @@ -0,0 +1,141 @@ +from datetime import datetime +from typing import Any + +from .gateway import CryptoInfrastructureGateway +from .support import CryptoDomainSupport, CryptoResultSupport + + +class CryptoRuntimeService: + """ + 传输加密运行时服务。 + + 该服务作为传输加密运行时 facade,对外统一暴露配置校验、 + 公钥导出、密钥生成和轮换辅助方案构建入口。 + + :param infrastructure_gateway: 传输加密基础设施网关 + :param domain_support: 传输加密领域支持对象 + :param result_support: 传输加密结果支持对象 + """ + + def __init__( + self, + *, + infrastructure_gateway: CryptoInfrastructureGateway | None = None, + domain_support: CryptoDomainSupport | None = None, + result_support: CryptoResultSupport | None = None, + ) -> None: + """ + 初始化传输加密运行时服务。 + + :param infrastructure_gateway: 传输加密基础设施网关 + :param domain_support: 传输加密领域支持对象 + :param result_support: 传输加密结果支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway or CryptoInfrastructureGateway() + self.domain_support = domain_support or CryptoDomainSupport(self.infrastructure_gateway) + self.result_support = result_support or CryptoResultSupport() + + def generate_crypto_key_pair(self, kid: str, key_size: int) -> dict[str, Any]: + """ + 生成新的传输加密密钥对。 + + :param kid: 目标密钥版本标识 + :param key_size: RSA 密钥长度 + :return: 密钥生成结果 + """ + + def _operation() -> dict[str, Any]: + self.domain_support.validate_rsa_key_size(key_size) + private_key_pem, public_key_pem = self.domain_support.generate_rsa_key_pair(key_size) + return { + 'ok': True, + 'message': '传输加密密钥生成完成', + 'kid': kid, + 'keySize': key_size, + 'privateKey': private_key_pem, + 'publicKey': public_key_pem, + 'envPatch': self.domain_support.build_key_pair_env_patch(kid, private_key_pem, public_key_pem), + } + + return self.result_support.run_argument_guarded(_operation, failure_message='生成传输加密密钥失败') + + def validate_crypto_config(self) -> dict[str, Any]: + """ + 校验传输加密运行配置。 + + :return: 传输加密配置校验结果 + """ + transport_key_provider = self.infrastructure_gateway.get_transport_key_provider() + + def _operation() -> dict[str, Any]: + transport_key_provider.validate_runtime_configuration() + return {'ok': True, 'message': '传输加密配置校验通过'} + + return self.result_support.run_runtime_guarded(_operation, failure_message='传输加密配置校验失败') + + def export_public_key(self) -> dict[str, Any]: + """ + 导出当前运行环境的传输加密公钥信息。 + + :return: 公钥导出结果 + """ + transport_key_provider = self.infrastructure_gateway.get_transport_key_provider() + transport_crypto_util = self.infrastructure_gateway.get_transport_crypto_util() + + def _operation() -> dict[str, Any]: + transport_key_provider.validate_runtime_configuration() + return {'ok': True, 'publicKey': transport_crypto_util.build_public_key_payload()} + + return self.result_support.run_runtime_guarded(_operation, failure_message='导出传输加密公钥失败') + + def build_rotation_payload(self, next_kid: str, key_size: int) -> dict[str, Any]: + """ + 生成传输加密密钥轮换辅助结果。 + + :param next_kid: 新密钥版本标识 + :param key_size: 新密钥的 RSA 长度 + :return: 轮换辅助结果 + """ + transport_key_provider = self.infrastructure_gateway.get_transport_key_provider() + + def _operation() -> dict[str, Any]: + self.domain_support.validate_rsa_key_size(key_size) + transport_key_provider.validate_runtime_configuration() + current_key_pair = transport_key_provider.get_current_key_pair() + if next_kid == current_key_pair.kid: + raise ValueError('新密钥版本不能与当前版本相同') + + private_key_pem, public_key_pem = self.domain_support.generate_rsa_key_pair(key_size) + legacy_key_pairs = self.domain_support.load_existing_legacy_key_pairs() + merged_legacy_key_pairs = self.domain_support.merge_rotation_legacy_key_pairs( + legacy_key_pairs, + current_key_pair, + next_kid, + ) + return { + 'ok': True, + 'message': '传输加密密钥轮换方案已生成', + 'currentKid': current_key_pair.kid, + 'nextKid': next_kid, + 'keySize': key_size, + 'generatedAt': datetime.now().isoformat(), + 'nextKeyPair': { + 'kid': next_kid, + 'privateKey': private_key_pem, + 'publicKey': public_key_pem, + }, + 'legacyKeyPairs': merged_legacy_key_pairs, + 'envPatch': self.domain_support.build_rotation_env_patch( + next_kid, + private_key_pem, + public_key_pem, + merged_legacy_key_pairs, + ), + 'applySteps': self.result_support.build_rotation_apply_steps(), + } + + return self.result_support.run_argument_guarded(_operation, failure_message='生成密钥轮换方案失败') + + +CRYPTO_RUNTIME = CryptoRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/crypto/support.py b/shierfeng-fastapi-backend/cli/runtime/crypto/support.py new file mode 100644 index 0000000..73f2e81 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/crypto/support.py @@ -0,0 +1,234 @@ +import json +from collections.abc import Callable +from typing import Any + +from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR + +from .gateway import CryptoInfrastructureGateway + +MIN_RSA_KEY_SIZE = 2048 +RSA_KEY_SIZE_STEP = 256 + + +class CryptoDomainSupport: + """ + 传输加密领域支持对象。 + + 该对象负责 RSA 密钥规则校验、密钥对生成和历史密钥规整, + 避免主运行时服务继续承载局部安全规则和数据拼装细节。 + + :param infrastructure_gateway: 传输加密基础设施网关 + """ + + def __init__(self, infrastructure_gateway: CryptoInfrastructureGateway) -> None: + """ + 初始化传输加密领域支持对象。 + + :param infrastructure_gateway: 传输加密基础设施网关 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + + @staticmethod + def validate_rsa_key_size(key_size: int) -> None: + """ + 校验 RSA 密钥长度是否合法。 + + :param key_size: RSA 密钥长度 + :return: None + :raises ValueError: 密钥长度不满足约束时抛出异常 + """ + if key_size < MIN_RSA_KEY_SIZE or key_size % RSA_KEY_SIZE_STEP != 0: + raise ValueError('RSA 密钥长度必须大于等于 2048,且为 256 的整数倍') + + def generate_rsa_key_pair(self, key_size: int) -> tuple[str, str]: + """ + 生成 PEM 格式的 RSA 密钥对。 + + :param key_size: RSA 密钥长度 + :return: 私钥与公钥 PEM 字符串 + """ + rsa_module = self.infrastructure_gateway.get_rsa_module() + serialization_module = self.infrastructure_gateway.get_serialization_module() + private_key = rsa_module.generate_private_key(public_exponent=65537, key_size=key_size) + private_key_pem = private_key.private_bytes( + encoding=serialization_module.Encoding.PEM, + format=serialization_module.PrivateFormat.PKCS8, + encryption_algorithm=serialization_module.NoEncryption(), + ).decode('utf-8') + public_key_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization_module.Encoding.PEM, + format=serialization_module.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) + return private_key_pem, public_key_pem + + def load_existing_legacy_key_pairs(self) -> list[dict[str, str]]: + """ + 解析当前配置中的历史密钥列表。 + + :return: 历史密钥列表 + :raises ValueError: 历史密钥配置非法时抛出异常 + """ + transport_crypto_config = self.infrastructure_gateway.get_transport_crypto_config() + raw_legacy_key_pairs = transport_crypto_config.transport_crypto_legacy_key_pairs or '[]' + parsed_legacy_key_pairs = json.loads(raw_legacy_key_pairs) + if not isinstance(parsed_legacy_key_pairs, list): + raise ValueError('TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS 必须为 JSON 数组') + return [item for item in parsed_legacy_key_pairs if isinstance(item, dict)] + + @staticmethod + def merge_rotation_legacy_key_pairs( + legacy_key_pairs: list[dict[str, str]], + current_key_pair: Any, + next_kid: str, + ) -> list[dict[str, str]]: + """ + 合并轮换后的历史密钥列表。 + + :param legacy_key_pairs: 当前历史密钥列表 + :param current_key_pair: 当前生效密钥对对象 + :param next_kid: 即将切换的新密钥版本 + :return: 合并后的历史密钥列表 + """ + normalized_legacy_key_pairs: dict[str, dict[str, str]] = { + str(item.get('kid')): item for item in legacy_key_pairs if item.get('kid') + } + normalized_legacy_key_pairs[current_key_pair.kid] = { + 'kid': current_key_pair.kid, + 'privateKey': current_key_pair.private_key_pem, + 'publicKey': current_key_pair.public_key_pem, + } + normalized_legacy_key_pairs.pop(next_kid, None) + return list(normalized_legacy_key_pairs.values()) + + @staticmethod + def build_key_pair_env_patch(kid: str, private_key_pem: str, public_key_pem: str) -> dict[str, str]: + """ + 构建密钥对写回环境变量所需的补丁数据。 + + :param kid: 密钥版本标识 + :param private_key_pem: 私钥 PEM 文本 + :param public_key_pem: 公钥 PEM 文本 + :return: 环境变量补丁数据 + """ + return { + 'TRANSPORT_CRYPTO_KID': kid, + 'TRANSPORT_CRYPTO_PRIVATE_KEY': private_key_pem, + 'TRANSPORT_CRYPTO_PUBLIC_KEY': public_key_pem, + } + + def build_rotation_env_patch( + self, + next_kid: str, + private_key_pem: str, + public_key_pem: str, + merged_legacy_key_pairs: list[dict[str, str]], + ) -> dict[str, str]: + """ + 构建密钥轮换写回环境变量所需的补丁数据。 + + :param next_kid: 新密钥版本标识 + :param private_key_pem: 新私钥 PEM 文本 + :param public_key_pem: 新公钥 PEM 文本 + :param merged_legacy_key_pairs: 合并后的历史密钥列表 + :return: 环境变量补丁数据 + """ + env_patch = self.build_key_pair_env_patch(next_kid, private_key_pem, public_key_pem) + env_patch['TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS'] = json.dumps(merged_legacy_key_pairs, ensure_ascii=False) + return env_patch + + +class CryptoResultSupport: + """ + 传输加密结果支持对象。 + + 该对象负责参数错误/运行时错误结果翻译,以及固定轮换提示文案构建, + 避免主运行时服务继续承载重复异常映射与静态 payload 片段。 + """ + + @staticmethod + def build_argument_error_result(message: str, exc: Exception) -> dict[str, Any]: + """ + 构建参数错误结果。 + + :param message: 失败消息 + :param exc: 原始异常 + :return: 标准参数错误结果 + """ + return { + 'ok': False, + 'message': message, + 'error': str(exc), + 'exit_code': ARGUMENT_ERROR, + } + + @staticmethod + def build_runtime_error_result(message: str, exc: Exception) -> dict[str, Any]: + """ + 构建运行时错误结果。 + + :param message: 失败消息 + :param exc: 原始异常 + :return: 标准运行时错误结果 + """ + return { + 'ok': False, + 'message': message, + 'error': str(exc), + 'exit_code': RUNTIME_ERROR, + } + + def run_argument_guarded( + self, + operation: Callable[[], dict[str, Any]], + *, + failure_message: str, + ) -> dict[str, Any]: + """ + 以统一参数/运行时错误翻译执行操作。 + + :param operation: 待执行操作 + :param failure_message: 失败消息 + :return: 操作结果 + """ + try: + return operation() + except ValueError as exc: + return self.build_argument_error_result(failure_message, exc) + except Exception as exc: + return self.build_runtime_error_result(failure_message, exc) + + def run_runtime_guarded( + self, + operation: Callable[[], dict[str, Any]], + *, + failure_message: str, + ) -> dict[str, Any]: + """ + 以统一运行时错误翻译执行操作。 + + :param operation: 待执行操作 + :param failure_message: 失败消息 + :return: 操作结果 + """ + try: + return operation() + except Exception as exc: + return self.build_runtime_error_result(failure_message, exc) + + @staticmethod + def build_rotation_apply_steps() -> list[str]: + """ + 构建密钥轮换后的应用步骤说明。 + + :return: 轮换应用步骤列表 + """ + return [ + '将 envPatch 中的新密钥写入目标环境配置', + '发布后保留 legacyKeyPairs,确保旧请求在轮换窗口内可解密', + '确认前端已刷新到 nextKid 后,再移除不再需要的历史密钥', + ] diff --git a/shierfeng-fastapi-backend/cli/runtime/db/__init__.py b/shierfeng-fastapi-backend/cli/runtime/db/__init__.py new file mode 100644 index 0000000..2b1832e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/db/__init__.py @@ -0,0 +1,3 @@ +from .service import DATABASE_RUNTIME, DatabaseRuntimeService + +__all__ = ['DATABASE_RUNTIME', 'DatabaseRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/db/gateway.py b/shierfeng-fastapi-backend/cli/runtime/db/gateway.py new file mode 100644 index 0000000..20798e4 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/db/gateway.py @@ -0,0 +1,56 @@ +from importlib import import_module +from typing import Any + + +class DatabaseInfrastructureGateway: + """ + 数据库基础设施网关。 + + 该对象负责延迟加载数据库引擎、SQLAlchemy 和 Alembic 相关依赖, + 供数据库运行时 facade 与其协作对象统一复用。 + """ + + @staticmethod + def get_async_db_engine_factory() -> Any: + """ + 获取异步数据库引擎工厂函数。 + + :return: 异步数据库引擎工厂函数 + """ + return import_module('config.database').create_async_db_engine + + @staticmethod + def get_sync_db_engine_factory() -> Any: + """ + 获取同步数据库引擎工厂函数。 + + :return: 同步数据库引擎工厂函数 + """ + return import_module('config.database').create_sync_db_engine + + @staticmethod + def get_sqlalchemy_text() -> Any: + """ + 获取 SQLAlchemy `text` 构造函数。 + + :return: SQLAlchemy `text` 函数 + """ + return import_module('sqlalchemy').text + + @staticmethod + def get_alembic_config_class() -> Any: + """ + 获取 Alembic 配置类。 + + :return: Alembic 配置类 + """ + return import_module('alembic.config').Config + + @staticmethod + def get_alembic_script_directory_class() -> Any: + """ + 获取 Alembic 脚本目录类。 + + :return: Alembic 脚本目录类 + """ + return import_module('alembic.script').ScriptDirectory diff --git a/shierfeng-fastapi-backend/cli/runtime/db/service.py b/shierfeng-fastapi-backend/cli/runtime/db/service.py new file mode 100644 index 0000000..f3ea221 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/db/service.py @@ -0,0 +1,199 @@ +from typing import Any + +from cli.exit_codes import DATABASE_ERROR +from cli.runtime.base import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService + +from .gateway import DatabaseInfrastructureGateway +from .support import DatabaseAlembicCommandSupport, DatabaseRevisionSupport + + +class DatabaseRuntimeService: + """ + 数据库运行时服务。 + + 该服务作为数据库运行时 facade,对外统一暴露数据库连通性检查、 + Alembic 版本读取与迁移命令执行入口。 + + :param runtime_environment: 运行时环境服务 + :param infrastructure_gateway: 数据库基础设施网关 + :param revision_support: 数据库迁移版本支持对象 + :param alembic_command_support: 数据库 Alembic 命令支持对象 + """ + + def __init__( + self, + *, + runtime_environment: RuntimeEnvironmentService | None = None, + infrastructure_gateway: DatabaseInfrastructureGateway | None = None, + revision_support: DatabaseRevisionSupport | None = None, + alembic_command_support: DatabaseAlembicCommandSupport | None = None, + ) -> None: + """ + 初始化数据库运行时服务。 + + :param runtime_environment: 运行时环境服务 + :param infrastructure_gateway: 数据库基础设施网关 + :param revision_support: 数据库迁移版本支持对象 + :param alembic_command_support: 数据库 Alembic 命令支持对象 + :return: None + """ + self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT + self.infrastructure_gateway = infrastructure_gateway or DatabaseInfrastructureGateway() + self.revision_support = revision_support or DatabaseRevisionSupport( + self.infrastructure_gateway, + self.runtime_environment, + ) + self.alembic_command_support = alembic_command_support or DatabaseAlembicCommandSupport( + self.runtime_environment + ) + + async def ping_database(self) -> dict[str, Any]: + """ + 检查数据库连通性。 + + :return: 数据库检查结果 + """ + create_async_db_engine = self.infrastructure_gateway.get_async_db_engine_factory() + text = self.infrastructure_gateway.get_sqlalchemy_text() + engine = create_async_db_engine(echo=False) + try: + async with engine.connect() as connection: + await connection.execute(text('SELECT 1')) + return {'ok': True, 'message': '数据库连接成功'} + except Exception as exc: + return {'ok': False, 'message': '数据库连接失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + finally: + await engine.dispose() + + def get_current_revision(self) -> dict[str, Any]: + """ + 获取数据库当前迁移版本。 + + :return: 当前迁移版本信息 + """ + create_sync_db_engine = self.infrastructure_gateway.get_sync_db_engine_factory() + text = self.infrastructure_gateway.get_sqlalchemy_text() + engine = create_sync_db_engine(echo=False) + try: + with engine.connect() as connection: + revision = connection.execute(text('SELECT version_num FROM alembic_version')).scalar() + return {'ok': True, 'currentRevision': revision} + except Exception as exc: + return {'ok': False, 'message': '读取数据库迁移版本失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + finally: + engine.dispose() + + def upgrade_database(self, revision: str = 'head', *, dry_run: bool = False) -> dict[str, Any]: + """ + 执行数据库迁移升级。 + + :param revision: 目标迁移版本,默认为 `head` + :param dry_run: 是否仅演练执行 + :return: 数据库迁移执行结果 + """ + return self.alembic_command_support.run_alembic_command( + 'upgrade', + revision, + success_message=f'数据库已升级到 {revision}', + failure_message='数据库升级失败', + dry_run=dry_run, + ) + + def init_database(self, *, dry_run: bool = False) -> dict[str, Any]: + """ + 初始化数据库到最新迁移版本。 + + :param dry_run: 是否仅演练执行 + :return: 数据库初始化结果 + """ + return self.alembic_command_support.run_alembic_command( + 'upgrade', + 'head', + success_message='数据库初始化完成,当前版本已同步到 head', + failure_message='数据库初始化失败', + dry_run=dry_run, + ) + + def downgrade_database(self, revision: str = '-1', *, dry_run: bool = False) -> dict[str, Any]: + """ + 执行数据库回退。 + + :param revision: 目标回退版本,默认为 `-1` + :param dry_run: 是否仅演练执行 + :return: 数据库回退结果 + """ + return self.alembic_command_support.run_alembic_command( + 'downgrade', + revision, + success_message=f'数据库已回退到 {revision}', + failure_message='数据库回退失败', + dry_run=dry_run, + ) + + def create_revision(self, message: str, *, autogenerate: bool = False, dry_run: bool = False) -> dict[str, Any]: + """ + 创建新的 Alembic 迁移版本文件。 + + :param message: 迁移说明 + :param autogenerate: 是否自动生成变更 + :param dry_run: 是否仅演练执行 + :return: 迁移版本创建结果 + """ + arguments: list[str] = ['-m', message] + if autogenerate: + arguments.append('--autogenerate') + return self.alembic_command_support.run_alembic_command( + 'revision', + *arguments, + success_message='数据库迁移版本文件创建完成', + failure_message='数据库迁移版本文件创建失败', + dry_run=dry_run, + ) + + def get_alembic_heads(self) -> dict[str, Any]: + """ + 读取当前代码仓库中的 Alembic heads 信息。 + + :return: Alembic heads 结果 + """ + try: + script_directory = self.revision_support.build_alembic_script_directory() + items = [ + self.revision_support.serialize_revision(revision) + for revision in script_directory.get_revisions('heads') + ] + return { + 'ok': True, + 'message': '已读取 Alembic heads', + 'count': len(items), + 'items': items, + } + except Exception as exc: + return {'ok': False, 'message': '读取 Alembic heads 失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + def get_alembic_history(self, *, limit: int = 20) -> dict[str, Any]: + """ + 读取当前代码仓库中的 Alembic 历史版本信息。 + + :param limit: 返回的最大历史记录数量 + :return: Alembic 历史版本结果 + """ + try: + script_directory = self.revision_support.build_alembic_script_directory() + history_items = [ + self.revision_support.serialize_revision(revision) for revision in script_directory.walk_revisions() + ] + limited_items = history_items[:limit] + return { + 'ok': True, + 'message': '已读取 Alembic 历史版本', + 'count': len(limited_items), + 'totalCount': len(history_items), + 'limit': limit, + 'items': limited_items, + } + except Exception as exc: + return {'ok': False, 'message': '读取 Alembic 历史版本失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + +DATABASE_RUNTIME = DatabaseRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/db/support.py b/shierfeng-fastapi-backend/cli/runtime/db/support.py new file mode 100644 index 0000000..2f1cfcc --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/db/support.py @@ -0,0 +1,160 @@ +import os +import subprocess +from typing import Any + +from cli.exit_codes import DATABASE_ERROR +from cli.runtime.base import RuntimeEnvironmentService + +from .gateway import DatabaseInfrastructureGateway + + +class DatabaseRevisionSupport: + """ + 数据库迁移版本支持对象。 + + 该对象负责 Alembic script directory 构建、修订版本规整和 + 版本对象序列化,避免主运行时服务继续承载迁移元数据细节。 + + :param infrastructure_gateway: 数据库基础设施网关 + :param runtime_environment: 运行时环境服务 + """ + + def __init__( + self, + infrastructure_gateway: DatabaseInfrastructureGateway, + runtime_environment: RuntimeEnvironmentService, + ) -> None: + """ + 初始化数据库迁移版本支持对象。 + + :param infrastructure_gateway: 数据库基础设施网关 + :param runtime_environment: 运行时环境服务 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + self.runtime_environment = runtime_environment + + def build_alembic_script_directory(self) -> Any: + """ + 基于当前后端目录构建 Alembic 脚本目录对象。 + + :return: Alembic 脚本目录对象 + """ + config_class = self.infrastructure_gateway.get_alembic_config_class() + script_directory_class = self.infrastructure_gateway.get_alembic_script_directory_class() + config = config_class(os.path.join(self.runtime_environment.get_backend_dir(), 'alembic.ini')) + return script_directory_class.from_config(config) + + @staticmethod + def normalize_revision_value(value: Any) -> list[str]: + """ + 规范化 Alembic 修订版本引用值。 + + :param value: 原始修订版本引用 + :return: 规范化后的修订版本列表 + """ + if value is None: + return [] + if isinstance(value, str): + return [value] + return [str(item) for item in value] + + def serialize_revision(self, script_revision: Any) -> dict[str, Any]: + """ + 序列化 Alembic 修订版本对象。 + + :param script_revision: Alembic 修订版本对象 + :return: 序列化后的修订版本字典 + """ + return { + 'revision': str(script_revision.revision), + 'downRevisions': self.normalize_revision_value(script_revision.down_revision), + 'branchLabels': sorted(str(item) for item in script_revision.branch_labels or []), + 'dependsOn': self.normalize_revision_value(script_revision.dependencies), + 'doc': (script_revision.doc or '').strip(), + 'path': str(script_revision.path), + } + + +class DatabaseAlembicCommandSupport: + """ + 数据库 Alembic 命令支持对象。 + + 该对象负责 Alembic 命令组装与子进程执行, + 避免主运行时服务继续承载命令行桥接细节。 + + :param runtime_environment: 运行时环境服务 + """ + + def __init__(self, runtime_environment: RuntimeEnvironmentService) -> None: + """ + 初始化数据库 Alembic 命令支持对象。 + + :param runtime_environment: 运行时环境服务 + :return: None + """ + self.runtime_environment = runtime_environment + + def build_alembic_command(self, command: str, *arguments: str) -> list[str]: + """ + 构建 Alembic 命令。 + + :param command: Alembic 子命令名称 + :param arguments: Alembic 子命令参数列表 + :return: Alembic 命令参数列表 + """ + alembic_ini_path = os.path.join(self.runtime_environment.get_backend_dir(), 'alembic.ini') + return ['alembic', '-c', alembic_ini_path, command, *arguments] + + def run_alembic_command( + self, + command: str, + *arguments: str, + success_message: str, + failure_message: str, + dry_run: bool = False, + ) -> dict[str, Any]: + """ + 执行 Alembic 命令并返回统一结果。 + + :param command: Alembic 子命令名称 + :param arguments: Alembic 子命令参数列表 + :param success_message: 成功提示信息 + :param failure_message: 失败提示信息 + :param dry_run: 是否仅演练执行 + :return: Alembic 执行结果 + """ + command_arguments = self.build_alembic_command(command, *arguments) + if dry_run: + return { + 'ok': True, + 'message': f'{success_message}(dry-run)', + 'dryRun': True, + 'command': command_arguments, + 'cwd': self.runtime_environment.get_backend_dir(), + } + + try: + completed = subprocess.run( + command_arguments, + cwd=self.runtime_environment.get_backend_dir(), + env={**os.environ, 'APP_ENV': os.environ.get('APP_ENV', 'dev')}, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + return { + 'ok': False, + 'message': failure_message, + 'error': completed.stderr.strip() or completed.stdout.strip(), + 'exit_code': DATABASE_ERROR, + } + + payload: dict[str, Any] = {'ok': True, 'message': success_message} + stdout = completed.stdout.strip() + if stdout: + payload['stdout'] = stdout + return payload + except Exception as exc: + return {'ok': False, 'message': failure_message, 'error': str(exc), 'exit_code': DATABASE_ERROR} diff --git a/shierfeng-fastapi-backend/cli/runtime/dev/__init__.py b/shierfeng-fastapi-backend/cli/runtime/dev/__init__.py new file mode 100644 index 0000000..e5316cd --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/dev/__init__.py @@ -0,0 +1,3 @@ +from .service import DEVELOPMENT_RUNTIME, DevelopmentRuntimeService + +__all__ = ['DEVELOPMENT_RUNTIME', 'DevelopmentRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/dev/gateway.py b/shierfeng-fastapi-backend/cli/runtime/dev/gateway.py new file mode 100644 index 0000000..7ea2c11 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/dev/gateway.py @@ -0,0 +1,60 @@ +import subprocess +from typing import Any + +from cli.exit_codes import RUNTIME_ERROR +from cli.runtime.base import RuntimeEnvironmentService + + +class DevelopmentProcessGateway: + """ + 开发子进程执行网关。 + + 该对象负责统一执行开发工具子进程命令, + 避免支持对象直接依赖 `subprocess` 细节。 + + :param runtime_environment: 运行时环境服务 + """ + + def __init__(self, runtime_environment: RuntimeEnvironmentService) -> None: + """ + 初始化开发子进程执行网关。 + + :param runtime_environment: 运行时环境服务 + :return: None + """ + self.runtime_environment = runtime_environment + + def run_command(self, command: list[str]) -> dict[str, Any]: + """ + 执行子进程命令并返回统一结果。 + + :param command: 待执行命令 + :return: 命令执行结果 + """ + try: + completed = subprocess.run( + command, + cwd=self.runtime_environment.get_backend_dir(), + capture_output=True, + text=True, + check=False, + ) + except Exception as exc: + return { + 'ok': False, + 'message': '开发命令执行失败', + 'error': str(exc), + 'command': command, + 'exit_code': RUNTIME_ERROR, + } + + payload: dict[str, Any] = { + 'ok': completed.returncode == 0, + 'command': command, + 'returnCode': completed.returncode, + } + if completed.stdout.strip(): + payload['stdout'] = completed.stdout.strip() + if completed.stderr.strip(): + payload['stderr'] = completed.stderr.strip() + return payload diff --git a/shierfeng-fastapi-backend/cli/runtime/dev/service.py b/shierfeng-fastapi-backend/cli/runtime/dev/service.py new file mode 100644 index 0000000..5812c75 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/dev/service.py @@ -0,0 +1,158 @@ +from typing import Any + +from cli.exit_codes import DEPENDENCY_ERROR, RUNTIME_ERROR +from cli.runtime.base import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService + +from .gateway import DevelopmentProcessGateway +from .support import DevelopmentCommandSupport, DevelopmentToolingSupport + + +class DevelopmentRuntimeService: + """ + 开发运行时服务。 + + 该服务作为开发运行时 facade,对外统一暴露 lint/format 和 pytest + 执行入口。 + + :param runtime_environment: 运行时环境服务 + :param tooling_support: 开发工具支持对象 + :param command_support: 开发命令构建支持对象 + :param process_gateway: 开发子进程执行网关 + """ + + def __init__( + self, + *, + runtime_environment: RuntimeEnvironmentService | None = None, + tooling_support: DevelopmentToolingSupport | None = None, + command_support: DevelopmentCommandSupport | None = None, + process_gateway: DevelopmentProcessGateway | None = None, + ) -> None: + """ + 初始化开发运行时服务。 + + :param runtime_environment: 运行时环境服务 + :param tooling_support: 开发工具支持对象 + :param command_support: 开发命令构建支持对象 + :param process_gateway: 开发子进程执行网关 + :return: None + """ + self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT + self.tooling_support = tooling_support or DevelopmentToolingSupport() + self.command_support = command_support or DevelopmentCommandSupport(self.runtime_environment) + self.process_gateway = process_gateway or DevelopmentProcessGateway(self.runtime_environment) + + def run_lint( + self, + targets: list[str] | None, + *, + check_only: bool = False, + fix: bool = False, + unsafe_fixes: bool = False, + ) -> dict[str, Any]: + """ + 执行仓库 lint/format 检查。 + + :param targets: 目标路径列表 + :param check_only: 是否仅检查不写回 + :param fix: 是否允许 `ruff check --fix` + :param unsafe_fixes: 是否允许不安全修复 + :return: lint 执行结果 + """ + normalized_targets = self.tooling_support.resolve_targets(targets) + format_command = self.command_support.build_format_command(normalized_targets, check_only=check_only) + format_result = self.process_gateway.run_command(format_command) + if not format_result.get('ok', False): + return { + 'ok': False, + 'message': 'Ruff format 阶段失败', + 'targets': normalized_targets, + 'format': format_result, + 'exit_code': RUNTIME_ERROR, + } + + check_command = self.command_support.build_check_command( + normalized_targets, + check_only=check_only, + fix=fix, + unsafe_fixes=unsafe_fixes, + ) + check_result = self.process_gateway.run_command(check_command) + if not check_result.get('ok', False): + return { + 'ok': False, + 'message': 'Ruff check 阶段失败', + 'targets': normalized_targets, + 'format': format_result, + 'check': check_result, + 'exit_code': RUNTIME_ERROR, + } + + return { + 'ok': True, + 'message': '开发检查已完成', + 'targets': normalized_targets, + 'checkOnly': check_only, + 'fix': fix and not check_only, + 'unsafeFixes': unsafe_fixes and fix and not check_only, + 'format': format_result, + 'check': check_result, + } + + def run_tests( + self, + targets: list[str] | None, + *, + keyword: str = '', + maxfail: int = 0, + quiet: bool = False, + ) -> dict[str, Any]: + """ + 执行项目测试。 + + :param targets: 目标测试路径列表 + :param keyword: pytest `-k` 过滤表达式 + :param maxfail: 最大失败数,0 表示不限制 + :param quiet: 是否启用简洁输出 + :return: 测试执行结果 + """ + if not self.tooling_support.is_pytest_available(): + return { + 'ok': False, + 'message': 'pytest 未安装,无法执行 dev test', + 'hint': '请在当前环境安装 pytest 后重试', + 'exit_code': DEPENDENCY_ERROR, + } + + normalized_targets = self.tooling_support.resolve_targets(targets) + command = self.command_support.build_pytest_command( + normalized_targets, + keyword=keyword, + maxfail=maxfail, + quiet=quiet, + ) + test_result = self.process_gateway.run_command(command) + if not test_result.get('ok', False): + return { + 'ok': False, + 'message': '测试执行失败', + 'targets': normalized_targets, + 'keyword': keyword, + 'maxfail': maxfail, + 'quiet': quiet, + 'test': test_result, + 'exit_code': RUNTIME_ERROR, + } + + return { + 'ok': True, + 'message': '测试执行完成', + 'targets': normalized_targets, + 'keyword': keyword, + 'maxfail': maxfail, + 'quiet': quiet, + 'test': test_result, + } + + +DEVELOPMENT_RUNTIME = DevelopmentRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/dev/support.py b/shierfeng-fastapi-backend/cli/runtime/dev/support.py new file mode 100644 index 0000000..4f37d2b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/dev/support.py @@ -0,0 +1,118 @@ +import importlib.util + +from cli.runtime.base import RuntimeEnvironmentService + + +class DevelopmentToolingSupport: + """ + 开发工具支持对象。 + + 该对象负责目标列表规整与开发依赖可用性检查, + 避免主运行时服务继续承载轻量工具规则。 + """ + + @staticmethod + def resolve_targets(targets: list[str] | None) -> list[str]: + """ + 规范化命令目标列表。 + + :param targets: 原始目标列表 + :return: 规范化后的目标列表 + """ + normalized_targets = [target.strip() for target in (targets or []) if target.strip()] + return normalized_targets or ['.'] + + @staticmethod + def is_pytest_available() -> bool: + """ + 检查 pytest 是否已安装。 + + :return: pytest 是否可用 + """ + return importlib.util.find_spec('pytest') is not None + + +class DevelopmentCommandSupport: + """ + 开发命令构建支持对象。 + + 该对象负责开发工具命令参数拼装, + 避免主运行时服务继续承载命令行桥接细节。 + + :param runtime_environment: 运行时环境服务 + """ + + def __init__(self, runtime_environment: RuntimeEnvironmentService) -> None: + """ + 初始化开发命令构建支持对象。 + + :param runtime_environment: 运行时环境服务 + :return: None + """ + self.runtime_environment = runtime_environment + + def build_format_command(self, normalized_targets: list[str], *, check_only: bool) -> list[str]: + """ + 构建 Ruff format 命令。 + + :param normalized_targets: 已规整目标列表 + :param check_only: 是否仅检查 + :return: 格式化命令参数列表 + """ + command = [self.runtime_environment.get_python_executable(), '-m', 'ruff', 'format'] + if check_only: + command.append('--check') + command.extend(normalized_targets) + return command + + def build_check_command( + self, + normalized_targets: list[str], + *, + check_only: bool, + fix: bool, + unsafe_fixes: bool, + ) -> list[str]: + """ + 构建 Ruff check 命令。 + + :param normalized_targets: 已规整目标列表 + :param check_only: 是否仅检查 + :param fix: 是否允许自动修复 + :param unsafe_fixes: 是否允许不安全修复 + :return: 检查命令参数列表 + """ + command = [self.runtime_environment.get_python_executable(), '-m', 'ruff', 'check'] + if fix and not check_only: + command.append('--fix') + if unsafe_fixes: + command.append('--unsafe-fixes') + command.extend(normalized_targets) + return command + + def build_pytest_command( + self, + normalized_targets: list[str], + *, + keyword: str, + maxfail: int, + quiet: bool, + ) -> list[str]: + """ + 构建 pytest 命令。 + + :param normalized_targets: 已规整目标列表 + :param keyword: pytest `-k` 表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :return: pytest 命令参数列表 + """ + command = [self.runtime_environment.get_python_executable(), '-m', 'pytest'] + if quiet: + command.append('-q') + if keyword: + command.extend(['-k', keyword]) + if maxfail > 0: + command.append(f'--maxfail={maxfail}') + command.extend(normalized_targets) + return command diff --git a/shierfeng-fastapi-backend/cli/runtime/gen/__init__.py b/shierfeng-fastapi-backend/cli/runtime/gen/__init__.py new file mode 100644 index 0000000..0f23c5c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/gen/__init__.py @@ -0,0 +1,3 @@ +from .service import GEN_RUNTIME, GenRuntimeService + +__all__ = ['GEN_RUNTIME', 'GenRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/gen/gateway.py b/shierfeng-fastapi-backend/cli/runtime/gen/gateway.py new file mode 100644 index 0000000..e099a6a --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/gen/gateway.py @@ -0,0 +1,110 @@ +from importlib import import_module +from typing import Any + + +class GenInfrastructureGateway: + """ + 代码生成基础设施网关。 + + 该对象负责延迟加载 sqlglot、数据库会话、分页模型、配置对象、 + 业务异常和代码生成服务依赖,供代码生成运行时 facade 与其协作对象统一复用。 + """ + + @staticmethod + def get_sqlglot_module() -> Any: + """ + 获取 `sqlglot` 模块。 + + :return: `sqlglot` 模块 + """ + return import_module('sqlglot') + + @staticmethod + def get_sqlglot_expressions_module() -> Any: + """ + 获取 `sqlglot.expressions` 模块。 + + :return: `sqlglot.expressions` 模块 + """ + return import_module('sqlglot.expressions') + + @staticmethod + def get_async_session_local() -> Any: + """ + 获取异步数据库会话工厂。 + + :return: 异步数据库会话工厂 + """ + return import_module('config.database').AsyncSessionLocal + + @staticmethod + def get_page_model() -> Any: + """ + 获取分页模型类型。 + + :return: 分页模型类型 + """ + return import_module('common.vo').PageModel + + @staticmethod + def get_database_config() -> Any: + """ + 获取数据库配置对象。 + + :return: 数据库配置对象 + """ + return import_module('config.env').DataBaseConfig + + @staticmethod + def get_gen_config() -> Any: + """ + 获取代码生成配置对象。 + + :return: 代码生成配置对象 + """ + return import_module('config.env').GenConfig + + @staticmethod + def get_service_exception_class() -> type[Exception]: + """ + 获取业务异常类型。 + + :return: 业务异常类型 + """ + return import_module('exceptions.exception').ServiceException + + @staticmethod + def get_user_vo_module() -> Any: + """ + 获取用户 VO 模块。 + + :return: 用户 VO 模块 + """ + return import_module('module_admin.entity.vo.user_vo') + + @staticmethod + def get_gen_vo_module() -> Any: + """ + 获取代码生成 VO 模块。 + + :return: 代码生成 VO 模块 + """ + return import_module('module_generator.entity.vo.gen_vo') + + @staticmethod + def get_gen_table_service() -> Any: + """ + 获取代码生成服务类。 + + :return: 代码生成服务类 + """ + return import_module('module_generator.service.gen_service').GenTableService + + @staticmethod + def get_gen_table_column_service() -> Any: + """ + 获取代码生成字段服务类。 + + :return: 代码生成字段服务类 + """ + return import_module('module_generator.service.gen_service').GenTableColumnService diff --git a/shierfeng-fastapi-backend/cli/runtime/gen/service.py b/shierfeng-fastapi-backend/cli/runtime/gen/service.py new file mode 100644 index 0000000..e760da9 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/gen/service.py @@ -0,0 +1,412 @@ +from typing import Any + +from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, RUNTIME_ERROR + +from .gateway import GenInfrastructureGateway +from .support import GenDomainSupport + + +class GenRuntimeService: + """ + 代码生成运行时服务。 + + 该服务作为代码生成运行时 facade,对外统一暴露业务表、数据库物理表、 + 建表 SQL、代码预览、导出与数据库同步等入口。 + + :param infrastructure_gateway: 代码生成基础设施网关 + :param domain_support: 代码生成领域支持对象 + """ + + def __init__( + self, + *, + infrastructure_gateway: GenInfrastructureGateway | None = None, + domain_support: GenDomainSupport | None = None, + ) -> None: + """ + 初始化代码生成运行时服务。 + + :param infrastructure_gateway: 代码生成基础设施网关 + :param domain_support: 代码生成领域支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway or GenInfrastructureGateway() + self.domain_support = domain_support or GenDomainSupport(self.infrastructure_gateway) + + async def import_tables(self, table_names: list[str], *, dry_run: bool = False) -> dict[str, Any]: + """ + 导入数据库表到代码生成业务表。 + + :param table_names: 待导入表名列表 + :param dry_run: 是否仅演练执行 + :return: 导入结果 + """ + normalized_table_names = self.domain_support.normalize_table_names(table_names) + if not normalized_table_names: + return {'ok': False, 'message': '至少需要传入一个表名', 'exit_code': ARGUMENT_ERROR} + + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + service_exception = self.infrastructure_gateway.get_service_exception_class() + try: + async with async_session_local() as session: + gen_table_list = await gen_table_service.get_gen_db_table_list_by_name_services( + session, + normalized_table_names, + ) + matched_table_names = [gen_table.table_name for gen_table in gen_table_list if gen_table.table_name] + missing_table_names = [ + table_name for table_name in normalized_table_names if table_name not in matched_table_names + ] + + if dry_run: + return { + 'ok': True, + 'message': '导入表结构演练完成,未执行实际写入', + 'dryRun': True, + 'requestedTables': normalized_table_names, + 'matchedTables': matched_table_names, + 'missingTables': missing_table_names, + } + + result = await gen_table_service.import_gen_table_services( + session, + gen_table_list, + self.domain_support.build_cli_current_user(), + ) + except service_exception as exc: + return {'ok': False, 'message': '导入表结构失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + except Exception as exc: + return {'ok': False, 'message': '导入表结构失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + return { + 'ok': bool(result.is_success), + 'message': result.message, + 'requestedTables': normalized_table_names, + } + + async def list_gen_tables( + self, + *, + table_name: str = '', + table_comment: str = '', + paged: bool = False, + page_num: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """ + 查询代码生成业务表列表。 + + :param table_name: 表名称过滤条件 + :param table_comment: 表描述过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 查询结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + try: + gen_vo_module = self.infrastructure_gateway.get_gen_vo_module() + query_model = gen_vo_module.GenTablePageQueryModel( + tableName=table_name or None, + tableComment=table_comment or None, + pageNum=page_num, + pageSize=page_size, + ) + async with async_session_local() as session: + result = await gen_table_service.get_gen_table_list_services(session, query_model, is_page=paged) + except Exception as exc: + return { + 'ok': False, + 'message': '读取代码生成业务表列表失败', + 'error': str(exc), + 'exit_code': DATABASE_ERROR, + } + + filters = { + 'tableName': table_name, + 'tableComment': table_comment, + 'paged': paged, + 'pageNum': page_num, + 'pageSize': page_size, + } + return self.domain_support.build_list_payload(result, filters=filters, paged=paged) + + async def list_gen_db_tables( + self, + *, + table_name: str = '', + table_comment: str = '', + paged: bool = False, + page_num: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """ + 查询数据库中可导入的物理表列表。 + + :param table_name: 表名称过滤条件 + :param table_comment: 表描述过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 查询结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + try: + gen_vo_module = self.infrastructure_gateway.get_gen_vo_module() + query_model = gen_vo_module.GenTablePageQueryModel( + tableName=table_name or None, + tableComment=table_comment or None, + pageNum=page_num, + pageSize=page_size, + ) + async with async_session_local() as session: + result = await gen_table_service.get_gen_db_table_list_services(session, query_model, is_page=paged) + except Exception as exc: + return {'ok': False, 'message': '读取数据库表列表失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + filters = { + 'tableName': table_name, + 'tableComment': table_comment, + 'paged': paged, + 'pageNum': page_num, + 'pageSize': page_size, + } + return self.domain_support.build_list_payload(result, filters=filters, paged=paged) + + async def create_tables(self, sql: str, sql_file: str, *, dry_run: bool = False) -> dict[str, Any]: + """ + 根据 SQL 创建表并导入代码生成业务表。 + + :param sql: 直接传入的 SQL 文本 + :param sql_file: SQL 文件路径 + :param dry_run: 是否仅演练执行 + :return: 创建结果 + """ + try: + sql_text = self.domain_support.resolve_sql_text(sql, sql_file) + sql_statements, table_names = self.domain_support.parse_create_table_sql(sql_text) + except ValueError as exc: + return {'ok': False, 'message': '创建表结构失败', 'error': str(exc), 'exit_code': ARGUMENT_ERROR} + except Exception as exc: + return {'ok': False, 'message': '创建表结构失败', 'error': str(exc), 'exit_code': RUNTIME_ERROR} + + if dry_run: + return { + 'ok': True, + 'message': '建表语句演练完成,未执行实际建表', + 'dryRun': True, + 'statementCount': len(sql_statements), + 'tableNames': table_names, + 'sql': sql_text, + } + + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + service_exception = self.infrastructure_gateway.get_service_exception_class() + try: + async with async_session_local() as session: + result = await gen_table_service.create_table_services( + session, + sql_text, + self.domain_support.build_cli_current_user(), + ) + except service_exception as exc: + return {'ok': False, 'message': '创建表结构失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + except Exception as exc: + return {'ok': False, 'message': '创建表结构失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + return { + 'ok': bool(result.is_success), + 'message': result.message, + 'tableNames': table_names, + } + + async def preview_code(self, table_id: int) -> dict[str, Any]: + """ + 预览指定业务表的代码生成结果。 + + :param table_id: 业务表 ID + :return: 预览结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + service_exception = self.infrastructure_gateway.get_service_exception_class() + try: + async with async_session_local() as session: + preview_payload = await gen_table_service.preview_code_services(session, table_id) + except service_exception as exc: + return {'ok': False, 'message': '预览代码失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + except Exception as exc: + return {'ok': False, 'message': '预览代码失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + return { + 'ok': True, + 'tableId': table_id, + 'templateCount': len(preview_payload), + 'preview': preview_payload, + } + + async def get_gen_table_detail(self, table_id: int) -> dict[str, Any]: + """ + 读取单个代码生成业务表详情。 + + :param table_id: 业务表 ID + :return: 详情结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + gen_table_column_service = self.infrastructure_gateway.get_gen_table_column_service() + try: + async with async_session_local() as session: + info = await gen_table_service.get_gen_table_by_id_services(session, table_id) + if not getattr(info, 'table_id', None): + return { + 'ok': False, + 'message': f'代码生成业务表不存在:{table_id}', + 'tableId': table_id, + 'exit_code': RUNTIME_ERROR, + } + rows = await gen_table_column_service.get_gen_table_column_list_by_table_id_services(session, table_id) + tables = await gen_table_service.get_gen_table_all_services(session) + except Exception as exc: + return { + 'ok': False, + 'message': '读取代码生成业务表详情失败', + 'error': str(exc), + 'exit_code': DATABASE_ERROR, + } + + detail_payload = { + 'info': self.domain_support.serialize_gen_item(info), + 'rows': self.domain_support.serialize_gen_items(rows), + 'tables': self.domain_support.serialize_gen_items(tables), + } + return { + 'ok': True, + 'tableId': table_id, + 'tableName': detail_payload['info'].get('tableName', ''), + 'columnCount': len(detail_payload['rows']), + 'tableCount': len(detail_payload['tables']), + 'detail': detail_payload, + } + + async def export_code( + self, + table_names: list[str], + *, + mode: str = 'zip', + output_file: str = '', + dry_run: bool = False, + ) -> dict[str, Any]: + """ + 导出代码生成结果。 + + :param table_names: 业务表名称列表 + :param mode: 导出模式,支持 `zip` 与 `local` + :param output_file: zip 导出目标文件路径 + :param dry_run: 是否仅演练执行 + :return: 导出结果 + """ + normalized_table_names = self.domain_support.normalize_table_names(table_names) + if not normalized_table_names: + return {'ok': False, 'message': '至少需要传入一个表名', 'exit_code': ARGUMENT_ERROR} + if mode not in {'zip', 'local'}: + return {'ok': False, 'message': '导出模式仅支持 zip 或 local', 'exit_code': ARGUMENT_ERROR} + + gen_config = self.infrastructure_gateway.get_gen_config() + if mode == 'local' and not gen_config.allow_overwrite: + return { + 'ok': False, + 'message': '当前系统配置不允许生成文件覆盖到本地', + 'hint': '请检查 GenConfig.allow_overwrite 配置', + 'exit_code': RUNTIME_ERROR, + } + + if dry_run: + dry_run_payload: dict[str, Any] = { + 'ok': True, + 'message': '代码导出演练完成,未执行实际导出', + 'dryRun': True, + 'mode': mode, + 'tableNames': normalized_table_names, + } + if mode == 'zip': + target_file = output_file.strip() or f'gen_code_{"_".join(normalized_table_names)}.zip' + dry_run_payload['outputFile'] = self.domain_support.resolve_output_file_path(target_file) + else: + dry_run_payload['genPath'] = gen_config.GEN_PATH + return dry_run_payload + + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + service_exception = self.infrastructure_gateway.get_service_exception_class() + try: + async with async_session_local() as session: + if mode == 'zip': + zip_bytes = await gen_table_service.batch_gen_code_services(session, normalized_table_names) + else: + messages = [] + for table_name in normalized_table_names: + result = await gen_table_service.generate_code_services(session, table_name) + messages.append({'tableName': table_name, 'message': result.message, 'ok': result.is_success}) + return { + 'ok': True, + 'message': '代码已生成到本地目录', + 'mode': mode, + 'tableNames': normalized_table_names, + 'genPath': gen_config.GEN_PATH, + 'results': messages, + } + except service_exception as exc: + return {'ok': False, 'message': '导出代码失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + except Exception as exc: + return {'ok': False, 'message': '导出代码失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + target_file = output_file.strip() or f'gen_code_{"_".join(normalized_table_names)}.zip' + try: + target_path = self.domain_support.write_export_zip(target_file, zip_bytes) + except Exception as exc: + return {'ok': False, 'message': '写出导出文件失败', 'error': str(exc), 'exit_code': RUNTIME_ERROR} + + return { + 'ok': True, + 'message': '代码压缩包导出完成', + 'mode': mode, + 'tableNames': normalized_table_names, + 'outputFile': target_path, + 'size': len(zip_bytes), + } + + async def sync_gen_table_from_db(self, table_name: str) -> dict[str, Any]: + """ + 将指定代码生成业务表与数据库表结构进行同步。 + + :param table_name: 业务表名称 + :return: 同步结果 + """ + normalized_table_name = table_name.strip() + if not normalized_table_name: + return {'ok': False, 'message': '表名不能为空', 'exit_code': ARGUMENT_ERROR} + + async_session_local = self.infrastructure_gateway.get_async_session_local() + gen_table_service = self.infrastructure_gateway.get_gen_table_service() + service_exception = self.infrastructure_gateway.get_service_exception_class() + try: + async with async_session_local() as session: + result = await gen_table_service.sync_db_services(session, normalized_table_name) + except service_exception as exc: + return {'ok': False, 'message': '同步数据库表结构失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + except Exception as exc: + return {'ok': False, 'message': '同步数据库表结构失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + return { + 'ok': bool(result.is_success), + 'message': result.message, + 'tableName': normalized_table_name, + } + + +GEN_RUNTIME = GenRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/gen/support.py b/shierfeng-fastapi-backend/cli/runtime/gen/support.py new file mode 100644 index 0000000..6f92be7 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/gen/support.py @@ -0,0 +1,191 @@ +from pathlib import Path +from typing import Any + +from cli.runtime.base import RUNTIME_OPERATOR, RuntimeOperatorService + +from .gateway import GenInfrastructureGateway + + +class GenDomainSupport: + """ + 代码生成领域支持对象。 + + 该对象负责 CLI 当前用户构建、表名规整、记录序列化、SQL 解析 + 以及导出路径处理,避免主运行时服务继续承载过多局部规则。 + + :param infrastructure_gateway: 代码生成基础设施网关 + """ + + def __init__( + self, + infrastructure_gateway: GenInfrastructureGateway, + operator_service: RuntimeOperatorService = RUNTIME_OPERATOR, + ) -> None: + """ + 初始化代码生成领域支持对象。 + + :param infrastructure_gateway: 代码生成基础设施网关 + :param operator_service: 运行时操作者解析服务 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + self.operator_service = operator_service + + def build_cli_current_user(self) -> Any: + """ + 构建 CLI 场景使用的最小当前用户模型。 + + :return: CLI 当前用户模型 + """ + operator = self.operator_service.resolve_operator() + user_vo_module = self.infrastructure_gateway.get_user_vo_module() + return user_vo_module.CurrentUserModel( + permissions=[], + roles=['admin'], + user=user_vo_module.UserInfoModel( + user_id=1, + user_name=operator, + nick_name=operator, + user_type='00', + status='0', + del_flag='0', + ), + ) + + @staticmethod + def normalize_table_names(table_names: list[str]) -> list[str]: + """ + 规范化表名列表。 + + :param table_names: 原始表名列表 + :return: 规范化后的表名列表 + """ + return [table_name.strip() for table_name in table_names if table_name.strip()] + + @staticmethod + def serialize_gen_item(item: Any) -> dict[str, Any]: + """ + 序列化单个代码生成表记录。 + + :param item: 原始记录对象 + :return: 可输出的字典 + """ + if hasattr(item, 'model_dump'): + return dict(item.model_dump(by_alias=True, exclude_none=True)) + return dict(item) + + def serialize_gen_items(self, items: list[Any]) -> list[dict[str, Any]]: + """ + 序列化代码生成表记录列表。 + + :param items: 原始记录列表 + :return: 序列化后的字典列表 + """ + return [self.serialize_gen_item(item) for item in items] + + def parse_create_table_sql(self, sql: str) -> tuple[list[Any], list[str]]: + """ + 解析建表 SQL 并提取建表语句信息。 + + :param sql: 原始 SQL 文本 + :return: SQL AST 列表与建表表名列表 + :raises ValueError: SQL 非法时抛出异常 + """ + sqlglot_module = self.infrastructure_gateway.get_sqlglot_module() + expressions_module = self.infrastructure_gateway.get_sqlglot_expressions_module() + database_config = self.infrastructure_gateway.get_database_config() + sql_statements = sqlglot_module.parse(sql, dialect=database_config.sqlglot_parse_dialect) + has_create = any(isinstance(sql_statement, expressions_module.Create) for sql_statement in sql_statements) + has_forbidden_keyword = any( + isinstance( + sql_statement, + ( + expressions_module.Add, + expressions_module.Alter, + expressions_module.Delete, + expressions_module.Drop, + expressions_module.Insert, + expressions_module.TruncateTable, + expressions_module.Update, + ), + ) + for sql_statement in sql_statements + ) + if not has_create or has_forbidden_keyword: + raise ValueError('建表语句不合法,仅允许 CREATE TABLE 语句') + + table_names = [ + sql_statement.find(expressions_module.Table).name + for sql_statement in sql_statements + if isinstance(sql_statement, expressions_module.Create) + ] + if not table_names: + raise ValueError('未解析到建表表名') + return sql_statements, table_names + + @staticmethod + def resolve_sql_text(sql: str, sql_file: str) -> str: + """ + 解析命令输入中的 SQL 文本。 + + :param sql: 直接传入的 SQL 文本 + :param sql_file: SQL 文件路径 + :return: 最终 SQL 文本 + :raises ValueError: 参数非法时抛出异常 + """ + if bool(sql.strip()) == bool(sql_file.strip()): + raise ValueError('必须且只能传入 --sql 或 --sql-file 其中一种方式') + if sql.strip(): + return sql.strip() + + sql_path = Path(sql_file).expanduser().resolve() + if not sql_path.is_file(): + raise ValueError(f'SQL 文件不存在:{sql_path}') + return sql_path.read_text(encoding='utf-8').strip() + + @staticmethod + def resolve_output_file_path(target_file: str) -> str: + """ + 解析导出文件绝对路径。 + + :param target_file: 原始导出文件路径 + :return: 绝对路径字符串 + """ + return str(Path(target_file).expanduser().resolve()) + + @staticmethod + def write_export_zip(target_file: str, zip_bytes: bytes) -> str: + """ + 将导出的 zip 数据写入目标文件。 + + :param target_file: 目标文件路径 + :param zip_bytes: zip 二进制内容 + :return: 实际写入的绝对路径 + """ + target_path = Path(target_file).expanduser().resolve() + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(zip_bytes) + return str(target_path) + + def build_list_payload( + self, + result: Any, + *, + filters: dict[str, Any], + paged: bool, + ) -> dict[str, Any]: + """ + 统一构建代码生成列表返回结构。 + + :param result: 原始结果对象 + :param filters: 查询过滤条件 + :param paged: 是否分页 + :return: 可输出结果 + """ + if paged and isinstance(result, self.infrastructure_gateway.get_page_model()): + page_payload = result.model_dump(by_alias=True) + page_payload['rows'] = self.serialize_gen_items(page_payload.get('rows', [])) + return {'ok': True, 'filters': filters, 'page': page_payload} + + items = self.serialize_gen_items(result) + return {'ok': True, 'filters': filters, 'count': len(items), 'items': items} diff --git a/shierfeng-fastapi-backend/cli/runtime/job/__init__.py b/shierfeng-fastapi-backend/cli/runtime/job/__init__.py new file mode 100644 index 0000000..214da66 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/job/__init__.py @@ -0,0 +1,3 @@ +from .service import JOB_RUNTIME, JobRuntimeService + +__all__ = ['JOB_RUNTIME', 'JobRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/job/gateway.py b/shierfeng-fastapi-backend/cli/runtime/job/gateway.py new file mode 100644 index 0000000..b2491d4 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/job/gateway.py @@ -0,0 +1,74 @@ +from importlib import import_module +from typing import Any + + +class JobInfrastructureGateway: + """ + 定时任务基础设施网关。 + + 该对象负责延迟加载数据库会话、Redis、调度器和任务服务依赖, + 供定时任务运行时 facade 与其协作对象统一复用。 + """ + + @staticmethod + def get_page_model() -> Any: + """ + 获取分页模型类型。 + + :return: 分页模型类型 + """ + return import_module('common.vo').PageModel + + @staticmethod + def get_async_session_local() -> Any: + """ + 获取异步数据库会话工厂。 + + :return: 异步数据库会话工厂 + """ + return import_module('config.database').AsyncSessionLocal + + @staticmethod + def get_redis_util() -> Any: + """ + 获取 Redis 工具类。 + + :return: Redis 工具类 + """ + return import_module('config.get_redis').RedisUtil + + @staticmethod + def get_scheduler_util() -> Any: + """ + 获取调度器工具类。 + + :return: 调度器工具类 + """ + return import_module('config.get_scheduler').SchedulerUtil + + @staticmethod + def get_job_vo_module() -> Any: + """ + 获取定时任务 VO 模块。 + + :return: 定时任务 VO 模块 + """ + return import_module('module_admin.entity.vo.job_vo') + + @staticmethod + def get_job_service() -> Any: + """ + 获取定时任务服务类。 + + :return: 定时任务服务类 + """ + return import_module('module_admin.service.job_service').JobService + + @staticmethod + def get_job_log_service() -> Any: + """ + 获取定时任务日志服务类。 + + :return: 定时任务日志服务类 + """ + return import_module('module_admin.service.job_log_service').JobLogService diff --git a/shierfeng-fastapi-backend/cli/runtime/job/service.py b/shierfeng-fastapi-backend/cli/runtime/job/service.py new file mode 100644 index 0000000..9cf8e62 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/job/service.py @@ -0,0 +1,246 @@ +from typing import Any + +from cli.exit_codes import DATABASE_ERROR, RUNTIME_ERROR + +from .gateway import JobInfrastructureGateway +from .support import JobDomainSupport, JobSchedulerSupport + + +class JobRuntimeService: + """ + 定时任务运行时服务。 + + 该服务作为定时任务运行时 facade,对外统一暴露任务列表、详情、日志、 + 执行一次、暂停与恢复入口。 + + :param infrastructure_gateway: 定时任务基础设施网关 + :param domain_support: 定时任务领域支持对象 + :param scheduler_support: 定时任务调度上下文支持对象 + """ + + def __init__( + self, + *, + infrastructure_gateway: JobInfrastructureGateway | None = None, + domain_support: JobDomainSupport | None = None, + scheduler_support: JobSchedulerSupport | None = None, + ) -> None: + """ + 初始化定时任务运行时服务。 + + :param infrastructure_gateway: 定时任务基础设施网关 + :param domain_support: 定时任务领域支持对象 + :param scheduler_support: 定时任务调度上下文支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway or JobInfrastructureGateway() + self.domain_support = domain_support or JobDomainSupport(self.infrastructure_gateway) + self.scheduler_support = scheduler_support or JobSchedulerSupport(self.infrastructure_gateway) + + async def list_jobs( + self, + *, + job_name: str = '', + job_group: str = '', + status: str | None = None, + paged: bool = False, + page_num: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """ + 查询定时任务列表。 + + :param job_name: 任务名称过滤条件 + :param job_group: 任务组过滤条件 + :param status: 状态过滤条件 + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 定时任务列表结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + job_vo_module = self.infrastructure_gateway.get_job_vo_module() + job_service = self.infrastructure_gateway.get_job_service() + query_model = job_vo_module.JobPageQueryModel( + jobName=job_name or None, + jobGroup=job_group or None, + status=status, + pageNum=page_num, + pageSize=page_size, + ) + try: + async with async_session_local() as session: + result = await job_service.get_job_list_services(session, query_model, is_page=paged) + except Exception as exc: + return {'ok': False, 'message': '读取定时任务列表失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + filters = self.domain_support.build_filters( + job_name=job_name, + job_group=job_group, + status=status, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + return self.domain_support.build_list_payload(result, filters=filters, paged=paged) + + async def get_job_detail(self, job_id: int) -> dict[str, Any]: + """ + 读取单个定时任务详情。 + + :param job_id: 任务 ID + :return: 定时任务详情结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + job_service = self.infrastructure_gateway.get_job_service() + try: + async with async_session_local() as session: + job_model = await job_service.job_detail_services(session, job_id) + except Exception as exc: + return {'ok': False, 'message': '读取定时任务详情失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + job_payload = self.domain_support.serialize_job_item(job_model) + if not job_payload.get('jobId'): + return { + 'ok': False, + 'message': f'定时任务不存在:{job_id}', + 'jobId': job_id, + 'exit_code': RUNTIME_ERROR, + } + return {'ok': True, 'job': job_payload} + + async def list_job_logs( + self, + *, + job_name: str = '', + job_group: str = '', + status: str | None = None, + begin_date: str = '', + end_date: str = '', + paged: bool = False, + page_num: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """ + 查询定时任务执行日志列表。 + + :param job_name: 任务名称过滤条件 + :param job_group: 任务组过滤条件 + :param status: 执行状态过滤条件 + :param begin_date: 开始日期,格式 `YYYY-MM-DD` + :param end_date: 结束日期,格式 `YYYY-MM-DD` + :param paged: 是否启用分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 定时任务日志列表结果 + """ + async_session_local = self.infrastructure_gateway.get_async_session_local() + job_log_service = self.infrastructure_gateway.get_job_log_service() + job_vo_module = self.infrastructure_gateway.get_job_vo_module() + query_model = job_vo_module.JobLogPageQueryModel( + jobName=job_name or None, + jobGroup=job_group or None, + status=status, + beginTime=begin_date or None, + endTime=end_date or None, + pageNum=page_num, + pageSize=page_size, + ) + try: + async with async_session_local() as session: + result = await job_log_service.get_job_log_list_services(session, query_model, is_page=paged) + except Exception as exc: + return {'ok': False, 'message': '读取定时任务日志列表失败', 'error': str(exc), 'exit_code': DATABASE_ERROR} + + filters = self.domain_support.build_filters( + job_name=job_name, + job_group=job_group, + status=status, + begin_date=begin_date, + end_date=end_date, + paged=paged, + page_num=page_num, + page_size=page_size, + ) + return self.domain_support.build_list_payload(result, filters=filters, paged=paged) + + async def run_with_scheduler_context( + self, + operation: str, + job_id: int, + *, + status: str | None = None, + ) -> dict[str, Any]: + """ + 在调度器上下文中执行任务操作。 + + :param operation: 操作名称 + :param job_id: 任务ID + :param status: 目标状态 + :return: 任务操作结果 + """ + operation_metadata = self.domain_support.build_job_operation_metadata(operation) + redis = None + async_session_local = self.infrastructure_gateway.get_async_session_local() + redis_util = self.infrastructure_gateway.get_redis_util() + scheduler_util = self.infrastructure_gateway.get_scheduler_util() + job_vo_module = self.infrastructure_gateway.get_job_vo_module() + job_service = self.infrastructure_gateway.get_job_service() + try: + async with async_session_local() as session: + redis = await redis_util.create_redis_pool(log_enabled=False) + await scheduler_util.init_system_scheduler(redis) + + if operation == 'run-once': + result = await job_service.execute_job_once_services(session, job_vo_module.JobModel(jobId=job_id)) + elif operation in {'pause', 'resume'}: + result = await job_service.edit_job_services( + session, + job_vo_module.EditJobModel(jobId=job_id, status=status, type='status'), + ) + else: + raise ValueError(f'不支持的任务操作:{operation}') + except Exception as exc: + return {'ok': False, 'message': '执行定时任务操作失败', 'error': str(exc), 'exit_code': 22} + finally: + await self.scheduler_support.close_scheduler_context(redis) + + return { + 'ok': bool(result.is_success), + 'jobId': job_id, + 'operation': operation, + 'operationLabel': operation_metadata['operationLabel'], + 'targetStatus': status, + 'message': operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败', + 'serviceMessage': result.message, + } + + async def run_job_once(self, job_id: int) -> dict[str, Any]: + """ + 执行一次指定定时任务。 + + :param job_id: 任务ID + :return: 执行结果 + """ + return await self.run_with_scheduler_context('run-once', job_id) + + async def pause_job(self, job_id: int) -> dict[str, Any]: + """ + 暂停指定定时任务。 + + :param job_id: 任务ID + :return: 暂停结果 + """ + return await self.run_with_scheduler_context('pause', job_id, status='1') + + async def resume_job(self, job_id: int) -> dict[str, Any]: + """ + 恢复指定定时任务。 + + :param job_id: 任务ID + :return: 恢复结果 + """ + return await self.run_with_scheduler_context('resume', job_id, status='0') + + +JOB_RUNTIME = JobRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/job/support.py b/shierfeng-fastapi-backend/cli/runtime/job/support.py new file mode 100644 index 0000000..19578b2 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/job/support.py @@ -0,0 +1,173 @@ +from typing import Any + +from .gateway import JobInfrastructureGateway + + +class JobDomainSupport: + """ + 定时任务领域支持对象。 + + 该对象负责任务序列化、分页结果规整和任务操作元数据定义, + 避免主运行时服务继续承载细碎领域规则。 + + :param infrastructure_gateway: 定时任务基础设施网关 + """ + + def __init__(self, infrastructure_gateway: JobInfrastructureGateway) -> None: + """ + 初始化定时任务领域支持对象。 + + :param infrastructure_gateway: 定时任务基础设施网关 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + + @staticmethod + def serialize_job_item(job_item: Any) -> dict[str, Any]: + """ + 序列化单个定时任务模型。 + + :param job_item: 原始定时任务模型或字典 + :return: 可输出的定时任务字典 + """ + if hasattr(job_item, 'model_dump'): + return dict(job_item.model_dump(by_alias=True, exclude_none=True)) + return dict(job_item) + + def serialize_job_items(self, job_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + 序列化任务列表结果。 + + :param job_items: 原始任务列表 + :return: 可输出的任务列表 + """ + return [self.serialize_job_item(item) for item in job_items] + + @staticmethod + def build_job_operation_metadata(operation: str) -> dict[str, str]: + """ + 构建任务操作的统一元数据。 + + :param operation: 操作名称 + :return: 操作元数据字典 + :raises ValueError: 操作名称不受支持时抛出异常 + """ + metadata_mapping = { + 'run-once': { + 'operationLabel': '执行一次任务', + 'successMessage': '定时任务已触发一次执行', + }, + 'pause': { + 'operationLabel': '暂停任务', + 'successMessage': '定时任务已暂停', + }, + 'resume': { + 'operationLabel': '恢复任务', + 'successMessage': '定时任务已恢复', + }, + } + if operation not in metadata_mapping: + raise ValueError(f'不支持的任务操作:{operation}') + return metadata_mapping[operation] + + def build_filters( + self, + *, + job_name: str = '', + job_group: str = '', + status: str | None = None, + begin_date: str = '', + end_date: str = '', + paged: bool = False, + page_num: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """ + 构建任务列表或日志查询过滤条件。 + + :param job_name: 任务名称 + :param job_group: 任务分组 + :param status: 状态 + :param begin_date: 开始日期 + :param end_date: 结束日期 + :param paged: 是否分页 + :param page_num: 页码 + :param page_size: 每页数量 + :return: 过滤条件字典 + """ + filters = { + 'jobName': job_name, + 'jobGroup': job_group, + 'status': status, + 'paged': paged, + 'pageNum': page_num, + 'pageSize': page_size, + } + if begin_date or end_date: + filters['beginDate'] = begin_date + filters['endDate'] = end_date + return filters + + def build_list_payload( + self, + result: Any, + *, + filters: dict[str, Any], + paged: bool, + ) -> dict[str, Any]: + """ + 统一构建任务列表或日志列表返回结构。 + + :param result: 原始结果对象 + :param filters: 查询过滤条件 + :param paged: 是否分页 + :return: 可输出结果 + """ + if paged and isinstance(result, self.infrastructure_gateway.get_page_model()): + page_payload = result.model_dump(by_alias=True) + page_payload['rows'] = self.serialize_job_items(page_payload.get('rows', [])) + return {'ok': True, 'filters': filters, 'page': page_payload} + + items = self.serialize_job_items(result) + return {'ok': True, 'filters': filters, 'count': len(items), 'items': items} + + +class JobSchedulerSupport: + """ + 定时任务调度上下文支持对象。 + + 该对象负责统一关闭调度器上下文资源,避免调度型操作在 facade 中 + 持续堆叠资源清理细节。 + + :param infrastructure_gateway: 定时任务基础设施网关 + """ + + def __init__(self, infrastructure_gateway: JobInfrastructureGateway) -> None: + """ + 初始化定时任务调度上下文支持对象。 + + :param infrastructure_gateway: 定时任务基础设施网关 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + + async def close_scheduler_context(self, redis: Any) -> None: + """ + 安全关闭任务调度相关资源。 + + :param redis: Redis 客户端实例 + :return: None + """ + scheduler_util = self.infrastructure_gateway.get_scheduler_util() + try: + await scheduler_util.close_system_scheduler() + except Exception: + pass + + if redis is None: + return + + try: + await redis.close() + except Exception: + pass diff --git a/shierfeng-fastapi-backend/cli/runtime/ops/__init__.py b/shierfeng-fastapi-backend/cli/runtime/ops/__init__.py new file mode 100644 index 0000000..9c60709 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/ops/__init__.py @@ -0,0 +1,3 @@ +from .service import OPERATIONS_RUNTIME, OperationsRuntimeService + +__all__ = ['OPERATIONS_RUNTIME', 'OperationsRuntimeService'] diff --git a/shierfeng-fastapi-backend/cli/runtime/ops/gateway.py b/shierfeng-fastapi-backend/cli/runtime/ops/gateway.py new file mode 100644 index 0000000..261c202 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/ops/gateway.py @@ -0,0 +1,65 @@ +from importlib import import_module +from typing import Any + + +class OperationsInfrastructureGateway: + """ + 运维基础设施网关。 + + 该对象负责延迟加载 Redis、调度器、服务器监控与系统工具依赖, + 供运维运行时 facade 和其协作对象统一复用。 + """ + + @staticmethod + def get_redis_util() -> Any: + """ + 获取 Redis 工具类。 + + :return: Redis 工具类 + """ + return import_module('config.get_redis').RedisUtil + + @staticmethod + def get_redis_error_class() -> type[Exception]: + """ + 获取 Redis 异常类型。 + + :return: Redis 异常类型 + """ + return import_module('redis.exceptions').RedisError + + @staticmethod + def get_scheduler_util() -> Any: + """ + 获取调度器工具类。 + + :return: 调度器工具类 + """ + return import_module('config.get_scheduler').SchedulerUtil + + @staticmethod + def get_server_service() -> Any: + """ + 获取服务器监控服务类。 + + :return: 服务器监控服务类 + """ + return import_module('module_admin.service.server_service').ServerService + + @staticmethod + def get_psutil_module() -> Any: + """ + 获取 `psutil` 模块。 + + :return: `psutil` 模块 + """ + return import_module('psutil') + + @staticmethod + def get_bytes2human() -> Any: + """ + 获取字节数转可读文本函数。 + + :return: `bytes2human` 函数 + """ + return import_module('utils.common_util').bytes2human diff --git a/shierfeng-fastapi-backend/cli/runtime/ops/service.py b/shierfeng-fastapi-backend/cli/runtime/ops/service.py new file mode 100644 index 0000000..cfff10c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/ops/service.py @@ -0,0 +1,132 @@ +import socket +from typing import Any + +from cli.exit_codes import REDIS_ERROR, RUNTIME_ERROR, SCHEDULER_ERROR + +from .gateway import OperationsInfrastructureGateway +from .support import ( + OperationsDependencyInspector, + OperationsServerInfoSupport, +) + + +class OperationsRuntimeService: + """ + 运维运行时服务。 + + 该服务作为运维运行时 facade,对外统一暴露依赖版本检查、Redis 探活、 + 调度同步以及服务器运行时信息采集入口。 + + :param infrastructure_gateway: 运维基础设施网关 + :param dependency_inspector: 运维依赖检查器 + :param server_info_support: 服务器信息支持对象 + """ + + def __init__( + self, + *, + infrastructure_gateway: OperationsInfrastructureGateway | None = None, + dependency_inspector: OperationsDependencyInspector | None = None, + server_info_support: OperationsServerInfoSupport | None = None, + ) -> None: + """ + 初始化运维运行时服务。 + + :param infrastructure_gateway: 运维基础设施网关 + :param dependency_inspector: 运维依赖检查器 + :param server_info_support: 服务器信息支持对象 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway or OperationsInfrastructureGateway() + self.dependency_inspector = dependency_inspector or OperationsDependencyInspector() + self.server_info_support = server_info_support or OperationsServerInfoSupport(self.infrastructure_gateway) + + def get_dependency_versions(self, *, include_dev: bool = False) -> dict[str, Any]: + """ + 读取 CLI 和后端运行所依赖的核心 Python 包版本。 + + :param include_dev: 是否附带开发阶段依赖 + :return: 依赖检查结果 + """ + return self.dependency_inspector.inspect(include_dev=include_dev) + + async def ping_redis(self) -> dict[str, Any]: + """ + 检查 Redis 连通性。 + + :return: Redis 检查结果 + """ + redis_util = self.infrastructure_gateway.get_redis_util() + redis_error = self.infrastructure_gateway.get_redis_error_class() + redis = await redis_util.create_redis_pool(log_enabled=False) + try: + await redis.ping() + return {'ok': True, 'message': 'Redis连接成功'} + except redis_error as exc: + return {'ok': False, 'message': 'Redis连接失败', 'error': str(exc), 'exit_code': REDIS_ERROR} + finally: + await redis.close() + + async def sync_jobs(self) -> dict[str, Any]: + """ + 同步调度任务配置。 + + :return: 任务同步执行结果 + """ + redis = None + scheduler_util = None + try: + redis_util = self.infrastructure_gateway.get_redis_util() + scheduler_util = self.infrastructure_gateway.get_scheduler_util() + redis = await redis_util.create_redis_pool(log_enabled=False) + await scheduler_util.init_system_scheduler(redis) + await scheduler_util.request_scheduler_sync() + return { + 'ok': True, + 'operation': 'sync', + 'operationLabel': '同步调度配置', + 'schedulerSyncRequested': True, + 'message': '调度配置同步请求已发送', + 'isLeader': scheduler_util._is_leader, + } + except Exception as exc: + return { + 'ok': False, + 'operation': 'sync', + 'operationLabel': '同步调度配置', + 'schedulerSyncRequested': False, + 'message': '调度配置同步失败', + 'error': str(exc), + 'exit_code': SCHEDULER_ERROR, + } + finally: + if scheduler_util is not None: + try: + await scheduler_util.close_system_scheduler() + except Exception: + pass + if redis is not None: + try: + await redis.close() + except Exception: + pass + + async def get_server_info(self) -> dict[str, Any]: + """ + 获取服务器运行信息。 + + :return: 服务器运行信息字典 + """ + try: + server_service = self.infrastructure_gateway.get_server_service() + return {'ok': True, 'server': (await server_service.get_server_monitor_info()).model_dump(by_alias=True)} + except socket.gaierror: + try: + return {'ok': True, 'server': self.server_info_support.build_server_info_fallback()} + except Exception as exc: + return {'ok': False, 'message': '读取服务器运行信息失败', 'error': str(exc), 'exit_code': RUNTIME_ERROR} + except Exception as exc: + return {'ok': False, 'message': '读取服务器运行信息失败', 'error': str(exc), 'exit_code': RUNTIME_ERROR} + + +OPERATIONS_RUNTIME = OperationsRuntimeService() diff --git a/shierfeng-fastapi-backend/cli/runtime/ops/support.py b/shierfeng-fastapi-backend/cli/runtime/ops/support.py new file mode 100644 index 0000000..f891c14 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/runtime/ops/support.py @@ -0,0 +1,241 @@ +import os +import platform +import socket +import time +from dataclasses import dataclass +from importlib import metadata +from typing import Any + +from cli.exit_codes import RUNTIME_ERROR + +from .gateway import OperationsInfrastructureGateway + + +@dataclass(frozen=True) +class OperationsDependencySpec: + """ + 运维依赖检查项定义。 + + :param package_name: CLI 展示用包名 + :param distribution: Python 分发名称 + :param required: 是否为必需依赖 + :param version: 显式版本,存在时跳过环境读取 + """ + + package_name: str + distribution: str + required: bool + version: str | None = None + + +class OperationsDependencyInspector: + """ + 运维依赖检查器。 + + 该对象负责核心依赖声明、版本探测与依赖检查结果构建, + 使运行时 facade 不再直接维护细碎依赖定义。 + """ + + @staticmethod + def read_package_version(distribution_name: str) -> str | None: + """ + 读取指定 Python 包的已安装版本号。 + + :param distribution_name: Python 包分发名称 + :return: 已安装版本号,未安装时返回 `None` + """ + try: + return metadata.version(distribution_name) + except metadata.PackageNotFoundError: + return None + + @staticmethod + def build_dependency_specs(*, include_dev: bool = False) -> list[OperationsDependencySpec]: + """ + 构建当前依赖检查项列表。 + + :param include_dev: 是否附带开发阶段依赖 + :return: 依赖检查项列表 + """ + dependency_specs = [ + OperationsDependencySpec('python', '', True, platform.python_version()), + OperationsDependencySpec('fastapi', 'fastapi', True), + OperationsDependencySpec('uvicorn', 'uvicorn', True), + OperationsDependencySpec('sqlalchemy', 'SQLAlchemy', True), + OperationsDependencySpec('alembic', 'alembic', True), + OperationsDependencySpec('redis', 'redis', True), + OperationsDependencySpec('pydantic', 'pydantic', True), + OperationsDependencySpec('pydantic-settings', 'pydantic-settings', True), + OperationsDependencySpec('typer', 'typer', True), + ] + if include_dev: + dependency_specs.extend( + [ + OperationsDependencySpec('pytest', 'pytest', False), + OperationsDependencySpec('ruff', 'ruff', False), + ] + ) + return dependency_specs + + def inspect(self, *, include_dev: bool = False) -> dict[str, Any]: + """ + 读取 CLI 和后端运行所依赖的核心 Python 包版本。 + + :param include_dev: 是否附带开发阶段依赖 + :return: 依赖检查结果 + """ + packages: dict[str, dict[str, Any]] = {} + missing_required: list[str] = [] + for dependency_spec in self.build_dependency_specs(include_dev=include_dev): + version = dependency_spec.version or self.read_package_version(dependency_spec.distribution) + installed = bool(version) + package_payload = { + 'installed': installed, + 'version': version or '', + 'required': dependency_spec.required, + } + if dependency_spec.distribution: + package_payload['distribution'] = dependency_spec.distribution + packages[dependency_spec.package_name] = package_payload + if dependency_spec.required and not installed: + missing_required.append(dependency_spec.package_name) + + if missing_required: + return { + 'ok': False, + 'message': '存在缺失的核心运行依赖', + 'missingRequired': missing_required, + 'includeDev': include_dev, + 'packages': packages, + 'exit_code': RUNTIME_ERROR, + } + + return { + 'ok': True, + 'message': '核心运行依赖已安装', + 'missingRequired': [], + 'includeDev': include_dev, + 'packages': packages, + } + + +class OperationsServerInfoSupport: + """ + 运维服务器信息支持对象。 + + 该对象负责 CLI 兜底服务器信息构建与 IP 解析逻辑, + 避免主运行时服务继续承载大量平台采集细节。 + + :param infrastructure_gateway: 运维基础设施网关 + """ + + def __init__(self, infrastructure_gateway: OperationsInfrastructureGateway) -> None: + """ + 初始化服务器信息支持对象。 + + :param infrastructure_gateway: 运维基础设施网关 + :return: None + """ + self.infrastructure_gateway = infrastructure_gateway + + def resolve_server_ip(self, hostname: str) -> str: + """ + 解析服务器 IP,优先使用主机名解析,失败时回退到网卡地址。 + + :param hostname: 当前主机名 + :return: 可用的 IPv4 地址 + """ + psutil = self.infrastructure_gateway.get_psutil_module() + try: + resolved_ip = socket.gethostbyname(hostname) + if resolved_ip: + return resolved_ip + except OSError: + pass + + for interface_addresses in psutil.net_if_addrs().values(): + for address_info in interface_addresses: + if ( + address_info.family == socket.AF_INET + and address_info.address + and not address_info.address.startswith('127.') + ): + return address_info.address + + return '127.0.0.1' + + def build_server_info_fallback(self) -> dict[str, Any]: + """ + 构建 CLI 使用的服务器运行信息兜底数据。 + + :return: 与原服务输出结构兼容的服务器运行信息字典 + """ + psutil = self.infrastructure_gateway.get_psutil_module() + bytes2human = self.infrastructure_gateway.get_bytes2human() + cpu_num = psutil.cpu_count(logical=True) + cpu_usage_percent = psutil.cpu_times_percent() + memory_info = psutil.virtual_memory() + + hostname = socket.gethostname() + current_process = psutil.Process(os.getpid()) + start_time_stamp = current_process.create_time() + current_time_stamp = time.time() + difference = current_time_stamp - start_time_stamp + days = int(difference // (24 * 60 * 60)) + hours = int((difference % (24 * 60 * 60)) // (60 * 60)) + minutes = int((difference % (60 * 60)) // 60) + process_memory_info = current_process.memory_info() + + sys_files: list[dict[str, Any]] = [] + for partition in psutil.disk_partitions(): + try: + disk_usage = psutil.disk_usage(partition.mountpoint) + except Exception: + continue + + mountpoint = partition.mountpoint.replace('\\', '') + sys_files.append( + { + 'dirName': partition.device, + 'sysTypeName': partition.fstype, + 'typeName': f'本地固定磁盘({mountpoint})', + 'total': bytes2human(disk_usage.total), + 'used': bytes2human(disk_usage.used), + 'free': bytes2human(disk_usage.free), + 'usage': f'{disk_usage.percent}%', + } + ) + + return { + 'cpu': { + 'cpuNum': cpu_num, + 'used': cpu_usage_percent.user, + 'sys': cpu_usage_percent.system, + 'free': cpu_usage_percent.idle, + }, + 'mem': { + 'total': bytes2human(memory_info.total), + 'used': bytes2human(memory_info.used), + 'free': bytes2human(memory_info.free), + 'usage': memory_info.percent, + }, + 'sys': { + 'computerIp': self.resolve_server_ip(hostname), + 'computerName': platform.node(), + 'osArch': platform.machine(), + 'osName': platform.platform(), + 'userDir': os.getcwd(), + }, + 'py': { + 'name': current_process.name(), + 'version': platform.python_version(), + 'startTime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time_stamp)), + 'runTime': f'{days}天{hours}小时{minutes}分钟', + 'home': current_process.exe(), + 'total': bytes2human(memory_info.available), + 'used': bytes2human(process_memory_info.rss), + 'free': bytes2human(memory_info.available - process_memory_info.rss), + 'usage': round((process_memory_info.rss / memory_info.available) * 100, 2), + }, + 'sysFiles': sys_files, + } diff --git a/shierfeng-fastapi-backend/cli/tui/__init__.py b/shierfeng-fastapi-backend/cli/tui/__init__.py new file mode 100644 index 0000000..a21e01d --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/__init__.py @@ -0,0 +1,3 @@ +from cli.tui.commands import TUI_COMMAND_REGISTRATION + +__all__ = ['TUI_COMMAND_REGISTRATION'] diff --git a/shierfeng-fastapi-backend/cli/tui/actions/__init__.py b/shierfeng-fastapi-backend/cli/tui/actions/__init__.py new file mode 100644 index 0000000..5272c71 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/__init__.py @@ -0,0 +1,10 @@ +from cli.tui.actions.bootstrap import TUI_ACTION_EXECUTION_SERVICE, TUI_ACTION_PRESENTATION_SERVICE, TUI_ACTION_REGISTRY +from cli.tui.actions.models import TuiActionResult, TuiActionSpec + +__all__ = [ + 'TUI_ACTION_EXECUTION_SERVICE', + 'TUI_ACTION_PRESENTATION_SERVICE', + 'TUI_ACTION_REGISTRY', + 'TuiActionResult', + 'TuiActionSpec', +] diff --git a/shierfeng-fastapi-backend/cli/tui/actions/assembly.py b/shierfeng-fastapi-backend/cli/tui/actions/assembly.py new file mode 100644 index 0000000..1496f75 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/assembly.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass + +from cli.tui.actions.builders import TuiActionSpecFactory +from cli.tui.actions.factories.cache import CacheActionTemplateFactory +from cli.tui.actions.factories.gen import GenActionTemplateFactory +from cli.tui.actions.factories.jobs import JobActionTemplateFactory +from cli.tui.actions.factories.static import StaticActionTemplateFactory +from cli.tui.actions.registry import TuiActionRegistry, TuiActionSlotResolver + + +@dataclass(frozen=True) +class TuiActionRegistryBuilder: + """ + TUI 动作注册表构建器。 + + 该对象负责装配各领域动作模板工厂,最终输出浏览页和详情页共用的 + 动作注册表,避免模板常量继续散落在模块底部。 + + :param jobs: 任务页动作模板工厂 + :param cache: 缓存页动作模板工厂 + :param gen: 代码生成页动作模板工厂 + :param static: 静态页面动作模板工厂 + :param spec_factory: 动作规格构建器 + """ + + jobs: JobActionTemplateFactory + cache: CacheActionTemplateFactory + gen: GenActionTemplateFactory + static: StaticActionTemplateFactory + spec_factory: TuiActionSpecFactory + + def build(self) -> TuiActionRegistry: + """ + 构建 TUI 动作注册表。 + + :return: 动作注册表 + """ + return TuiActionRegistry( + browser_resolvers={ + 'jobs': TuiActionSlotResolver( + slot_templates={ + 'primary': self.jobs.create_run_once_template(), + 'secondary': self.jobs.create_toggle_template(), + 'global': self.jobs.create_sync_template(), + }, + spec_factory=self.spec_factory, + ), + 'configs': TuiActionSlotResolver( + slot_templates={ + 'global': self.static.create_config_sync_template(), + }, + spec_factory=self.spec_factory, + ), + 'cache': TuiActionSlotResolver( + slot_templates={ + 'global': self.cache.create_clear_wizard_template(), + 'utility': self.cache.create_warmup_template(), + }, + spec_factory=self.spec_factory, + ), + 'gen': TuiActionSlotResolver( + slot_templates={ + 'primary': self.gen.create_export_wizard_template(), + 'secondary': self.gen.create_import_wizard_template(), + 'global': self.gen.create_export_dry_run_template(), + 'utility': self.gen.create_sync_db_template(), + }, + spec_factory=self.spec_factory, + ), + }, + detail_resolvers={ + 'app': TuiActionSlotResolver( + slot_templates={ + 'primary': self.static.create_app_run_template(), + 'global': self.static.create_app_run_wizard_template(), + 'utility': self.static.create_completion_install_template(), + }, + spec_factory=self.spec_factory, + ), + 'database': TuiActionSlotResolver( + slot_templates={ + 'global': self.static.create_db_upgrade_wizard_template(), + 'utility': self.static.create_db_init_dry_run_template(), + }, + spec_factory=self.spec_factory, + ), + 'ops': TuiActionSlotResolver( + slot_templates={ + 'primary': self.static.create_ops_ping_db_template(), + 'secondary': self.static.create_ops_ping_redis_template(), + 'global': self.static.create_prod_check_wizard_template(), + }, + spec_factory=self.spec_factory, + ), + 'crypto': TuiActionSlotResolver( + slot_templates={ + 'primary': self.static.create_crypto_keygen_template(), + 'global': self.static.create_crypto_rotate_dry_run_template(), + }, + spec_factory=self.spec_factory, + ), + }, + ) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/bootstrap.py b/shierfeng-fastapi-backend/cli/tui/actions/bootstrap.py new file mode 100644 index 0000000..a89c859 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/bootstrap.py @@ -0,0 +1,31 @@ +from cli.tui.actions.assembly import TuiActionRegistryBuilder +from cli.tui.actions.builders import TuiActionSpecFactory, TuiActionTemplateSupport +from cli.tui.actions.execution import TuiActionExecutionService +from cli.tui.actions.factories.cache import CacheActionTemplateFactory +from cli.tui.actions.factories.gen import GenActionTemplateFactory +from cli.tui.actions.factories.jobs import JobActionTemplateFactory +from cli.tui.actions.factories.static import StaticActionTemplateFactory +from cli.tui.actions.presentation import TuiActionPresentationService +from cli.tui.capabilities import TUI_CAPABILITY_REGISTRY + +_ACTION_SPEC_FACTORY = TuiActionSpecFactory() +TUI_ACTION_EXECUTION_SERVICE = TuiActionExecutionService() + +_ACTION_TEMPLATE_SUPPORT = TuiActionTemplateSupport(spec_factory=_ACTION_SPEC_FACTORY) +_JOB_ACTION_TEMPLATE_FACTORY = JobActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT) +_CACHE_ACTION_TEMPLATE_FACTORY = CacheActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT) +_GEN_ACTION_TEMPLATE_FACTORY = GenActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT) +_STATIC_ACTION_TEMPLATE_FACTORY = StaticActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT) +_ACTION_REGISTRY_BUILDER = TuiActionRegistryBuilder( + jobs=_JOB_ACTION_TEMPLATE_FACTORY, + cache=_CACHE_ACTION_TEMPLATE_FACTORY, + gen=_GEN_ACTION_TEMPLATE_FACTORY, + static=_STATIC_ACTION_TEMPLATE_FACTORY, + spec_factory=_ACTION_SPEC_FACTORY, +) +TUI_ACTION_REGISTRY = _ACTION_REGISTRY_BUILDER.build() + +TUI_ACTION_PRESENTATION_SERVICE = TuiActionPresentationService( + capability_registry=TUI_CAPABILITY_REGISTRY, + action_registry=TUI_ACTION_REGISTRY, +) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/builders.py b/shierfeng-fastapi-backend/cli/tui/actions/builders.py new file mode 100644 index 0000000..830a65d --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/builders.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from cli.tui.actions.models import ( + ActionCommandBuilder, + ActionExecutionMode, + ActionSummaryBuilder, + ActionTextBuilder, + TuiActionSpec, +) +from cli.tui.copy import TUI_COPY +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + +if TYPE_CHECKING: + from cli.tui.adapters.models import BrowserRecordSnapshot + + +@dataclass(frozen=True) +class TuiActionSpecFactory: + """ + TUI 动作规格构建器。 + + 该对象负责统一构建动作预览文本与 `TuiActionSpec`, + 避免各动作函数继续手工拼接重复的预览和规格对象。 + """ + + @staticmethod + def parse_record_suffix(record: BrowserRecordSnapshot, prefix: str) -> str: + """ + 从记录键中提取指定前缀后的标识。 + + :param record: 浏览记录 + :param prefix: 键名前缀 + :return: 后缀标识 + """ + raw_key = str(record.key).strip() + token_prefix = f'{prefix}:' + if not raw_key.startswith(token_prefix): + return '' + return raw_key[len(token_prefix) :].strip() + + def build_command_preview_lines( + self, + *, + env: str, + summary_lines: list[str], + command_args: tuple[str, ...], + append_yes: bool = True, + consequence_text: str = TUI_COPY.build_action_consequence_text('preview'), + ) -> list[str]: + """ + 构建统一的动作预览文本。 + + :param env: 当前运行环境 + :param summary_lines: 业务摘要文本 + :param command_args: 对应 CLI 命令参数 + :param consequence_text: 结果影响说明 + :return: 预览文本 + """ + nested_cli_arguments = [ + *command_args, + f'--env={env}', + '--output=json', + ] + if append_yes: + nested_cli_arguments.append('--yes') + command = SHELL_TEXT_FORMATTER.format_shell_command( + NESTED_CLI_SUPPORT.build_nested_cli_command(*nested_cli_arguments) + ) + return [ + TUI_COPY.build_action_preview_title('summary'), + *summary_lines, + '', + TUI_COPY.build_action_preview_title('env'), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('target_env'), env), + '', + TUI_COPY.build_action_preview_title('command'), + command, + '', + TUI_COPY.build_action_preview_title('consequence'), + consequence_text, + ] + + def build_external_command_preview_lines( + self, + *, + env: str, + summary_lines: list[str], + command_args: tuple[str, ...], + append_yes: bool = True, + consequence_text: str = TUI_COPY.build_action_consequence_text('wizard'), + ) -> list[str]: + """ + 构建外部交互命令的预览文本。 + + :param env: 当前运行环境 + :param summary_lines: 业务摘要文本 + :param command_args: 对应 CLI 命令参数 + :param consequence_text: 结果影响说明 + :return: 预览文本 + """ + return self.build_command_preview_lines( + env=env, + summary_lines=summary_lines, + command_args=command_args, + append_yes=append_yes, + consequence_text=consequence_text, + ) + + def build_nested_json_action( + self, + *, + action_id: str, + label: str, + command_args: tuple[str, ...], + env: str, + summary_lines: list[str], + preview_title: str | None = None, + consequence_text: str = TUI_COPY.build_action_consequence_text('preview'), + append_yes: bool = True, + refresh_view: bool = True, + ) -> TuiActionSpec: + """ + 构建标准 nested JSON 动作。 + + :param action_id: 动作唯一标识 + :param label: 动作显示名称 + :param command_args: 对应 CLI 命令参数 + :param env: 当前运行环境 + :param summary_lines: 业务摘要文本 + :param preview_title: 预览标题 + :param consequence_text: 结果影响说明 + :param refresh_view: 执行后是否刷新视图 + :return: 动作定义 + """ + return TuiActionSpec( + action_id=action_id, + label=label, + command_args=command_args, + preview_title=preview_title or label, + preview_lines=self.build_command_preview_lines( + env=env, + summary_lines=summary_lines, + command_args=command_args, + append_yes=append_yes, + consequence_text=consequence_text, + ), + append_yes=append_yes, + refresh_view=refresh_view, + ) + + def build_external_action( + self, + *, + action_id: str, + label: str, + command_args: tuple[str, ...], + env: str, + summary_lines: list[str], + preview_title: str | None = None, + consequence_text: str = TUI_COPY.build_action_consequence_text('wizard'), + append_yes: bool = True, + refresh_view: bool = True, + ) -> TuiActionSpec: + """ + 构建标准外部交互动作。 + + :param action_id: 动作唯一标识 + :param label: 动作显示名称 + :param command_args: 对应 CLI 命令参数 + :param env: 当前运行环境 + :param summary_lines: 业务摘要文本 + :param preview_title: 预览标题 + :param consequence_text: 结果影响说明 + :param refresh_view: 执行后是否刷新视图 + :return: 动作定义 + """ + return TuiActionSpec( + action_id=action_id, + label=label, + command_args=command_args, + preview_title=preview_title or label, + preview_lines=self.build_external_command_preview_lines( + env=env, + summary_lines=summary_lines, + command_args=command_args, + append_yes=append_yes, + consequence_text=consequence_text, + ), + execution_mode='external', + append_yes=append_yes, + refresh_view=refresh_view, + ) + + +@dataclass(frozen=True) +class TuiActionTemplate: + """ + TUI 动作模板。 + + 该模板将命令参数构建、摘要构建与执行模式收敛为统一对象, + 使动作注册层可以直接声明“某个槽位对应哪类动作”。 + + :param action_id: 动作唯一标识 + :param label: 动作显示名称 + :param command_builder: 命令参数构建器 + :param summary_builder: 预览摘要构建器 + :param action_id_builder: 动态动作标识构建器 + :param label_builder: 动态动作标题构建器 + :param preview_title: 预览标题 + :param consequence_text: 结果影响说明 + :param execution_mode: 执行模式 + :param append_yes: 是否在命令执行与预览中自动追加 `--yes` + :param refresh_view: 执行后是否刷新视图 + :param preview_env_override: 预览中展示的环境名覆盖值 + """ + + action_id: str + label: str + command_builder: ActionCommandBuilder + summary_builder: ActionSummaryBuilder + action_id_builder: ActionTextBuilder | None = None + label_builder: ActionTextBuilder | None = None + preview_title: str | None = None + consequence_text: str = TUI_COPY.build_action_consequence_text('preview') + execution_mode: ActionExecutionMode = 'nested_json' + append_yes: bool = True + refresh_view: bool = True + preview_env_override: str | None = None + + def build( + self, + *, + record: BrowserRecordSnapshot | None, + env: str, + spec_factory: TuiActionSpecFactory, + ) -> TuiActionSpec | None: + """ + 根据当前页面上下文构建动作定义。 + + :param record: 当前选中记录 + :param env: 当前运行环境 + :param spec_factory: 动作规格构建器 + :return: 动作定义 + """ + command_args = self.command_builder(record, env) + if command_args is None: + return None + preview_env = self.preview_env_override or env + summary_lines = self.summary_builder(record, env) + action_id = self.action_id_builder(record, env) if self.action_id_builder is not None else self.action_id + label = self.label_builder(record, env) if self.label_builder is not None else self.label + if self.execution_mode == 'external': + return spec_factory.build_external_action( + action_id=action_id, + label=label, + command_args=command_args, + env=preview_env, + summary_lines=summary_lines, + preview_title=self.preview_title, + consequence_text=self.consequence_text, + append_yes=self.append_yes, + refresh_view=self.refresh_view, + ) + return spec_factory.build_nested_json_action( + action_id=action_id, + label=label, + command_args=command_args, + env=preview_env, + summary_lines=summary_lines, + preview_title=self.preview_title, + consequence_text=self.consequence_text, + append_yes=self.append_yes, + refresh_view=self.refresh_view, + ) + + +@dataclass(frozen=True) +class TuiActionTemplateSupport: + """ + TUI 动作模板共享构建支持。 + + 该对象集中封装多个动作模板都会复用的记录解析与摘要拼装逻辑, + 避免这些基础能力继续散落在模块级函数中。 + + :param spec_factory: 动作规格构建器 + """ + + spec_factory: TuiActionSpecFactory + + def build_scope_purpose_summary(self, scope: str, purpose: str) -> list[str]: + """ + 构建通用的作用范围与目的摘要。 + + :param scope: 作用范围说明 + :param purpose: 目的说明 + :return: 预览摘要 + """ + return [ + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('scope'), scope), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('purpose'), purpose), + ] + + def extract_job_id(self, record: BrowserRecordSnapshot | None) -> str | None: + """ + 从任务记录中提取任务 ID。 + + :param record: 当前记录 + :return: 任务 ID + """ + if record is None: + return None + job_id = self.spec_factory.parse_record_suffix(record, 'job') + return job_id if job_id.isdigit() else None + + @staticmethod + def require_record_title(record: BrowserRecordSnapshot | None) -> str | None: + """ + 提取记录标题作为命令目标。 + + :param record: 当前记录 + :return: 标题文本 + """ + if record is None: + return None + title = str(record.title).strip() + return title or None diff --git a/shierfeng-fastapi-backend/cli/tui/actions/execution.py b/shierfeng-fastapi-backend/cli/tui/actions/execution.py new file mode 100644 index 0000000..cb15a63 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/execution.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass + +from cli.tui.actions.models import TuiActionResult, TuiActionSpec +from cli.tui.copy import TUI_COPY +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class TuiActionExecutionService: + """ + TUI 动作执行服务。 + + 该对象负责统一封装 nested JSON 动作、外部交互动作 + 与结果文本行的收口逻辑。 + """ + + def execute(self, spec: TuiActionSpec, env: str) -> TuiActionResult: + """ + 执行指定 TUI 动作。 + + :param spec: 动作定义 + :param env: 当前运行环境 + :return: 动作执行结果 + """ + command_arguments = [ + *spec.command_args, + f'--env={env}', + '--output=json', + ] + if spec.append_yes: + command_arguments.append('--yes') + payload = NESTED_CLI_SUPPORT.run(*command_arguments, parse_json=True).payload + return TuiActionResult(spec=spec, payload=payload) + + def execute_external(self, spec: TuiActionSpec) -> TuiActionResult: + """ + 在当前终端中执行交互式 TUI 动作。 + + :param spec: 动作定义 + :return: 动作执行结果 + """ + completed = NESTED_CLI_SUPPORT.run_live(*spec.command_args) + if completed.returncode == 0: + message = '外部交互命令已执行完成' + else: + message = f'外部交互命令执行失败,退出码 {completed.returncode}' + return TuiActionResult( + spec=spec, + external_exit_code=completed.returncode, + external_message=message, + ) + + def build_result_lines(self, result: TuiActionResult) -> list[str]: + """ + 构建动作结果详情文本。 + + :param result: 动作执行结果 + :return: 结果文本行 + """ + payload = result.payload if isinstance(result.payload, dict) else {} + lines = [ + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('name'), + result.spec.label, + ), + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('outcome'), + TUI_COPY.build_action_result_field_label('success') + if result.ok + else TUI_COPY.build_action_result_field_label('fail'), + ), + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('summary'), + SHELL_TEXT_FORMATTER.truncate_text(result.message, 88), + ), + ] + service_message = str(payload.get('serviceMessage', '') or '').strip() + if service_message: + lines.append( + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('service'), + SHELL_TEXT_FORMATTER.truncate_text(service_message, 88), + ) + ) + if payload.get('hint'): + lines.append( + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('hint'), + SHELL_TEXT_FORMATTER.truncate_text(str(payload.get('hint', '') or ''), 88), + ) + ) + if payload.get('count') is not None: + lines.append( + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('count'), + str(payload.get('count')), + ) + ) + if payload.get('jobId') is not None: + lines.append( + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('job_id'), + str(payload.get('jobId')), + ) + ) + if result.external_exit_code is not None: + lines.append( + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('exit_code'), + str(result.external_exit_code), + ) + ) + operation_label = str(payload.get('operationLabel', '') or '').strip() + if operation_label and operation_label != result.spec.label: + lines.append( + TUI_COPY.build_action_result_message_line( + TUI_COPY.build_action_result_field_label('operation'), + SHELL_TEXT_FORMATTER.truncate_text(operation_label, 64), + ) + ) + return lines diff --git a/shierfeng-fastapi-backend/cli/tui/actions/factories/__init__.py b/shierfeng-fastapi-backend/cli/tui/actions/factories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-backend/cli/tui/actions/factories/cache.py b/shierfeng-fastapi-backend/cli/tui/actions/factories/cache.py new file mode 100644 index 0000000..293b301 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/factories/cache.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass + +from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport +from cli.tui.adapters.models import BrowserRecordSnapshot +from cli.tui.copy import TUI_COPY + + +@dataclass(frozen=True) +class CacheActionTemplateFactory: + """ + 缓存页动作模板工厂。 + + 该对象负责生成缓存浏览页相关动作模板,统一缓存清理向导和预热动作 + 的命令与预览摘要构建。 + + :param support: 动作模板共享构建支持 + """ + + support: TuiActionTemplateSupport + + def build_clear_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建缓存清理向导命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + cache_name = record.title if record is not None else '' + return ( + 'wizard', + 'cache-clear', + '--output=text', + f'--default-env={env}', + '--default-mode=cache-name', + f'--default-cache-name={cache_name}', + '--default-dry-run', + ) + + def build_clear_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建缓存清理向导摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del record, env + return self.support.build_scope_purpose_summary( + '当前环境缓存清理流程', + '进入向导后确认缓存名、键前缀和 dry-run 范围', + ) + + def create_warmup_template(self) -> TuiActionTemplate: + """ + 创建缓存预热动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='cache-warmup', + label=TUI_COPY.build_action_label('cache_warmup'), + command_builder=lambda record, env: ('cache', 'warmup'), + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('cache_warmup'), + TUI_COPY.build_action_purpose_label('cache_warmup'), + ), + ) + + def create_clear_wizard_template(self) -> TuiActionTemplate: + """ + 创建缓存清理向导动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='wizard-cache-clear', + label=TUI_COPY.build_action_label('cache_clear_wizard'), + command_builder=self.build_clear_command, + summary_builder=self.build_clear_summary, + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + ) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/factories/gen.py b/shierfeng-fastapi-backend/cli/tui/actions/factories/gen.py new file mode 100644 index 0000000..40bd68b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/factories/gen.py @@ -0,0 +1,254 @@ +from dataclasses import dataclass + +from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport +from cli.tui.adapters.models import BrowserRecordSnapshot +from cli.tui.copy import TUI_COPY + + +@dataclass(frozen=True) +class GenActionTemplateFactory: + """ + 代码生成页动作模板工厂。 + + 该对象负责生成代码生成浏览页相关动作模板,统一记录标题提取、 + 导出导入向导和表结构同步动作的构建逻辑。 + + :param support: 动作模板共享构建支持 + """ + + support: TuiActionTemplateSupport + + def build_export_wizard_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None: + """ + 构建代码生成导出向导命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + table_name = self.support.require_record_title(record) + if table_name is None: + return None + return ( + 'wizard', + 'gen-export', + '--output=text', + f'--default-env={env}', + f'--default-table-names={table_name}', + '--default-mode=zip', + '--default-dry-run', + ) + + def build_import_wizard_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None: + """ + 构建代码生成导入向导命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + table_name = self.support.require_record_title(record) + if table_name is None: + return None + return ( + 'wizard', + 'gen-import', + '--output=text', + f'--default-env={env}', + f'--default-table-names={table_name}', + '--default-dry-run', + ) + + def build_export_dry_run_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None: + """ + 构建代码生成导出 dry-run 命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del env + table_name = self.support.require_record_title(record) + if table_name is None: + return None + return ('gen', 'export', table_name, '--dry-run', '--mode=zip') + + def build_sync_db_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None: + """ + 构建代码生成表结构同步命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del env + table_name = self.support.require_record_title(record) + if table_name is None: + return None + return ('gen', 'sync-db', table_name) + + @staticmethod + def build_export_wizard_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建代码生成导出向导摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del env + if record is None: + return [] + return [ + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('scope'), f'当前业务表 {record.title}' + ), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('current_status'), + record.summary, + ), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('purpose'), + '进入向导后选择业务表、输出目录和覆盖策略', + ), + ] + + @staticmethod + def build_import_wizard_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建代码生成导入向导摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del env + if record is None: + return [] + return [ + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('scope'), f'当前物理表 {record.title}' + ), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('current_status'), + record.summary, + ), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('purpose'), + '进入向导后确认物理表列表,并先执行 dry-run 导入预演', + ), + ] + + @staticmethod + def build_export_dry_run_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建代码生成导出预演摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del env + if record is None: + return [] + return [ + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('scope'), + TUI_COPY.build_action_scope_label('gen_export_dry_run'), + ), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('current_status'), + record.summary, + ), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('purpose'), + TUI_COPY.build_action_purpose_label('gen_export_dry_run'), + ), + ] + + @staticmethod + def build_sync_db_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建代码生成表结构同步摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del env + if record is None: + return [] + return [ + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('scope'), + TUI_COPY.build_action_scope_label('gen_sync_db'), + ), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('current_status'), + record.summary, + ), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('purpose'), + TUI_COPY.build_action_purpose_label('gen_sync_db'), + ), + ] + + def create_export_wizard_template(self) -> TuiActionTemplate: + """ + 创建代码生成导出向导动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='wizard-gen-export', + label=TUI_COPY.build_action_label('gen_export_wizard'), + command_builder=self.build_export_wizard_command, + summary_builder=self.build_export_wizard_summary, + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + refresh_view=False, + ) + + def create_import_wizard_template(self) -> TuiActionTemplate: + """ + 创建代码生成导入向导动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='wizard-gen-import', + label=TUI_COPY.build_action_label('gen_import_wizard'), + command_builder=self.build_import_wizard_command, + summary_builder=self.build_import_wizard_summary, + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + refresh_view=False, + ) + + def create_export_dry_run_template(self) -> TuiActionTemplate: + """ + 创建代码生成导出预演动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='gen-export-dry-run', + label=TUI_COPY.build_action_label('gen_export_dry_run'), + command_builder=self.build_export_dry_run_command, + summary_builder=self.build_export_dry_run_summary, + ) + + def create_sync_db_template(self) -> TuiActionTemplate: + """ + 创建代码生成表结构同步动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='gen-sync-db', + label=TUI_COPY.build_action_label('gen_sync_db'), + command_builder=self.build_sync_db_command, + summary_builder=self.build_sync_db_summary, + ) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/factories/jobs.py b/shierfeng-fastapi-backend/cli/tui/actions/factories/jobs.py new file mode 100644 index 0000000..1e5032f --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/factories/jobs.py @@ -0,0 +1,171 @@ +from dataclasses import dataclass + +from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport +from cli.tui.adapters.models import BrowserRecordSnapshot +from cli.tui.copy import TUI_COPY + + +@dataclass(frozen=True) +class JobActionTemplateFactory: + """ + 任务页动作模板工厂。 + + 该对象负责生成任务浏览页相关动作模板,收口任务 ID 解析、 + 暂停/恢复切换和摘要拼装逻辑。 + + :param support: 动作模板共享构建支持 + """ + + support: TuiActionTemplateSupport + + def build_run_once_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None: + """ + 构建任务执行一次命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del env + job_id = self.support.extract_job_id(record) + if job_id is None: + return None + return ('job', 'run-once', job_id) + + def build_run_once_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建任务执行一次动作摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del env + if record is None: + return [] + job_id = self.support.extract_job_id(record) or '' + return [ + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job_id'), job_id), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('current_status'), + record.summary, + ), + ] + + def build_toggle_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None: + """ + 构建任务暂停或恢复命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del env + if record is None: + return None + job_id = self.support.extract_job_id(record) + if job_id is None: + return None + if record.status == 'warn': + return ('job', 'resume', job_id) + return ('job', 'pause', job_id) + + def build_toggle_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]: + """ + 构建任务暂停或恢复动作摘要。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 预览摘要 + """ + del env + if record is None: + return [] + job_id = self.support.extract_job_id(record) or '' + label = ( + TUI_COPY.build_action_label('job_resume') + if record.status == 'warn' + else TUI_COPY.build_action_label('job_pause') + ) + return [ + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job_id'), job_id), + TUI_COPY.build_labeled_value_line( + TUI_COPY.build_action_preview_field_label('current_status'), + record.summary, + ), + TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('target_action'), label), + ] + + @staticmethod + def build_toggle_action_id(record: BrowserRecordSnapshot | None, env: str) -> str: + """ + 构建任务暂停或恢复动作标识。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 动作标识 + """ + del env + if record is not None and record.status == 'warn': + return 'job-resume' + return 'job-pause' + + @staticmethod + def build_toggle_label(record: BrowserRecordSnapshot | None, env: str) -> str: + """ + 构建任务暂停或恢复动作标题。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 动作标题 + """ + del env + if record is not None and record.status == 'warn': + return TUI_COPY.build_action_label('job_resume') + return TUI_COPY.build_action_label('job_pause') + + def create_run_once_template(self) -> TuiActionTemplate: + """ + 创建任务执行一次动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='job-run-once', + label=TUI_COPY.build_action_label('job_run_once'), + command_builder=self.build_run_once_command, + summary_builder=self.build_run_once_summary, + ) + + def create_toggle_template(self) -> TuiActionTemplate: + """ + 创建任务暂停或恢复动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='job-toggle', + label=TUI_COPY.build_action_label('job_pause'), + command_builder=self.build_toggle_command, + summary_builder=self.build_toggle_summary, + action_id_builder=self.build_toggle_action_id, + label_builder=self.build_toggle_label, + ) + + def create_sync_template(self) -> TuiActionTemplate: + """ + 创建任务同步动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='job-sync', + label=TUI_COPY.build_action_label('job_sync'), + command_builder=lambda record, env: ('job', 'sync'), + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('job_sync'), + TUI_COPY.build_action_purpose_label('job_sync'), + ), + ) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/factories/static.py b/shierfeng-fastapi-backend/cli/tui/actions/factories/static.py new file mode 100644 index 0000000..ba51ace --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/factories/static.py @@ -0,0 +1,385 @@ +from dataclasses import dataclass + +from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport +from cli.tui.adapters.models import BrowserRecordSnapshot +from cli.tui.copy import TUI_COPY + + +@dataclass(frozen=True) +class StaticActionTemplateFactory: + """ + 静态页面动作模板工厂。 + + 该对象负责生成不依赖当前记录内容的页面动作模板,统一详情页和 + 部分浏览页的固定动作定义。 + + :param support: 动作模板共享构建支持 + """ + + support: TuiActionTemplateSupport + + @staticmethod + def build_job_sync_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建任务同步命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('job', 'sync') + + @staticmethod + def build_config_sync_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建配置同步命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('config', 'sync-cache') + + @staticmethod + def build_cache_warmup_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建缓存预热命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('cache', 'warmup') + + @staticmethod + def build_db_upgrade_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建数据库升级向导命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record + return ( + 'wizard', + 'db-upgrade', + '--output=text', + f'--default-env={env}', + '--default-revision=head', + '--default-dry-run', + ) + + @staticmethod + def build_db_init_dry_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建数据库初始化预演命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('db', 'init', '--dry-run') + + @staticmethod + def build_app_run_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建应用启动向导命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('wizard', 'app-run') + + @staticmethod + def build_completion_install_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建补全安装命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('completion', 'install', '--activate') + + @staticmethod + def build_prod_check_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建生产巡检向导命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record + return ( + 'wizard', + 'prod-check', + '--output=text', + f'--default-env={env}', + '--default-include-config', + ) + + @staticmethod + def build_crypto_rotate_dry_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建加密轮换预演命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('crypto', 'rotate', '--dry-run') + + @staticmethod + def build_app_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建应用直接启动命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record + return ('app', 'run', f'--env={env}') + + @staticmethod + def build_crypto_keygen_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建加密密钥生成命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record + return ('crypto', 'keygen', f'--env={env}', '--output=text') + + @staticmethod + def build_ops_ping_db_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建数据库探活命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('ops', 'ping-db') + + @staticmethod + def build_ops_ping_redis_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]: + """ + 构建 Redis 探活命令参数。 + + :param record: 当前记录 + :param env: 当前运行环境 + :return: 命令参数 + """ + del record, env + return ('ops', 'ping-redis') + + def create_config_sync_template(self) -> TuiActionTemplate: + """ + 创建配置同步动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='config-sync-cache', + label=TUI_COPY.build_action_label('config_sync'), + command_builder=self.build_config_sync_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('config_sync'), + TUI_COPY.build_action_purpose_label('config_sync'), + ), + ) + + def create_db_upgrade_wizard_template(self) -> TuiActionTemplate: + """ + 创建数据库升级向导动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='wizard-db-upgrade', + label=TUI_COPY.build_action_label('db_upgrade_wizard'), + command_builder=self.build_db_upgrade_wizard_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + '当前环境数据库升级流程', + '进入向导后确认目标 revision、环境和 dry-run 预演', + ), + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + ) + + def create_db_init_dry_run_template(self) -> TuiActionTemplate: + """ + 创建数据库初始化预演动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='db-init-dry-run', + label=TUI_COPY.build_action_label('db_init_dry_run'), + command_builder=self.build_db_init_dry_run_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('db_init_dry_run'), + TUI_COPY.build_action_purpose_label('db_init_dry_run'), + ), + append_yes=False, + ) + + def create_app_run_wizard_template(self) -> TuiActionTemplate: + """ + 创建应用启动向导动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='wizard-app-run', + label=TUI_COPY.build_action_label('app_run_wizard'), + command_builder=self.build_app_run_wizard_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + '当前环境应用启动流程', + '进入向导后确认环境,并决定是否先执行启动前检查', + ), + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + refresh_view=False, + ) + + def create_completion_install_template(self) -> TuiActionTemplate: + """ + 创建补全安装动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='completion-install', + label=TUI_COPY.build_action_label('completion_install'), + command_builder=self.build_completion_install_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('completion_install'), + TUI_COPY.build_action_purpose_label('completion_install'), + ), + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('external'), + refresh_view=False, + preview_env_override='-', + ) + + def create_prod_check_wizard_template(self) -> TuiActionTemplate: + """ + 创建生产巡检向导动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='wizard-prod-check', + label=TUI_COPY.build_action_label('prod_check_wizard'), + command_builder=self.build_prod_check_wizard_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + '当前环境生产巡检流程', + '进入向导后统一检查数据库、缓存和运行配置', + ), + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + ) + + def create_crypto_rotate_dry_run_template(self) -> TuiActionTemplate: + """ + 创建加密轮换预演动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='crypto-rotate-dry-run', + label=TUI_COPY.build_action_label('crypto_rotate_dry_run'), + command_builder=self.build_crypto_rotate_dry_run_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + '当前环境传输加密轮换预演', + '仅生成轮换辅助结果,不直接写入新密钥配置', + ), + append_yes=False, + ) + + def create_app_run_template(self) -> TuiActionTemplate: + """ + 创建应用直接启动动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='app-run', + label=TUI_COPY.build_action_label('app_run'), + command_builder=self.build_app_run_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + '当前环境应用直接启动流程', + '确认后会在当前终端直接启动当前 FastAPI 应用,并持续占用终端会话', + ), + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + refresh_view=False, + ) + + def create_crypto_keygen_template(self) -> TuiActionTemplate: + """ + 创建密钥生成动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='crypto-keygen', + label=TUI_COPY.build_action_label('crypto_keygen'), + command_builder=self.build_crypto_keygen_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + '当前环境新密钥生成流程', + '在终端中直接输出新密钥材料和环境变量补丁建议', + ), + execution_mode='external', + consequence_text=TUI_COPY.build_action_consequence_text('wizard'), + refresh_view=False, + ) + + def create_ops_ping_db_template(self) -> TuiActionTemplate: + """ + 创建数据库探活动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='ops-ping-db', + label=TUI_COPY.build_action_label('ops_ping_db'), + command_builder=self.build_ops_ping_db_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('ops_ping_db'), + TUI_COPY.build_action_purpose_label('ops_ping_db'), + ), + append_yes=False, + ) + + def create_ops_ping_redis_template(self) -> TuiActionTemplate: + """ + 创建 Redis 探活动作模板。 + + :return: 动作模板 + """ + return TuiActionTemplate( + action_id='ops-ping-redis', + label=TUI_COPY.build_action_label('ops_ping_redis'), + command_builder=self.build_ops_ping_redis_command, + summary_builder=lambda record, env: self.support.build_scope_purpose_summary( + TUI_COPY.build_action_scope_label('ops_ping_redis'), + TUI_COPY.build_action_purpose_label('ops_ping_redis'), + ), + append_yes=False, + ) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/models.py b/shierfeng-fastapi-backend/cli/tui/actions/models.py new file mode 100644 index 0000000..621bd5e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/models.py @@ -0,0 +1,72 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER, BrowserRecordSnapshot + +ActionSlot = Literal['primary', 'secondary', 'global', 'utility'] +ActionExecutionMode = Literal['nested_json', 'external'] +ActionCommandBuilder = Callable[[BrowserRecordSnapshot | None, str], tuple[str, ...] | None] +ActionSummaryBuilder = Callable[[BrowserRecordSnapshot | None, str], list[str]] +ActionTextBuilder = Callable[[BrowserRecordSnapshot | None, str], str] + + +@dataclass(frozen=True) +class TuiActionSpec: + """ + TUI 低风险动作定义。 + + :param action_id: 动作唯一标识 + :param label: 动作显示名称 + :param command_args: 对应 CLI 命令参数 + :param preview_title: 预览弹窗标题 + :param preview_lines: 预览摘要文本 + :param append_yes: 执行 nested JSON 动作时是否自动追加 `--yes` + :param refresh_view: 执行完成后是否刷新当前页面 + """ + + action_id: str + label: str + command_args: tuple[str, ...] + preview_title: str + preview_lines: list[str] + execution_mode: ActionExecutionMode = 'nested_json' + append_yes: bool = True + refresh_view: bool = True + + +@dataclass(frozen=True) +class TuiActionResult: + """ + TUI 动作执行结果。 + + :param spec: 动作定义 + :param payload: CLI JSON 负载 + """ + + spec: TuiActionSpec + payload: dict[str, object] | None = None + external_exit_code: int | None = None + external_message: str | None = None + + @property + def ok(self) -> bool: + """ + 判断动作是否执行成功。 + + :return: 是否成功 + """ + if self.external_exit_code is not None: + return self.external_exit_code == 0 + return bool(isinstance(self.payload, dict) and self.payload.get('ok', False)) + + @property + def message(self) -> str: + """ + 获取动作结果摘要。 + + :return: 结果摘要 + """ + if self.external_message is not None: + return self.external_message + return TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(self.payload) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/presentation.py b/shierfeng-fastapi-backend/cli/tui/actions/presentation.py new file mode 100644 index 0000000..e4cd232 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/presentation.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass + +from cli.tui.actions.models import TuiActionResult +from cli.tui.actions.registry import TuiActionRegistry +from cli.tui.adapters.models import BrowserRecordSnapshot +from cli.tui.capabilities import TuiCapabilityRegistry +from cli.tui.copy import TUI_COPY +from cli.tui.keymaps import TUI_KEYMAP_REGISTRY +from cli.utils import SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class TuiActionPresentationService: + """ + TUI 动作展示服务。 + + 该对象负责动作提示、动作列表和动作结果通知等展示层文本构建, + 避免继续保留模块级桥接函数。 + """ + + capability_registry: TuiCapabilityRegistry + action_registry: TuiActionRegistry + + def build_browser_action_hint(self, view_key: str) -> str: + """ + 构建浏览页动作提示文本。 + + :param view_key: 当前页面视图标识 + :return: 动作提示文本 + """ + capability_labels = [ + f'[{capability.key}] {capability.hint_label}' + for capability in self.capability_registry.get_browser_capabilities(view_key) + ] + interaction_text = TUI_COPY.build_browser_action_hint_text( + view_key, TUI_KEYMAP_REGISTRY.browser_interaction_hint + ) + return TUI_COPY.build_capability_hint_text( + capability_labels, + interaction_text, + fallback='{interaction_hint}', + ) + + def build_detail_action_hint(self, view_key: str) -> str: + """ + 构建详情页动作提示文本。 + + :param view_key: 当前页面视图标识 + :return: 动作提示文本 + """ + capability_labels = [ + f'[{capability.key}] {capability.hint_label}' + for capability in self.capability_registry.get_detail_capabilities(view_key) + ] + interaction_text = TUI_COPY.build_detail_action_hint_text( + view_key, TUI_KEYMAP_REGISTRY.browser_interaction_hint + ) + return TUI_COPY.build_capability_hint_text( + capability_labels, + interaction_text, + fallback='{interaction_hint}', + ) + + def build_browser_action_lines( + self, + *, + view_key: str, + record: BrowserRecordSnapshot | None, + env: str, + ) -> list[str]: + """ + 构建当前页面可执行动作列表。 + + :param view_key: 当前页面视图标识 + :param record: 当前选中记录 + :param env: 当前运行环境 + :return: 动作文本行 + """ + lines: list[str] = [] + for capability in self.capability_registry.get_browser_capabilities(view_key): + slot = capability.slot # type: ignore[assignment] + action = self.action_registry.resolve_browser_action( + view_key=view_key, + slot=slot, + record=record, + env=env, + ) + if action is None: + continue + lines.append(TUI_COPY.build_action_line(capability.key, action.label)) + return lines or [TUI_COPY.build_action_empty_line()] + + @staticmethod + def build_action_result_message(result: TuiActionResult) -> str: + """ + 构建动作执行结果通知文本。 + + :param result: 动作执行结果 + :return: 通知文本 + """ + outcome = ( + TUI_COPY.build_action_result_field_label('success') + if result.ok + else TUI_COPY.build_action_result_field_label('fail') + ) + return TUI_COPY.build_action_result_toast( + result.spec.label, + outcome, + SHELL_TEXT_FORMATTER.truncate_text(result.message, 72), + ) diff --git a/shierfeng-fastapi-backend/cli/tui/actions/registry.py b/shierfeng-fastapi-backend/cli/tui/actions/registry.py new file mode 100644 index 0000000..90534e2 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/actions/registry.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cli.tui.actions.builders import TuiActionSpecFactory, TuiActionTemplate + from cli.tui.actions.models import ActionSlot, TuiActionSpec + from cli.tui.adapters.models import BrowserRecordSnapshot + + +@dataclass(frozen=True) +class TuiActionRegistry: + """ + TUI 动作注册表。 + + 该注册表负责集中维护浏览页和详情页的动作解析器,从而将 + 原本散落的条件分发收敛为可声明的映射关系。 + + :param browser_resolvers: 浏览页动作槽位解析器映射 + :param detail_resolvers: 详情页动作槽位解析器映射 + """ + + browser_resolvers: dict[str, TuiActionSlotResolver] + detail_resolvers: dict[str, TuiActionSlotResolver] + + def resolve_browser_action( + self, + *, + view_key: str, + slot: ActionSlot, + record: BrowserRecordSnapshot | None, + env: str, + ) -> TuiActionSpec | None: + """ + 解析浏览页动作。 + + :param view_key: 当前页面视图标识 + :param slot: 动作槽位 + :param record: 当前选中记录 + :param env: 当前运行环境 + :return: 动作定义 + """ + resolver = self.browser_resolvers.get(view_key) + if resolver is None: + return None + return resolver.resolve(slot=slot, record=record, env=env) + + def resolve_detail_action( + self, + *, + view_key: str, + slot: ActionSlot, + env: str, + ) -> TuiActionSpec | None: + """ + 解析详情页动作。 + + :param view_key: 当前页面视图标识 + :param slot: 动作槽位 + :param env: 当前运行环境 + :return: 动作定义 + """ + resolver = self.detail_resolvers.get(view_key) + if resolver is None: + return None + return resolver.resolve(slot=slot, record=None, env=env) + + +@dataclass(frozen=True) +class TuiActionSlotResolver: + """ + TUI 动作槽位解析器。 + + 该对象将一个页面支持的槽位动作集中定义为映射关系,避免继续在 + `resolve_*_action` 中堆叠条件分支。 + + :param slot_templates: 槽位到动作模板的映射 + :param spec_factory: 动作规格构建器 + """ + + slot_templates: dict[ActionSlot, TuiActionTemplate] + spec_factory: TuiActionSpecFactory + + def resolve( + self, + *, + slot: ActionSlot, + record: BrowserRecordSnapshot | None, + env: str, + ) -> TuiActionSpec | None: + """ + 解析指定槽位的动作定义。 + + :param slot: 动作槽位 + :param record: 当前选中记录 + :param env: 当前运行环境 + :return: 动作定义 + """ + template = self.slot_templates.get(slot) + if template is None: + return None + return template.build(record=record, env=env, spec_factory=self.spec_factory) diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/__init__.py b/shierfeng-fastapi-backend/cli/tui/adapters/__init__.py new file mode 100644 index 0000000..55d1882 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/__init__.py @@ -0,0 +1,98 @@ +from collections.abc import Callable +from dataclasses import dataclass + +from cli.tui.adapters.app import APP_DETAIL_ADAPTER as _APP_DETAIL_ADAPTER +from cli.tui.adapters.cache import CACHE_BROWSER_ADAPTER as _CACHE_BROWSER_ADAPTER +from cli.tui.adapters.configs import CONFIGS_BROWSER_ADAPTER as _CONFIGS_BROWSER_ADAPTER +from cli.tui.adapters.crypto import CRYPTO_DETAIL_ADAPTER as _CRYPTO_DETAIL_ADAPTER +from cli.tui.adapters.database import DATABASE_DETAIL_ADAPTER as _DATABASE_DETAIL_ADAPTER +from cli.tui.adapters.gen import GEN_BROWSER_ADAPTER as _GEN_BROWSER_ADAPTER +from cli.tui.adapters.health import ( + DASHBOARD_ADAPTER as _DASHBOARD_ADAPTER, +) +from cli.tui.adapters.health import DashboardPanelSnapshot, DashboardSnapshot +from cli.tui.adapters.jobs import JOBS_BROWSER_ADAPTER as _JOBS_BROWSER_ADAPTER +from cli.tui.adapters.models import ( + BrowserPageSnapshot, + BrowserRecordSnapshot, + DetailPageSnapshot, + DetailSectionSnapshot, +) +from cli.tui.adapters.ops import OPS_DETAIL_ADAPTER as _OPS_DETAIL_ADAPTER + +PageSnapshot = BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot +SnapshotCollector = Callable[..., PageSnapshot] + + +@dataclass(frozen=True) +class TuiSnapshotCollectorRegistry: + """ + TUI 页面快照采集注册表。 + + 该对象集中维护页面视图标识与其采集函数之间的映射, + 使应用层和测试层可以通过统一入口发现当前可用的采集器。 + + :param collectors: 视图到采集函数的映射 + """ + + collectors: dict[str, SnapshotCollector] + + @staticmethod + def normalize_view_key(view_key: str) -> str: + """ + 规范化页面视图标识。 + + :param view_key: 原始页面视图标识 + :return: 规范化后的视图标识 + """ + return str(view_key).strip().lower() + + def get_collector(self, view_key: str) -> SnapshotCollector | None: + """ + 读取指定视图的快照采集器。 + + :param view_key: 页面视图标识 + :return: 采集函数 + """ + return self.collectors.get(self.normalize_view_key(view_key)) + + def collect(self, view_key: str, *args, **kwargs) -> PageSnapshot: + """ + 调用指定视图的快照采集器。 + + :param view_key: 页面视图标识 + :param args: 位置参数 + :param kwargs: 关键字参数 + :return: 页面快照 + :raises KeyError: 当视图未注册时抛出 + """ + collector = self.get_collector(view_key) + if collector is None: + raise KeyError(self.normalize_view_key(view_key)) + return collector(*args, **kwargs) + + +TUI_SNAPSHOT_COLLECTOR_REGISTRY = TuiSnapshotCollectorRegistry( + collectors={ + 'dashboard': _DASHBOARD_ADAPTER.collect_snapshot, + 'app': _APP_DETAIL_ADAPTER.collect_snapshot, + 'ops': _OPS_DETAIL_ADAPTER.collect_snapshot, + 'database': _DATABASE_DETAIL_ADAPTER.collect_snapshot, + 'cache': _CACHE_BROWSER_ADAPTER.collect_snapshot, + 'jobs': _JOBS_BROWSER_ADAPTER.collect_snapshot, + 'gen': _GEN_BROWSER_ADAPTER.collect_snapshot, + 'configs': _CONFIGS_BROWSER_ADAPTER.collect_snapshot, + 'crypto': _CRYPTO_DETAIL_ADAPTER.collect_snapshot, + } +) + +__all__ = [ + 'TUI_SNAPSHOT_COLLECTOR_REGISTRY', + 'BrowserPageSnapshot', + 'BrowserRecordSnapshot', + 'DashboardPanelSnapshot', + 'DashboardSnapshot', + 'DetailPageSnapshot', + 'DetailSectionSnapshot', + 'TuiSnapshotCollectorRegistry', +] diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/app.py b/shierfeng-fastapi-backend/cli/tui/adapters/app.py new file mode 100644 index 0000000..217ca23 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/app.py @@ -0,0 +1,680 @@ +from dataclasses import dataclass +from typing import Any + +from cli.tui.adapters.base import BaseDetailAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + DetailPageSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + +COMPLETION_PREVIEW_LINE_LIMIT = 12 + + +@dataclass(frozen=True) +class AppDetailSourcePayloads: + """ + 应用详情页原始数据源快照。 + + :param env_payload: `app env` 结果 + :param config_payload: `app config` 结果 + :param doctor_payload: `app doctor` 结果 + :param routes_payload: `app routes` 结果 + :param completion_payload: `completion doctor` 结果 + :param completion_preview_result: `completion show` 命令结果 + :param preview_shell: 当前补全脚本预览 shell + """ + + env_payload: dict[str, Any] | None + config_payload: dict[str, Any] | None + doctor_payload: dict[str, Any] | None + routes_payload: dict[str, Any] | None + completion_payload: dict[str, Any] | None + completion_preview_result: Any + preview_shell: str + + +class AppDetailSnapshotCollector: + """ + 应用详情页数据采集器。 + + 该对象负责拉取应用详情页所需的多路 CLI 原始结果, + 让 `AppDetailAdapter` 保持详情页编排职责。 + + :param section_builder: 应用详情分区构建器 + """ + + def __init__(self, section_builder: 'AppSectionBuilder') -> None: + """ + 初始化应用详情页数据采集器。 + + :param section_builder: 应用详情分区构建器 + :return: None + """ + self.section_builder = section_builder + + def collect(self, env: str) -> AppDetailSourcePayloads: + """ + 采集应用详情页所需原始结果。 + + :param env: 当前运行环境 + :return: 应用详情页原始数据源快照 + """ + env_payload = NESTED_CLI_SUPPORT.run( + 'app', + 'env', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + config_payload = NESTED_CLI_SUPPORT.run( + 'app', + 'config', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + doctor_payload = NESTED_CLI_SUPPORT.run( + 'app', + 'doctor', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + routes_payload = NESTED_CLI_SUPPORT.run( + 'app', + 'routes', + f'--env={env}', + '--group-by=tag', + '--output=json', + parse_json=True, + ).payload + completion_payload = NESTED_CLI_SUPPORT.run( + 'completion', + 'doctor', + '--output=json', + parse_json=True, + ).payload + preview_shell = self.section_builder.resolve_completion_preview_shell(completion_payload) + completion_preview_result = NESTED_CLI_SUPPORT.run('completion', 'show', preview_shell) + return AppDetailSourcePayloads( + env_payload=env_payload, + config_payload=config_payload, + doctor_payload=doctor_payload, + routes_payload=routes_payload, + completion_payload=completion_payload, + completion_preview_result=completion_preview_result, + preview_shell=preview_shell, + ) + + +class AppSectionBuilder: + """ + 应用详情分区构建器。 + + 该构建器负责将应用、补全和路由相关 CLI 结果转换为 TUI 详情页分区, + 使详情页适配器本体只保留采集与编排职责。 + """ + + @staticmethod + def resolve_completion_preview_shell(payload: dict[str, Any] | None) -> str: + """ + 解析补全脚本预览应使用的 shell。 + + :param payload: `completion doctor` JSON 负载 + :return: shell 名称 + """ + if not isinstance(payload, dict): + return 'bash' + active_shell = str(payload.get('activeShell') or '').strip().lower() + shells = payload.get('shells') if isinstance(payload.get('shells'), dict) else {} + if active_shell and isinstance(shells.get(active_shell), dict) and shells[active_shell].get('supported', False): + return active_shell + for shell_name in ('bash', 'zsh', 'fish', 'powershell'): + shell_payload = shells.get(shell_name) + if isinstance(shell_payload, dict) and shell_payload.get('supported', False): + return shell_name + return 'bash' + + @staticmethod + def build_env_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建应用环境解析分区。 + + :param payload: `app env` JSON 负载 + :return: 分区快照 + """ + runtime = payload.get('runtime') if isinstance(payload, dict) else None + if not isinstance(runtime, dict): + return DetailSectionSnapshot( + title='环境解析', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='环境信息', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='环境解析', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 环境映射', + f'CLI 目标环境: {runtime.get("cliEnv", "-")}', + f'配置模块环境: {runtime.get("configEnv", "-")}', + f'进程环境变量: {runtime.get("appEnv", "-") or "-"}', + '', + '## 文件与进程', + f'环境文件: {runtime.get("envFile", "-")}', + f'环境文件存在: {"是" if runtime.get("envFileExists", False) else "否"}', + f'后端目录: {SHELL_TEXT_FORMATTER.truncate_text(runtime.get("backendDir", "-"), 64)}', + f'Python 可执行文件: {SHELL_TEXT_FORMATTER.truncate_text(runtime.get("pythonExecutable", "-"), 64)}', + ], + ) + + @staticmethod + def build_config_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建应用基础配置分区。 + + :param payload: `app config` JSON 负载 + :return: 分区快照 + """ + config = payload.get('config') if isinstance(payload, dict) else None + if not isinstance(config, dict): + return DetailSectionSnapshot( + title='应用配置', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='应用配置', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='应用配置', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 应用入口', + f'应用名称: {SHELL_TEXT_FORMATTER.truncate_text(config.get("name", "-"), 40)}', + f'监听地址: {config.get("host", "-")}:{config.get("port", "-")}', + f'根路径: {config.get("rootPath", "-") or "/"}', + f'工作进程: {config.get("workers", "-")}', + f'热重载: {"开启" if config.get("reload", False) else "关闭"}', + '', + '## 文档与日志', + f'Swagger: {"关闭" if config.get("disableSwagger", False) else "开启"}', + f'ReDoc: {"关闭" if config.get("disableRedoc", False) else "开启"}', + f'日志级别: {config.get("logLevel", "-")}', + ], + ) + + @staticmethod + def build_dependency_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建应用依赖配置分区。 + + :param payload: `app config` JSON 负载 + :return: 分区快照 + """ + config = payload.get('config') if isinstance(payload, dict) else None + if not isinstance(config, dict): + return DetailSectionSnapshot( + title='依赖配置', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='依赖配置', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='依赖配置', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 数据库', + f'类型: {config.get("dbType", "-")}', + f'地址: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}', + f'数据库名: {config.get("dbDatabase", "-")}', + '', + '## Redis 与加密', + f'Redis 地址: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}', + f'传输加密: {"开启" if config.get("transportCryptoEnabled", False) else "关闭"}', + f'加密模式: {config.get("transportCryptoMode", "-")}', + ], + ) + + def build_routes_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建应用路由摘要分区。 + + :param payload: `app routes` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='路由摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='路由', empty_value='不可用'), + ) + + count = payload.get('count', 0) + routes = payload.get('routes') if isinstance(payload.get('routes'), list) else [] + grouped_routes = payload.get('groupedRoutes') if isinstance(payload.get('groupedRoutes'), dict) else {} + group_items = sorted( + ((str(tag), len(items) if isinstance(items, list) else 0) for tag, items in grouped_routes.items()), + key=lambda item: (-item[1], item[0]), + ) + + lines = [ + '## 路由规模', + f'总路由数: {count}', + f'标签分组数: {len(group_items)}', + '', + '## 主要标签', + ] + if group_items: + lines.extend(f'> {tag} · {size} 条' for tag, size in group_items[:6]) + else: + lines.append('> 暂无标签分组数据') + + if routes: + lines.extend(['', '## 路由样本']) + for route in routes[:6]: + if not isinstance(route, dict): + continue + methods = '/'.join(route.get('methods', [])) if isinstance(route.get('methods'), list) else '-' + lines.extend( + [ + f'> [{methods or "-"}] {SHELL_TEXT_FORMATTER.truncate_text(route.get("path", "-"), 48)}', + f'> {SHELL_TEXT_FORMATTER.truncate_text(route.get("summary", "-") or "-", 48)}', + ] + ) + else: + lines.extend( + [ + '', + *TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='路由样本', + empty_value='0 条', + detail='当前环境未返回可展示的路由样本', + suggestion=TUI_COPY.build_refresh_page_suggestion('应用', '确认当前环境是否已完成路由注册'), + ), + ] + ) + + return DetailSectionSnapshot( + title='路由摘要', + status='ok', + lines=lines, + ) + + @staticmethod + def build_completion_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建 shell completion 诊断分区。 + + :param payload: `completion doctor` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='补全诊断', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='补全诊断', empty_value='不可用' + ), + ) + + shells = payload.get('shells') if isinstance(payload.get('shells'), dict) else {} + active_shell = str(payload.get('activeShell') or '-') + env_choices = payload.get('envChoices') if isinstance(payload.get('envChoices'), list) else [] + active_shell_payload = shells.get(active_shell) if isinstance(shells.get(active_shell), dict) else None + target_file = active_shell_payload.get('targetFile', '-') if isinstance(active_shell_payload, dict) else '-' + source_command = ( + active_shell_payload.get('sourceCommand', '-') if isinstance(active_shell_payload, dict) else '-' + ) + install_command = ( + active_shell_payload.get('recommendedInstallCommand', '-') + if isinstance(active_shell_payload, dict) + else payload.get('recommendedInstallCommand', '-') + ) + + lines = [ + '## 当前环境', + f'活动 Shell: {active_shell}', + f'项目目录: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("projectDir", "-"), 64)}', + f'补全环境变量: {payload.get("completeEnvVar", "-")}', + f'环境候选数: {len(env_choices)}', + '', + '## 激活建议', + f'目标脚本: {SHELL_TEXT_FORMATTER.truncate_text(target_file, 64)}', + f'加载命令: {SHELL_TEXT_FORMATTER.truncate_text(source_command, 72)}', + f'推荐安装命令: {SHELL_TEXT_FORMATTER.truncate_text(install_command, 72)}', + ] + + shell_lines = [] + for shell_name in ('bash', 'zsh', 'fish', 'powershell'): + shell_payload = shells.get(shell_name) + if not isinstance(shell_payload, dict): + continue + supported = '支持' if shell_payload.get('supported', False) else '不支持' + detected = '当前 Shell' if shell_payload.get('detected', False) else '候选 Shell' + shell_lines.append(f'> {shell_name} · {supported} · {detected}') + if shell_lines: + lines.extend(['', '## Shell 支持']) + lines.extend(shell_lines) + + return DetailSectionSnapshot( + title='补全诊断', + status='ok', + lines=lines, + ) + + def build_completion_preview_section( + self, + shell: str, + preview_result: Any, + ) -> DetailSectionSnapshot: + """ + 构建补全脚本预览分区。 + + :param shell: 当前预览 shell + :param preview_result: `completion show` 调用结果 + :return: 分区快照 + """ + stdout = str(getattr(preview_result, 'stdout', '') or '').strip() + stderr = str(getattr(preview_result, 'stderr', '') or '').strip() + returncode = int(getattr(preview_result, 'returncode', 1) or 0) + if returncode != 0: + return DetailSectionSnapshot( + title='补全脚本预览', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + {'ok': False, 'message': stderr or stdout or '补全脚本生成失败'}, + empty_label='补全脚本', + empty_value='不可用', + ), + ) + preview_lines = [ + SHELL_TEXT_FORMATTER.truncate_text(line, 88) for line in stdout.splitlines()[:COMPLETION_PREVIEW_LINE_LIMIT] + ] + if not preview_lines: + return DetailSectionSnapshot( + title='补全脚本预览', + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='补全脚本', + empty_value='0 行', + detail='当前 shell 的补全脚本未返回可预览内容', + suggestion=TUI_COPY.build_refresh_page_suggestion('应用', '重新生成补全脚本预览'), + ), + ) + return DetailSectionSnapshot( + title='补全脚本预览', + status='ok', + lines=[ + '## 预览目标', + f'Shell: {shell}', + f'预览行数: {len(preview_lines)}', + '', + '## 脚本片段', + *preview_lines, + ], + ) + + @staticmethod + def build_doctor_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建应用启动前检查分区。 + + :param payload: `app doctor` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict): + return DetailSectionSnapshot( + title='启动前检查', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='启动前检查', empty_value='不可用' + ), + ) + + database = payload.get('database') if isinstance(payload.get('database'), dict) else {} + redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {} + crypto = payload.get('crypto') if isinstance(payload.get('crypto'), dict) else {} + return DetailSectionSnapshot( + title='启动前检查', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 检查结果', + f'环境: {payload.get("env", "-")}', + f'数据库: {"正常" if database.get("ok", False) else "异常"}', + f'> {SHELL_TEXT_FORMATTER.truncate_text(database.get("message", "-"), 64)}', + f'Redis: {"正常" if redis.get("ok", False) else "异常"}', + f'> {SHELL_TEXT_FORMATTER.truncate_text(redis.get("message", "-"), 64)}', + f'加密组件: {"正常" if crypto.get("ok", False) else "异常"}', + f'> {SHELL_TEXT_FORMATTER.truncate_text(crypto.get("message", "-"), 64)}', + ], + ) + + @staticmethod + def build_run_entry_section() -> DetailSectionSnapshot: + """ + 构建应用启动向导入口分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='启动入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备启动当前环境应用时,可直接启动应用,或先通过向导确认目标环境与启动前检查选项。', + command=TUI_COPY.build_cli_command_hint('app', 'run', '--env=dev'), + guide='若需要先做启动前检查或切换环境,建议改用 `wizard app-run` 进入交互式启动流程。', + ), + ) + + @staticmethod + def build_completion_install_entry_section() -> DetailSectionSnapshot: + """ + 构建 shell 补全安装入口分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='补全安装入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备为当前 Shell 安装或修复补全脚本时,可直接执行补全安装命令,并按需把激活命令写入 rc 文件。', + command=TUI_COPY.build_cli_command_hint('completion', 'install', '--activate', '--output=text'), + guide='当前页面提供补全诊断与安装入口;执行安装后可返回补全诊断分区确认 target file 和 source command 是否更新。', + ), + ) + + @staticmethod + def build_overview_section( + env_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + routes_payload: dict[str, Any] | None, + completion_payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建应用页总览判断分区。 + + :param env_payload: `app env` JSON 负载 + :param config_payload: `app config` JSON 负载 + :param doctor_payload: `app doctor` JSON 负载 + :param routes_payload: `app routes` JSON 负载 + :param completion_payload: `completion doctor` JSON 负载 + :return: 分区快照 + """ + env_ok = bool(isinstance(env_payload, dict) and env_payload.get('ok', False)) + config_ok = bool(isinstance(config_payload, dict) and config_payload.get('ok', False)) + doctor_ok = bool(isinstance(doctor_payload, dict) and doctor_payload.get('ok', False)) + routes_ok = bool(isinstance(routes_payload, dict) and routes_payload.get('ok', False)) + completion_ok = bool(isinstance(completion_payload, dict) and completion_payload.get('ok', False)) + route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0 + runtime = ( + env_payload.get('runtime') + if isinstance(env_payload, dict) and isinstance(env_payload.get('runtime'), dict) + else {} + ) + config = ( + config_payload.get('config') + if isinstance(config_payload, dict) and isinstance(config_payload.get('config'), dict) + else {} + ) + + status = 'ok' + conclusion = '应用基线正常,可继续查看环境映射、配置摘要与路由状态' + if not env_ok or not config_ok: + status = 'fail' + conclusion = '应用基础信息读取异常,优先确认环境映射与应用配置是否可用' + elif not doctor_ok: + status = 'fail' + conclusion = '启动前检查异常,优先确认数据库、Redis 和加密组件状态' + elif not completion_ok: + status = 'warn' + conclusion = '补全诊断读取异常,建议优先确认项目目录、Shell 类型和补全脚本状态' + elif not routes_ok: + status = 'warn' + conclusion = '路由摘要读取异常,建议先确认当前环境是否完成应用初始化' + elif int(route_count or 0) <= 0: + status = 'info' + conclusion = '当前未返回注册路由,建议检查路由注册流程与运行上下文' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'CLI 环境: {runtime.get("cliEnv", "-")}', + f'配置环境: {runtime.get("configEnv", "-")}', + f'监听地址: {config.get("host", "-")}:{config.get("port", "-")}', + f'注册路由: {route_count} 条', + f'启动前检查: {"正常" if doctor_ok else "异常"}', + f'传输加密: {"开启" if config.get("transportCryptoEnabled", False) else "关闭"}', + f'补全诊断: {"正常" if completion_ok else "异常"}', + '', + '## 建议入口', + '优先关注:环境解析 / 应用配置 / 启动前检查 / 补全诊断 / 路由摘要 / 启动入口', + ], + ) + + def build_sections( + self, + *, + env_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + routes_payload: dict[str, Any] | None, + completion_payload: dict[str, Any] | None, + completion_preview_result: Any, + preview_shell: str, + ) -> list[DetailSectionSnapshot]: + """ + 构建应用页全部详情分区。 + + :param env_payload: `app env` 结果负载 + :param config_payload: `app config` 结果负载 + :param doctor_payload: `app doctor` 结果负载 + :param routes_payload: `app routes` 结果负载 + :param completion_payload: `completion doctor` 结果负载 + :param completion_preview_result: `completion show` 命令结果 + :param preview_shell: 当前预览 shell + :return: 详情分区列表 + """ + return [ + self.build_overview_section( + env_payload, + config_payload, + doctor_payload, + routes_payload, + completion_payload, + ), + self.build_env_section(env_payload), + self.build_config_section(config_payload), + self.build_dependency_section(config_payload), + self.build_doctor_section(doctor_payload), + self.build_completion_section(completion_payload), + self.build_completion_preview_section(preview_shell, completion_preview_result), + self.build_completion_install_entry_section(), + self.build_routes_section(routes_payload), + self.build_run_entry_section(), + ] + + +class AppDetailAdapter(BaseDetailAdapter): + """ + 应用详情页适配器。 + + 该适配器负责采集应用相关 CLI 结果,并委托分区构建器组装为 + TUI 详情页快照。 + """ + + def __init__( + self, + section_builder: AppSectionBuilder | None = None, + snapshot_collector: AppDetailSnapshotCollector | None = None, + ) -> None: + """ + 初始化应用详情页适配器。 + + :param section_builder: 应用详情分区构建器 + :param snapshot_collector: 应用详情页数据采集器 + :return: None + """ + super().__init__( + page_title='应用', + search_view_key='app', + default_suggestions=[ + '总览判断', + '环境解析', + '应用配置', + '依赖配置', + '启动前检查', + '补全诊断', + '补全脚本预览', + '补全安装入口', + '路由摘要', + '启动入口', + ], + ) + self.section_builder = section_builder or AppSectionBuilder() + self.snapshot_collector = snapshot_collector or AppDetailSnapshotCollector(self.section_builder) + + def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot: + """ + 采集应用状态页只读快照。 + + :param env: 当前运行环境 + :param query: 当前搜索词 + :return: 页面快照 + """ + source_payloads = self.snapshot_collector.collect(env) + sections = self.section_builder.build_sections( + env_payload=source_payloads.env_payload, + config_payload=source_payloads.config_payload, + doctor_payload=source_payloads.doctor_payload, + routes_payload=source_payloads.routes_payload, + completion_payload=source_payloads.completion_payload, + completion_preview_result=source_payloads.completion_preview_result, + preview_shell=source_payloads.preview_shell, + ) + return DetailPageSnapshot( + title='应用', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_app_diagnostic_subtitle( + source_payloads.env_payload, + source_payloads.config_payload, + source_payloads.doctor_payload, + source_payloads.routes_payload, + source_payloads.completion_payload, + ), + sections=self.filter_sections(sections, query), + search=self.resolve_search_context(query), + ) + + +APP_DETAIL_ADAPTER = AppDetailAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/base.py b/shierfeng-fastapi-backend/cli/tui/adapters/base.py new file mode 100644 index 0000000..5e3308d --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/base.py @@ -0,0 +1,219 @@ +from collections.abc import Sequence +from typing import Any + +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + BrowserRecordSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.search import TUI_SEARCH_SERVICE, PageFilterOption, PageSearchContext +from cli.utils import SHELL_TEXT_FORMATTER + + +class BaseBrowserAdapter: + """ + TUI 浏览页适配基类。 + + :param page_title: 浏览页标题 + :param search_view_key: 搜索上下文对应的页面键 + :param filter_options: 页面支持的筛选项 + """ + + def __init__( + self, + *, + page_title: str, + search_view_key: str, + filter_options: Sequence[PageFilterOption], + ) -> None: + """ + 初始化浏览页适配基类。 + + :param page_title: 浏览页标题 + :param search_view_key: 搜索上下文对应的页面键 + :param filter_options: 页面支持的筛选项 + :return: None + """ + self.page_title = page_title + self.search_view_key = search_view_key + self.filter_options = tuple(filter_options) + + @staticmethod + def extract_page_rows(payload: dict[str, Any] | None) -> list[dict[str, Any]]: + """ + 从标准分页 JSON 负载中提取行数据。 + + :param payload: 标准分页结果负载 + :return: 行数据列表 + """ + page_payload = payload.get('page') if isinstance(payload, dict) else None + rows = page_payload.get('rows') if isinstance(page_payload, dict) else None + if not isinstance(rows, list): + return [] + return [row for row in rows if isinstance(row, dict)] + + def resolve_active_filter(self, filter_key: str) -> PageFilterOption: + """ + 解析当前激活筛选项。 + + :param filter_key: 筛选键 + :return: 已解析的筛选项 + """ + return TUI_SEARCH_SERVICE.resolve_filter_option(self.filter_options, filter_key) or self.filter_options[0] + + def resolve_search_context(self, query: str) -> PageSearchContext: + """ + 解析当前页面搜索上下文。 + + :param query: 搜索词 + :return: 搜索上下文 + """ + return TUI_SEARCH_SERVICE.resolve_search_context(self.search_view_key, query) + + def build_failure_record( + self, + *, + key: str, + subject: str, + section_subject: str, + payload: dict[str, Any] | None, + ) -> BrowserRecordSnapshot: + """ + 构建底层命令失败时的兜底记录。 + + :param key: 记录键 + :param subject: 页面主体名称 + :param section_subject: 失败分区名称 + :param payload: 失败结果负载 + :return: 浏览记录快照 + """ + return BrowserRecordSnapshot( + key=key, + title=TUI_COPY.build_unavailable_record_title(subject), + status='fail', + summary=SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(payload), 64), + metadata_lines=[], + detail_sections=[ + DetailSectionSnapshot( + title=TUI_COPY.build_load_failure_section_title(section_subject), + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label=section_subject, empty_value='不可用' + ), + ) + ], + ) + + def build_empty_record( + self, + *, + key: str, + subject: str, + empty_label: str, + has_source_rows: bool, + filtered_summary: str, + empty_summary: str, + filtered_empty_value: str, + empty_empty_value: str, + filtered_detail: str, + empty_detail: str, + ) -> BrowserRecordSnapshot: + """ + 构建当前页面没有可浏览记录时的空态记录。 + + :param key: 记录键 + :param subject: 记录主体名称 + :param empty_label: 空态主字段名称 + :param has_source_rows: 是否存在未筛选前的源数据 + :param filtered_summary: 有源数据但当前筛选无结果时的摘要 + :param empty_summary: 源数据本身为空时的摘要 + :param filtered_empty_value: 有源数据但当前筛选无结果时的空态值 + :param empty_empty_value: 源数据本身为空时的空态值 + :param filtered_detail: 有源数据但当前筛选无结果时的说明 + :param empty_detail: 源数据本身为空时的说明 + :return: 浏览记录快照 + """ + summary = filtered_summary if has_source_rows else empty_summary + empty_value = filtered_empty_value if has_source_rows else empty_empty_value + detail = filtered_detail if has_source_rows else empty_detail + return BrowserRecordSnapshot( + key=key, + title=TUI_COPY.build_empty_record_title(subject), + status='info', + summary=TUI_COPY.build_empty_record_summary(summary), + metadata_lines=[], + detail_sections=[ + DetailSectionSnapshot( + title=TUI_COPY.build_empty_record_title(subject), + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label=empty_label, + empty_value=empty_value, + detail=detail, + ), + ) + ], + ) + + @staticmethod + def extract_payload_message(payload: dict[str, object] | None) -> str: + """ + 提取结果负载中的可读消息。 + + :param payload: 标准结果负载 + :return: 摘要消息文本 + """ + return TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload) + + +class BaseDetailAdapter: + """ + TUI 详情页适配基类。 + + :param page_title: 详情页标题 + :param search_view_key: 搜索上下文对应的页面键 + :param default_suggestions: 默认搜索建议 + """ + + def __init__( + self, + *, + page_title: str, + search_view_key: str, + default_suggestions: Sequence[str], + ) -> None: + """ + 初始化详情页适配基类。 + + :param page_title: 详情页标题 + :param search_view_key: 搜索上下文对应的页面键 + :param default_suggestions: 默认搜索建议 + :return: None + """ + self.page_title = page_title + self.search_view_key = search_view_key + self.default_suggestions = tuple(default_suggestions) + + def filter_sections(self, sections: Sequence[DetailSectionSnapshot], query: str) -> list[DetailSectionSnapshot]: + """ + 按搜索词过滤详情分区列表。 + + :param sections: 原始详情分区列表 + :param query: 搜索词 + :return: 过滤后的分区列表 + """ + return TUI_SEARCH_SERVICE.filter_detail_sections(list(sections), query) # type: ignore[arg-type] + + def resolve_search_context(self, query: str) -> PageSearchContext: + """ + 解析当前详情页搜索上下文。 + + :param query: 搜索词 + :return: 搜索上下文 + """ + return TUI_SEARCH_SERVICE.resolve_search_context( + self.search_view_key, + query, + default_suggestions=list(self.default_suggestions), + ) diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/cache.py b/shierfeng-fastapi-backend/cli/tui/adapters/cache.py new file mode 100644 index 0000000..a3596be --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/cache.py @@ -0,0 +1,579 @@ +from typing import Any + +from cli.tui.adapters.base import BaseBrowserAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + BrowserPageSnapshot, + BrowserRecordSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +class CacheRowExtractor: + """ + 缓存浏览行提取器。 + + 该对象负责从缓存统计结果中提取缓存名称列表,供共享分区和记录构建 + 逻辑复用。 + """ + + @staticmethod + def extract_cache_name_rows(payload: dict[str, Any] | None) -> list[dict[str, Any]]: + """ + 从缓存统计结果中提取缓存名称列表。 + + :param payload: `cache stats` JSON 负载 + :return: 缓存名称行列表 + """ + if not isinstance(payload, dict): + return [] + cache_names = payload.get('cacheNames') + if not isinstance(cache_names, list): + return [] + rows: list[dict[str, Any]] = [] + for item in cache_names: + if isinstance(item, dict): + rows.append(item) + continue + if isinstance(item, str): + rows.append({'cacheName': item, 'remark': ''}) + return rows + + +class CacheSectionBuilder: + """ + 缓存浏览页分区构建器。 + + 该构建器负责构建缓存浏览页共享分区,以及单个缓存名前缀下的键摘要、 + 键列表和键详情分区。 + + :param page_adapter: 缓存浏览页适配器 + :param row_extractor: 缓存浏览行提取器 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + row_extractor: CacheRowExtractor | None = None, + ) -> None: + """ + 初始化缓存浏览页分区构建器。 + + :param page_adapter: 缓存浏览页适配器 + :param row_extractor: 缓存浏览行提取器 + :return: None + """ + self.page_adapter = page_adapter + self.row_extractor = row_extractor or CacheRowExtractor() + + def build_overview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建缓存总览共享分区。 + + :param payload: `cache stats` JSON 负载 + :return: 分区快照 + """ + info = payload.get('info') if isinstance(payload, dict) and isinstance(payload.get('info'), dict) else {} + cache_rows = self.row_extractor.extract_cache_name_rows(payload) + return DetailSectionSnapshot( + title='Redis 摘要', + status='ok' if isinstance(payload, dict) and payload.get('ok', False) else 'fail', + lines=[ + '## 容量状态', + f'当前键数: {payload.get("dbSize", "-") if isinstance(payload, dict) else "-"}', + f'已登记缓存名: {len(cache_rows)} 个', + '', + '## 运行指标', + f'Redis 版本: {info.get("redis_version", "-")}', + f'客户端连接数: {info.get("connected_clients", "-")}', + f'内存占用: {info.get("used_memory_human", info.get("used_memory", "-"))}', + ], + ) + + def build_overview_judgement_section( + self, + payload: dict[str, Any] | None, + filtered_rows: list[dict[str, Any]], + ) -> DetailSectionSnapshot: + """ + 构建缓存页总览判断共享分区。 + + :param payload: `cache stats` JSON 负载 + :param filtered_rows: 当前筛选后的缓存名列表 + :return: 分区快照 + """ + info = payload.get('info') if isinstance(payload, dict) and isinstance(payload.get('info'), dict) else {} + cache_rows = self.row_extractor.extract_cache_name_rows(payload) + db_size = payload.get('dbSize', '-') if isinstance(payload, dict) else '-' + client_count = info.get('connected_clients', '-') + command_stats = payload.get('commandStats') if isinstance(payload, dict) else None + command_count = len(command_stats) if isinstance(command_stats, list) else 0 + + status = 'ok' + conclusion = '缓存基线正常,可继续查看键列表、键值样本与 TTL' + if not isinstance(payload, dict) or not payload.get('ok', False): + status = 'fail' + conclusion = '缓存状态读取失败,优先确认 Redis 连通性与运行环境' + elif not cache_rows: + status = 'info' + conclusion = '当前没有登记的缓存名前缀,可先核对缓存配置与实际键空间' + elif not filtered_rows: + status = 'info' + conclusion = '当前搜索条件没有命中缓存名前缀,可调整关键字后继续排查' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'已登记缓存名: {len(cache_rows)} 个', + f'当前匹配: {len(filtered_rows)} 个', + f'Redis 键数: {db_size}', + f'客户端连接数: {client_count}', + f'命令统计样本: {command_count} 组', + '', + '## 建议入口', + '优先关注:Redis 摘要 / 命令统计 / 键值样本 / TTL', + ], + ) + + def build_top_commands_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建缓存命令统计共享分区。 + + :param payload: `cache stats` JSON 负载 + :return: 分区快照 + """ + command_stats = payload.get('commandStats') if isinstance(payload, dict) else None + lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='命令统计', + empty_value='暂无数据', + detail='当前 Redis 运行快照中未返回命令统计样本', + ) + section_status = 'info' + if isinstance(command_stats, list) and command_stats: + section_status = 'ok' + lines = [ + f'{item.get("name", "-")} · {item.get("value", 0)} 次' + for item in command_stats[:8] + if isinstance(item, dict) + ] + return DetailSectionSnapshot( + title='命令统计', + status=section_status if isinstance(payload, dict) and payload.get('ok', False) else 'fail', + lines=lines, + ) + + @staticmethod + def build_cache_clear_entry_section() -> DetailSectionSnapshot: + """ + 构建缓存清理向导入口分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='缓存清理入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='当缓存命中异常、键数量失控,或需要按缓存名和前缀清理时,应先通过向导确认影响范围。', + command=TUI_COPY.build_cli_command_hint('wizard', 'cache-clear', '--output=text'), + guide='向导会继续询问环境、缓存名、键前缀和 dry-run 选项,确认后再执行实际清理。', + ), + ) + + def build_keys_summary_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建缓存键摘要分区。 + + :param payload: `cache keys` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='键摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='键摘要', empty_value='不可用' + ), + ) + keys = payload.get('keys') if isinstance(payload.get('keys'), list) else [] + rendered_keys = [str(item).strip() for item in keys if str(item).strip()] + lines = [ + '## 统计', + f'缓存名: {payload.get("cacheName", "-")}', + f'键数量: {payload.get("count", len(rendered_keys))}', + ] + if rendered_keys: + lines.extend( + ['', '## 键样本', *[f'> {SHELL_TEXT_FORMATTER.truncate_text(item, 64)}' for item in rendered_keys[:5]]] + ) + else: + lines.extend(['', '## 键样本', '> 当前缓存名前缀下没有键']) + return DetailSectionSnapshot( + title='键摘要', + status='ok', + lines=lines, + ) + + def build_keys_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建缓存键列表分区。 + + :param payload: `cache keys` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='键列表', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='键列表', empty_value='不可用' + ), + ) + keys = payload.get('keys') if isinstance(payload.get('keys'), list) else [] + lines = [ + SHELL_TEXT_FORMATTER.truncate_text(item, 88) for item in keys[:12] if str(item).strip() + ] or TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='缓存键', + empty_value='0 个', + detail='当前缓存名前缀下没有可展示的缓存键', + ) + return DetailSectionSnapshot( + title='键列表', + status='ok', + lines=lines, + ) + + def render_ttl_text(self, payload: dict[str, Any] | None) -> str: + """ + 渲染缓存 TTL 文本。 + + :param payload: `cache ttl` JSON 负载 + :return: TTL 文本 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(payload), 56) + ttl_seconds = payload.get('ttlSeconds', '-') + if payload.get('persistent', False): + return '永久' + if payload.get('expires', False): + return f'{ttl_seconds} 秒' + return str(ttl_seconds) + + def build_key_detail_sections( + self, + cache_name: str, + key_items: list[str], + env: str, + ) -> list[DetailSectionSnapshot]: + """ + 构建按缓存键展开的详情分区。 + + :param cache_name: 缓存名称 + :param key_items: 缓存键列表 + :param env: 当前运行环境 + :return: 分区快照列表 + """ + if not key_items: + return [] + + sections: list[DetailSectionSnapshot] = [] + for cache_key in key_items[:5]: + value_payload = NESTED_CLI_SUPPORT.run( + 'cache', + 'get', + cache_name, + cache_key, + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + ttl_payload = NESTED_CLI_SUPPORT.run( + 'cache', + 'ttl', + cache_name, + cache_key, + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + + value_ok = isinstance(value_payload, dict) and value_payload.get('ok', False) + ttl_ok = isinstance(ttl_payload, dict) and ttl_payload.get('ok', False) + if value_ok and ttl_ok: + section_status = 'ok' + elif value_ok or ttl_ok: + section_status = 'warn' + else: + section_status = 'fail' + + cache_value = ( + '' if not value_ok or value_payload.get('cacheValue') is None else str(value_payload.get('cacheValue')) + ) + preview_lines = cache_value.splitlines()[:6] + ttl_text = self.render_ttl_text(ttl_payload) + lines = [ + '## 键信息', + f'缓存名: {cache_name}', + f'缓存键: {SHELL_TEXT_FORMATTER.truncate_text(cache_key, 72)}', + f'TTL: {ttl_text}', + '', + '## 值预览', + ] + if value_ok: + lines.extend( + [f'> {SHELL_TEXT_FORMATTER.truncate_text(line, 72)}' for line in preview_lines] + if preview_lines + else ['> -'] + ) + else: + lines.append( + f'> {SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(value_payload), 72)}' + ) + if not ttl_ok: + lines.extend( + [ + '', + '## TTL 结果', + f'> {SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(ttl_payload), 72)}', + ] + ) + sections.append( + DetailSectionSnapshot( + title=f'键详情 · {SHELL_TEXT_FORMATTER.truncate_text(cache_key, 28)}', + status=section_status, + lines=lines, + ) + ) + return sections + + def load_cache_detail_sections( + self, + cache_row: dict[str, Any], + env: str, + ) -> list[DetailSectionSnapshot]: + """ + 按需加载单个缓存名称的详情分区。 + + :param cache_row: 缓存名称行数据 + :param env: 当前运行环境 + :return: 详情分区列表 + """ + cache_name = str(cache_row.get('cacheName', '-') or '-') + keys_payload = NESTED_CLI_SUPPORT.run( + 'cache', + 'keys', + cache_name, + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + key_items: list[str] = [] + if isinstance(keys_payload, dict) and keys_payload.get('ok', False): + raw_keys = keys_payload.get('keys') if isinstance(keys_payload.get('keys'), list) else [] + key_items = [str(item).strip() for item in raw_keys if str(item).strip()] + return [ + self.build_keys_summary_section(keys_payload), + self.build_keys_section(keys_payload), + *self.build_key_detail_sections(cache_name, key_items, env), + ] + + def build_shared_sections( + self, + payload: dict[str, Any] | None, + filtered_rows: list[dict[str, Any]], + ) -> list[DetailSectionSnapshot]: + """ + 构建缓存浏览页共享分区。 + + :param payload: `cache stats` 结果负载 + :param filtered_rows: 当前筛选后的缓存名列表 + :return: 共享分区列表 + """ + return [ + self.build_overview_judgement_section(payload, filtered_rows), + self.build_overview_section(payload), + self.build_top_commands_section(payload), + self.build_cache_clear_entry_section(), + ] + + +class CacheRecordBuilder: + """ + 缓存浏览记录构建器。 + + 该对象负责构建缓存页单条浏览记录与失败兜底记录。 + + :param page_adapter: 缓存浏览页适配器 + :param section_builder: 缓存浏览页分区构建器 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + section_builder: CacheSectionBuilder, + ) -> None: + """ + 初始化缓存浏览记录构建器。 + + :param page_adapter: 缓存浏览页适配器 + :param section_builder: 缓存浏览页分区构建器 + :return: None + """ + self.page_adapter = page_adapter + self.section_builder = section_builder + + def build_record(self, cache_row: dict[str, Any], env: str) -> BrowserRecordSnapshot: + """ + 构建单条缓存浏览记录。 + + :param cache_row: 缓存名称行数据 + :param env: 当前运行环境 + :return: 浏览记录快照 + """ + cache_name = str(cache_row.get('cacheName', '-') or '-') + remark = str(cache_row.get('remark', '') or '').strip() + return BrowserRecordSnapshot( + key=f'cache:{cache_name}', + title=SHELL_TEXT_FORMATTER.truncate_text(cache_name, 40), + status='ok', + summary=SHELL_TEXT_FORMATTER.truncate_text(remark or '查看当前缓存名前缀下的键、值样本和过期时间', 64), + metadata_lines=[ + '## 缓存身份', + f'缓存名称: {cache_name}', + f'用途说明: {SHELL_TEXT_FORMATTER.truncate_text(remark or "-", 64)}', + ], + detail_sections=[], + detail_loader=lambda cache_row=cache_row, env=env: self.section_builder.load_cache_detail_sections( + cache_row, env + ), + ) + + def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot: + """ + 构建缓存页失败兜底记录。 + + :param payload: 失败结果负载 + :return: 浏览记录快照 + """ + return self.page_adapter.build_failure_record( + key='cache:unavailable', + subject='缓存', + section_subject='缓存状态', + payload=payload, + ) + + +class CacheBrowserAdapter(BaseBrowserAdapter): + """ + 缓存浏览页适配器。 + + 该适配器负责采集 Redis 统计和缓存名前缀列表,并委托协作对象构建 + 共享分区、单条记录与键详情分区。 + """ + + def __init__( + self, + row_extractor: CacheRowExtractor | None = None, + section_builder: CacheSectionBuilder | None = None, + record_builder: CacheRecordBuilder | None = None, + ) -> None: + """ + 初始化缓存浏览页适配器。 + + :param row_extractor: 缓存浏览行提取器 + :param section_builder: 缓存浏览页分区构建器 + :param record_builder: 缓存浏览记录构建器 + :return: None + """ + super().__init__( + page_title='缓存', + search_view_key='cache', + filter_options=(), + ) + self.row_extractor = row_extractor or CacheRowExtractor() + self.section_builder = section_builder or CacheSectionBuilder(self, self.row_extractor) + self.record_builder = record_builder or CacheRecordBuilder(self, self.section_builder) + + @staticmethod + def apply_cache_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]: + """ + 按缓存名或备注查询词过滤缓存行数据。 + + :param rows: 原始缓存行列表 + :param query: 当前搜索词 + :return: 过滤后的缓存行列表 + """ + normalized_query = str(query).strip().lower() + if not normalized_query: + return rows + return [ + row + for row in rows + if normalized_query in str(row.get('cacheName', '') or '').strip().lower() + or normalized_query in str(row.get('remark', '') or '').strip().lower() + ] + + def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot: + """ + 采集缓存浏览页只读快照。 + + :param env: 当前运行环境 + :param query: 当前搜索词 + :return: 浏览页快照 + """ + search_context = self.resolve_search_context(query) + payload = NESTED_CLI_SUPPORT.run( + 'cache', + 'stats', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + cache_rows = self.row_extractor.extract_cache_name_rows(payload) + filtered_rows = self.apply_cache_query(cache_rows, query) + shared_sections = self.section_builder.build_shared_sections(payload, filtered_rows) + if not isinstance(payload, dict) or not payload.get('ok', False): + return BrowserPageSnapshot( + title='缓存', + subtitle=TUI_COPY.build_unavailable_subtitle( + '缓存状态', + SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(payload), 72), + ), + records=[self.record_builder.build_failure_record(payload)], + shared_sections=shared_sections, + search=search_context, + ) + + records = [self.record_builder.build_record(cache_row, env) for cache_row in filtered_rows[:12]] + if not records: + records = [ + self.build_empty_record( + key='cache:none', + subject='缓存名', + empty_label='缓存名前缀', + has_source_rows=bool(cache_rows), + filtered_summary='当前搜索条件下没有匹配缓存名前缀', + empty_summary='当前运行环境没有登记可浏览的缓存名前缀', + filtered_empty_value='暂无匹配', + empty_empty_value='暂无登记', + filtered_detail='当前搜索条件下没有匹配缓存名前缀', + empty_detail='当前运行环境没有登记可浏览的缓存名前缀', + ) + ] + return BrowserPageSnapshot( + title='缓存', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_cache_diagnostic_subtitle(payload, len(filtered_rows)), + records=records, + shared_sections=shared_sections, + search=search_context, + ) + + +CACHE_BROWSER_ADAPTER = CacheBrowserAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/configs.py b/shierfeng-fastapi-backend/cli/tui/adapters/configs.py new file mode 100644 index 0000000..63710da --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/configs.py @@ -0,0 +1,760 @@ +from typing import Any + +from cli.tui.adapters.base import BaseBrowserAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + BrowserPageSnapshot, + BrowserRecordSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.tui.search import CONFIG_FILTER_OPTIONS +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + +CONFIG_RISK_PRIORITY = { + 'mismatch': 0, + 'missing-cache': 1, + 'orphan-cache': 2, + 'normal': 3, +} + +CONFIG_RISK_LABELS = { + 'mismatch': '值不一致', + 'missing-cache': '缓存缺失', + 'orphan-cache': '缓存孤立', + 'normal': '正常', +} + +CONFIG_RISK_STATUSES = { + 'mismatch': 'fail', + 'missing-cache': 'warn', + 'orphan-cache': 'warn', + 'normal': 'ok', +} + + +class ConfigRiskSupport: + """ + 参数配置风险分类支持对象。 + + 该对象负责从巡检结果提取风险集合、解析单项风险分类,以及执行 + 风险排序和筛选逻辑,供浏览页分区和记录构建复用。 + """ + + @staticmethod + def extract_config_issue_keys(payload: dict[str, Any] | None, field_name: str) -> set[str]: + """ + 从配置巡检结果中提取指定风险字段对应的配置键集合。 + + :param payload: `config doctor` JSON 负载 + :param field_name: 风险字段名 + :return: 配置键集合 + """ + items = payload.get(field_name) if isinstance(payload, dict) else None + if not isinstance(items, list): + return set() + return {item.strip() for item in items if isinstance(item, str) and item.strip()} + + def build_config_risk_sets(self, payload: dict[str, Any] | None) -> dict[str, set[str]]: + """ + 构建配置风险分类集合。 + + :param payload: `config doctor` JSON 负载 + :return: 按风险分类聚合的配置键集合 + """ + return { + 'mismatch': self.extract_config_issue_keys(payload, 'mismatchKeys'), + 'missing-cache': self.extract_config_issue_keys(payload, 'missingInCache'), + 'orphan-cache': self.extract_config_issue_keys(payload, 'orphanInCache'), + } + + @staticmethod + def resolve_config_risk_key(config_key: str, risk_sets: dict[str, set[str]]) -> str: + """ + 解析单个配置项的风险分类。 + + :param config_key: 配置键 + :param risk_sets: 风险集合 + :return: 风险分类键 + """ + normalized_key = str(config_key).strip() + if normalized_key in risk_sets.get('mismatch', set()): + return 'mismatch' + if normalized_key in risk_sets.get('missing-cache', set()): + return 'missing-cache' + if normalized_key in risk_sets.get('orphan-cache', set()): + return 'orphan-cache' + return 'normal' + + def build_config_sort_key( + self, + config_row: dict[str, Any], + risk_sets: dict[str, set[str]], + ) -> tuple[int, str]: + """ + 构建配置列表排序键。 + + :param config_row: 配置项行数据 + :param risk_sets: 风险集合 + :return: 排序键 + """ + config_key = str(config_row.get('configKey', '') or '').strip() + risk_key = self.resolve_config_risk_key(config_key, risk_sets) + return (CONFIG_RISK_PRIORITY.get(risk_key, 99), config_key) + + def apply_config_filter( + self, + rows: list[dict[str, Any]], + risk_sets: dict[str, set[str]], + filter_key: str, + ) -> list[dict[str, Any]]: + """ + 按筛选键过滤配置行数据。 + + :param rows: 原始配置行列表 + :param risk_sets: 风险集合 + :param filter_key: 当前筛选键 + :return: 过滤后的配置行列表 + """ + normalized_filter = str(filter_key).strip().lower() + if normalized_filter == 'risky': + return [ + row + for row in rows + if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal' + ] + if normalized_filter == 'mismatch': + return [ + row + for row in rows + if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) == 'mismatch' + ] + if normalized_filter == 'cache-drift': + return [ + row + for row in rows + if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) + in {'missing-cache', 'orphan-cache'} + ] + return rows + + @staticmethod + def apply_config_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]: + """ + 按配置键或配置名称查询词过滤配置行数据。 + + :param rows: 原始配置行列表 + :param query: 当前搜索词 + :return: 过滤后的配置行列表 + """ + normalized_query = str(query).strip().lower() + if not normalized_query: + return rows + return [ + row + for row in rows + if normalized_query in str(row.get('configKey', '') or '').strip().lower() + or normalized_query in str(row.get('configName', '') or '').strip().lower() + ] + + @staticmethod + def append_orphan_cache_rows(rows: list[dict[str, Any]], orphan_keys: set[str]) -> list[dict[str, Any]]: + """ + 将仅存在于缓存侧的孤立配置补充为可浏览记录。 + + :param rows: 原始数据库配置行 + :param orphan_keys: 缓存孤立配置键集合 + :return: 补齐后的配置行列表 + """ + merged_rows = list(rows) + existing_keys = {str(row.get('configKey', '') or '').strip() for row in rows} + merged_rows.extend( + { + 'configId': '-', + 'configKey': orphan_key, + 'configName': '缓存孤立配置', + 'configType': '-', + 'configValue': '缓存侧残留,建议排查来源', + } + for orphan_key in sorted(orphan_keys - existing_keys) + ) + return merged_rows + + +class ConfigSectionBuilder: + """ + 参数配置浏览页分区构建器。 + + 该构建器负责构建参数配置页共享分区,以及单个配置项详情分区。 + + :param page_adapter: 参数配置浏览页适配器 + :param risk_support: 参数配置风险分类支持对象 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + risk_support: ConfigRiskSupport, + ) -> None: + """ + 初始化参数配置浏览页分区构建器。 + + :param page_adapter: 参数配置浏览页适配器 + :param risk_support: 参数配置风险分类支持对象 + :return: None + """ + self.page_adapter = page_adapter + self.risk_support = risk_support + + def build_config_doctor_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建参数配置巡检共享分区。 + + :param payload: `config doctor` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='配置巡检', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='巡检结果', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='配置巡检', + status='ok', + lines=[ + '## 一致性状态', + f'数据库配置: {payload.get("databaseCount", "-")} 项', + f'缓存同步配置: {payload.get("cacheCount", "-")} 项', + f'缓存缺失: {payload.get("missingInCacheCount", "-")} 项', + f'缓存孤立: {payload.get("orphanInCacheCount", "-")} 项', + f'值不一致: {payload.get("mismatchCount", "-")} 项', + ], + ) + + def build_config_issue_samples_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建参数配置不一致样本共享分区。 + + :param payload: `config doctor` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='异常样本', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='样本', empty_value='不可用'), + ) + + lines: list[str] = [] + missing_in_cache = payload.get('missingInCache') + orphan_in_cache = payload.get('orphanInCache') + mismatch_keys = payload.get('mismatchKeys') + + if isinstance(missing_in_cache, list) and missing_in_cache: + lines.extend( + f'缓存缺失示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}' + for item in missing_in_cache[:5] + if isinstance(item, str) + ) + if isinstance(orphan_in_cache, list) and orphan_in_cache: + lines.extend( + f'缓存孤立示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}' + for item in orphan_in_cache[:5] + if isinstance(item, str) + ) + if isinstance(mismatch_keys, list) and mismatch_keys: + lines.extend( + f'值不一致示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}' + for item in mismatch_keys[:5] + if isinstance(item, str) + ) + + issue_count = ( + int(payload.get('missingInCacheCount', 0)) + + int(payload.get('orphanInCacheCount', 0)) + + int(payload.get('mismatchCount', 0)) + ) + return DetailSectionSnapshot( + title='异常样本', + status='fail' if issue_count > 0 else 'ok', + lines=lines + if lines + else TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='异常样本', + empty_value='0 条', + detail='当前未发现配置异常样本', + ), + ) + + def build_config_set_entry_section(self, rows: list[dict[str, Any]]) -> DetailSectionSnapshot: + """ + 构建配置变更入口共享分区。 + + :param rows: 当前配置行列表 + :return: 分区快照 + """ + sample_key = '' + sample_value = '' + for row in rows: + config_key = str(row.get('configKey', '') or '').strip() + config_value = str(row.get('configValue', '') or '').strip() + if config_key: + sample_key = config_key + sample_value = config_value or 'new-value' + break + if not sample_key: + sample_key = 'demo.config.key' + sample_value = 'new-value' + return DetailSectionSnapshot( + title='配置变更入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备修复高风险配置、回填缓存缺失项或校正数据库与缓存值不一致时,应先在终端中明确目标键和值,再执行配置变更命令。', + command=TUI_COPY.build_cli_command_hint('config', 'set', sample_key, sample_value, '--output=text'), + guide='建议先在当前页面核对配置键、来源和现值;变更完成后再回到工作台执行参数缓存刷新,确认巡检结果恢复正常。', + ), + ) + + def build_high_risk_config_section( + self, + rows: list[dict[str, Any]], + risk_sets: dict[str, set[str]], + ) -> DetailSectionSnapshot: + """ + 构建高风险配置共享分区。 + + :param rows: 配置行列表 + :param risk_sets: 风险集合 + :return: 分区快照 + """ + risky_rows = [ + row + for row in sorted( + rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets) + ) + if self.risk_support.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal' + ] + if not risky_rows: + return DetailSectionSnapshot( + title='高风险配置', + status='ok', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='高风险配置', + empty_value='0 项', + detail='当前巡检结果中没有需要优先处理的高风险配置', + ), + ) + lines: list[str] = [] + for row in risky_rows[:8]: + config_key = str(row.get('configKey', '-') or '-') + config_name = str(row.get('configName', '-') or '-') + risk_key = self.risk_support.resolve_config_risk_key(config_key, risk_sets) + lines.append( + f'[{CONFIG_RISK_LABELS.get(risk_key, "风险")}] ' + f'{SHELL_TEXT_FORMATTER.truncate_text(config_key, 40)} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 20)}' + ) + return DetailSectionSnapshot( + title='高风险配置', + status='warn' if risky_rows else 'ok', + lines=lines, + ) + + def build_configs_overview_section( + self, + rows: list[dict[str, Any]], + filtered_rows: list[dict[str, Any]], + risk_sets: dict[str, set[str]], + filter_label: str, + ) -> DetailSectionSnapshot: + """ + 构建参数配置页总览判断共享分区。 + + :param rows: 原始配置行列表 + :param filtered_rows: 当前筛选后的配置行列表 + :param risk_sets: 风险集合 + :param filter_label: 当前筛选标签 + :return: 分区快照 + """ + mismatch_count = len(risk_sets.get('mismatch', set())) + drift_count = len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set())) + risky_count = mismatch_count + drift_count + status = 'ok' + conclusion = '当前配置一致性正常,可继续抽查单项详情与同步状态' + if mismatch_count > 0: + status = 'warn' + conclusion = '存在值不一致配置,优先核对数据库与缓存是否同步' + elif drift_count > 0: + status = 'warn' + conclusion = '存在缓存漂移配置,建议优先确认缺失项与孤立项来源' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'当前筛选: {filter_label}', + f'已加载配置: {len(rows)} 项', + f'当前匹配: {len(filtered_rows)} 项', + f'高风险配置: {risky_count} 项', + f'值不一致: {mismatch_count} 项', + f'缓存漂移: {drift_count} 项', + '', + '## 建议入口', + '优先关注:高风险配置 / 值不一致 / 缓存漂移', + ], + ) + + def build_config_consistency_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建单个配置的一致性分区。 + + :param payload: `config get` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='同步状态', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='同步状态', empty_value='不可用' + ), + ) + lines = [ + '## 当前配置', + f'键名: {payload.get("key", "-")}', + f'读取来源: {payload.get("source", "-")}', + ] + source = str(payload.get('source', '-') or '-').strip().lower() + section_status = 'ok' + if source == 'both': + lines.append(f'数据库与缓存一致: {"是" if payload.get("inSync", False) else "否"}') + if not payload.get('inSync', False): + section_status = 'fail' + elif source == 'database': + lines.append('缓存状态: 缺失') + section_status = 'warn' + elif source == 'cache': + lines.append('数据库状态: 缺失') + section_status = 'warn' + return DetailSectionSnapshot( + title='同步状态', + status=section_status, + lines=lines, + ) + + def build_config_source_section( + self, + title: str, + config_item: dict[str, Any] | None, + *, + missing_text: str, + ) -> DetailSectionSnapshot: + """ + 构建配置来源详情分区。 + + :param title: 分区标题 + :param config_item: 配置来源详情 + :param missing_text: 缺失时提示 + :return: 分区快照 + """ + if not isinstance(config_item, dict): + return DetailSectionSnapshot( + title=title, + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label=title, + empty_value='未找到', + detail=missing_text, + ), + ) + return DetailSectionSnapshot( + title=title, + status='ok', + lines=[ + '## 基础信息', + f'配置 ID: {config_item.get("configId", "-")}', + f'键名: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configKey", "-"), 48)}', + f'名称: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configName", "-"), 32)}', + f'键值: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configValue", "-"), 72)}', + f'内置标记: {config_item.get("configType", "-")}', + '', + '## 备注', + SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', '-') or '-', 88), + ], + ) + + def load_config_detail_sections( + self, + config_row: dict[str, Any], + env: str, + ) -> list[DetailSectionSnapshot]: + """ + 按需加载单个配置项详情分区。 + + :param config_row: 配置项行数据 + :param env: 当前运行环境 + :return: 详情分区列表 + """ + config_key = str(config_row.get('configKey', '-') or '-') + detail_payload = NESTED_CLI_SUPPORT.run( + 'config', + 'get', + config_key, + f'--env={env}', + '--source=both', + '--output=json', + parse_json=True, + ).payload + database_payload = detail_payload.get('database') if isinstance(detail_payload, dict) else None + cache_payload = detail_payload.get('cache') if isinstance(detail_payload, dict) else None + return [ + self.build_config_consistency_section(detail_payload), + self.build_config_source_section('数据库配置', database_payload, missing_text='数据库中未找到该配置项'), + self.build_config_source_section('缓存配置', cache_payload, missing_text='缓存中未找到该配置项'), + ] + + +class ConfigRecordBuilder: + """ + 参数配置浏览记录构建器。 + + 该构建器负责构建配置浏览记录与失败兜底记录。 + + :param page_adapter: 参数配置浏览页适配器 + :param section_builder: 参数配置浏览页分区构建器 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + section_builder: ConfigSectionBuilder, + ) -> None: + """ + 初始化参数配置浏览记录构建器。 + + :param page_adapter: 参数配置浏览页适配器 + :param section_builder: 参数配置浏览页分区构建器 + :return: None + """ + self.page_adapter = page_adapter + self.section_builder = section_builder + + def build_record( + self, + config_row: dict[str, Any], + env: str, + *, + risk_key: str, + ) -> BrowserRecordSnapshot: + """ + 构建单条参数配置浏览记录。 + + :param config_row: 配置项行数据 + :param env: 当前运行环境 + :param risk_key: 当前配置项风险键 + :return: 浏览记录快照 + """ + config_id = config_row.get('configId', '-') + config_key = str(config_row.get('configKey', '-') or '-') + config_name = str(config_row.get('configName', '-') or '-') + config_type = str(config_row.get('configType', '-') or '-') + config_value = str(config_row.get('configValue', '-') or '-') + risk_label = CONFIG_RISK_LABELS.get(risk_key, '正常') + return BrowserRecordSnapshot( + key=f'config:{config_key}', + title=SHELL_TEXT_FORMATTER.truncate_text(config_key, 44), + status=CONFIG_RISK_STATUSES.get(risk_key, 'ok'), + summary=f'{risk_label} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 18)} · 值 {SHELL_TEXT_FORMATTER.truncate_text(config_value, 24)}', + metadata_lines=[ + '## 配置身份', + f'配置 ID: {config_id}', + f'配置名称: {SHELL_TEXT_FORMATTER.truncate_text(config_name, 40)}', + f'系统内置: {config_type}', + f'风险分类: {risk_label}', + '', + '## 当前值', + SHELL_TEXT_FORMATTER.truncate_text(config_value, 88), + ], + detail_sections=[], + detail_loader=lambda config_row=config_row, env=env: self.section_builder.load_config_detail_sections( + config_row, + env, + ), + ) + + def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot: + """ + 构建配置页失败兜底记录。 + + :param payload: 失败结果负载 + :return: 浏览记录快照 + """ + return self.page_adapter.build_failure_record( + key='config:unavailable', + subject='参数配置', + section_subject='配置列表', + payload=payload, + ) + + +class ConfigsBrowserAdapter(BaseBrowserAdapter): + """ + 参数配置浏览页适配器。 + + 该适配器负责采集配置巡检、配置列表和单项配置详情,并统一完成 + 风险分类、筛选排序、共享分区构建与浏览记录装配。 + + :param risk_support: 参数配置风险分类支持对象 + :param section_builder: 参数配置浏览页分区构建器 + :param record_builder: 参数配置浏览记录构建器 + """ + + def __init__( + self, + risk_support: ConfigRiskSupport | None = None, + section_builder: ConfigSectionBuilder | None = None, + record_builder: ConfigRecordBuilder | None = None, + ) -> None: + """ + 初始化参数配置浏览页适配器。 + + :param risk_support: 参数配置风险分类支持对象 + :param section_builder: 参数配置浏览页分区构建器 + :param record_builder: 参数配置浏览记录构建器 + :return: None + """ + super().__init__( + page_title='参数配置', + search_view_key='configs', + filter_options=CONFIG_FILTER_OPTIONS, + ) + self.risk_support = risk_support or ConfigRiskSupport() + self.section_builder = section_builder or ConfigSectionBuilder(self, self.risk_support) + self.record_builder = record_builder or ConfigRecordBuilder(self, self.section_builder) + + def collect_snapshot(self, env: str, filter_key: str = 'all', query: str = '') -> BrowserPageSnapshot: + """ + 采集参数配置浏览页只读快照。 + + :param env: 当前运行环境 + :param filter_key: 当前筛选键 + :param query: 当前搜索词 + :return: 浏览页快照 + """ + active_filter_option = self.resolve_active_filter(filter_key) + active_filter = active_filter_option.key + active_filter_label = active_filter_option.label + search_context = self.resolve_search_context(query) + doctor_payload = NESTED_CLI_SUPPORT.run( + 'config', + 'doctor', + f'--env={env}', + '--sample-limit=5', + '--output=json', + parse_json=True, + ).payload + list_payload = NESTED_CLI_SUPPORT.run( + 'config', + 'list', + f'--env={env}', + '--paged', + '--page-size=8', + '--output=json', + parse_json=True, + ).payload + risk_sets = self.risk_support.build_config_risk_sets(doctor_payload) + + if not isinstance(list_payload, dict) or not list_payload.get('ok', False): + return BrowserPageSnapshot( + title='参数配置', + subtitle=TUI_COPY.build_unavailable_subtitle( + '配置', + SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(list_payload), 72), + ), + records=[self.record_builder.build_failure_record(list_payload)], + shared_sections=[ + self.section_builder.build_configs_overview_section([], [], risk_sets, active_filter_label), + self.section_builder.build_config_doctor_section(doctor_payload), + self.section_builder.build_high_risk_config_section([], risk_sets), + self.section_builder.build_config_issue_samples_section(doctor_payload), + self.section_builder.build_config_set_entry_section([]), + ], + filters=list(self.filter_options), + active_filter_key=active_filter, + search=search_context, + ) + + rows = self.risk_support.append_orphan_cache_rows( + self.extract_page_rows(list_payload), + risk_sets.get('orphan-cache', set()), + ) + sorted_rows = sorted( + rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets) + ) + filtered_rows = self.risk_support.apply_config_query( + self.risk_support.apply_config_filter(sorted_rows, risk_sets, active_filter), + query, + ) + records = [ + self.record_builder.build_record( + config_row, + env, + risk_key=self.risk_support.resolve_config_risk_key( + str(config_row.get('configKey', '') or ''), + risk_sets, + ), + ) + for config_row in filtered_rows[:12] + ] + if not records: + records = [ + self.build_empty_record( + key='config:none', + subject='配置项', + empty_label='配置项', + has_source_rows=bool(sorted_rows), + filtered_summary='当前筛选条件下没有匹配配置项', + empty_summary='当前环境没有可浏览的参数配置项', + filtered_empty_value='暂无数据', + empty_empty_value='暂无配置', + filtered_detail='当前筛选条件下没有匹配配置项', + empty_detail='当前环境没有可浏览的参数配置项', + ) + ] + + subtitle = TUI_DIAGNOSTIC_SERVICE.build_configs_diagnostic_subtitle( + active_filter_label, + len(filtered_rows), + len(risk_sets.get('mismatch', set())), + len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set())), + ) + if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False): + subtitle = ( + f'{subtitle} | {SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(doctor_payload), 72)}' + ) + shared_sections = [ + self.section_builder.build_configs_overview_section(rows, filtered_rows, risk_sets, active_filter_label), + self.section_builder.build_config_doctor_section(doctor_payload), + self.section_builder.build_high_risk_config_section(rows, risk_sets), + self.section_builder.build_config_issue_samples_section(doctor_payload), + self.section_builder.build_config_set_entry_section(rows), + ] + return BrowserPageSnapshot( + title='参数配置', + subtitle=subtitle, + records=records, + shared_sections=shared_sections, + filters=list(self.filter_options), + active_filter_key=active_filter, + search=search_context, + ) + + +CONFIGS_BROWSER_ADAPTER = ConfigsBrowserAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/crypto.py b/shierfeng-fastapi-backend/cli/tui/adapters/crypto.py new file mode 100644 index 0000000..47c6f7a --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/crypto.py @@ -0,0 +1,372 @@ +from dataclasses import dataclass +from typing import Any + +from cli.tui.adapters.base import BaseDetailAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + DetailPageSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class CryptoDetailSourcePayloads: + """ + 传输加密详情页原始数据源快照。 + + :param validate_payload: `crypto validate` 结果 + :param public_payload: `crypto export-public` 结果 + """ + + validate_payload: dict[str, Any] | None + public_payload: dict[str, Any] | None + + +class CryptoDetailSnapshotCollector: + """ + 传输加密详情页数据采集器。 + + 该对象负责拉取传输加密详情页所需的 CLI 原始结果, + 让 `CryptoDetailAdapter` 保持详情页编排职责。 + """ + + def collect(self, env: str) -> CryptoDetailSourcePayloads: + """ + 采集传输加密详情页所需原始结果。 + + :param env: 当前运行环境 + :return: 传输加密详情页原始数据源快照 + """ + return CryptoDetailSourcePayloads( + validate_payload=NESTED_CLI_SUPPORT.run( + 'crypto', + 'validate', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + public_payload=NESTED_CLI_SUPPORT.run( + 'crypto', + 'export-public', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + ) + + +class CryptoSectionBuilder: + """ + 传输加密详情分区构建器。 + + 该构建器负责将传输加密相关 CLI 结果负载转换为 TUI 详情页分区, + 使详情页适配器本体只保留采集与编排职责。 + """ + + @staticmethod + def build_validate_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建传输加密运行校验分区。 + + :param payload: `crypto validate` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict): + return DetailSectionSnapshot( + title='运行校验', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='运行校验', empty_value='不可用' + ), + ) + lines = [ + '## 校验结果', + f'状态: {"通过" if payload.get("ok", False) else "失败"}', + f'说明: {SHELL_TEXT_FORMATTER.truncate_text(TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload), 72)}', + ] + if payload.get('error'): + lines.append(f'错误: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("error", "-"), 72)}') + return DetailSectionSnapshot( + title='运行校验', + status='ok' if payload.get('ok', False) else 'fail', + lines=lines, + ) + + @staticmethod + def build_public_identity_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建公钥身份分区。 + + :param payload: `crypto export-public` JSON 负载 + :return: 分区快照 + """ + public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None + if not isinstance(public_key_payload, dict): + return DetailSectionSnapshot( + title='公钥身份', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='公钥', empty_value='不可用'), + ) + return DetailSectionSnapshot( + title='公钥身份', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 当前版本', + f'KID: {public_key_payload.get("kid", "-")}', + f'算法: {public_key_payload.get("alg", "-")}', + f'信封版本: {public_key_payload.get("envelopeVersion", "-")}', + f'过期时间: {public_key_payload.get("expireAt", "-")}', + ], + ) + + @staticmethod + def build_supported_kids_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建兼容版本分区。 + + :param payload: `crypto export-public` JSON 负载 + :return: 分区快照 + """ + public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None + if not isinstance(public_key_payload, dict): + return DetailSectionSnapshot( + title='兼容版本', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='兼容版本', empty_value='不可用' + ), + ) + supported_kids = public_key_payload.get('supportedKids') + lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='兼容版本', + empty_value='0 个', + detail='当前公钥未声明兼容版本 KID', + suggestion='如需兼容旧版本客户端,可检查 supportedKids 配置', + ) + if isinstance(supported_kids, list) and supported_kids: + lines = ['## 兼容 KID', *[f'支持版本: {kid}' for kid in supported_kids[:10]]] + return DetailSectionSnapshot( + title='兼容版本', + status='ok', + lines=lines, + ) + + def build_public_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建公钥预览分区。 + + :param payload: `crypto export-public` JSON 负载 + :return: 分区快照 + """ + public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None + if not isinstance(public_key_payload, dict): + return DetailSectionSnapshot( + title='公钥预览', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='公钥预览', empty_value='不可用' + ), + ) + public_key = str(public_key_payload.get('publicKey', '') or '').splitlines() + if not public_key: + return DetailSectionSnapshot( + title='公钥预览', + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='公钥预览', + empty_value='无内容', + detail='当前公钥内容为空,无法生成预览', + suggestion='可检查公钥导出结果,或重新生成公钥后再刷新', + ), + ) + return DetailSectionSnapshot( + title='公钥预览', + status='info', + lines=['## 预览内容', *[SHELL_TEXT_FORMATTER.truncate_text(line, 88) for line in public_key[:6]]], + ) + + @staticmethod + def build_overview_section( + validate_payload: dict[str, Any] | None, + public_payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建加密页总览判断分区。 + + :param validate_payload: `crypto validate` JSON 负载 + :param public_payload: `crypto export-public` JSON 负载 + :return: 分区快照 + """ + validate_ok = bool(isinstance(validate_payload, dict) and validate_payload.get('ok', False)) + public_ok = bool(isinstance(public_payload, dict) and public_payload.get('ok', False)) + public_key_payload = public_payload.get('publicKey') if isinstance(public_payload, dict) else None + supported_kids = public_key_payload.get('supportedKids') if isinstance(public_key_payload, dict) else None + supported_count = len(supported_kids) if isinstance(supported_kids, list) else 0 + + status = 'ok' + conclusion = '传输加密基线正常,可继续查看公钥身份、兼容版本与预演入口' + if not validate_ok: + status = 'fail' + conclusion = '运行校验失败,优先确认传输加密配置与环境变量' + elif not public_ok or not isinstance(public_key_payload, dict): + status = 'warn' + conclusion = '公钥导出结果异常,建议先确认当前 KID 与公钥内容是否可用' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'运行校验: {"通过" if validate_ok else "失败"}', + f'当前 KID: {public_key_payload.get("kid", "-") if isinstance(public_key_payload, dict) else "-"}', + f'算法: {public_key_payload.get("alg", "-") if isinstance(public_key_payload, dict) else "-"}', + f'兼容版本数: {supported_count}', + '', + '## 建议入口', + '优先关注:运行校验 / 公钥身份 / 兼容版本 / 轮换预演入口', + ], + ) + + @staticmethod + def build_rotation_entry_section(env: str) -> DetailSectionSnapshot: + """ + 构建加密密钥轮换预演入口分区。 + + :param env: 当前运行环境 + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='轮换预演入口', + status='warn', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备轮换传输加密密钥时,应先做 dry-run,确认新 KID、密钥长度和 guard 提示后再执行正式轮换。', + command=TUI_COPY.build_cli_command_hint( + 'crypto', + 'rotate', + f'--env={env}', + '--dry-run', + '--output=json', + ), + guide='当前页面先提供预演入口;正式轮换仍应遵循危险命令确认流程,避免直接在未知环境落地变更。', + ), + ) + + @staticmethod + def build_keygen_entry_section(env: str) -> DetailSectionSnapshot: + """ + 构建加密密钥生成入口分区。 + + :param env: 当前运行环境 + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='密钥生成入口', + status='warn', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备生成新的传输加密密钥对时,应在当前终端直接查看生成结果,并立即核对 KID、密钥长度和环境变量补丁。', + command=TUI_COPY.build_cli_command_hint( + 'crypto', + 'keygen', + f'--env={env}', + '--output=text', + ), + guide='当前入口会在终端中直接输出新公钥、私钥和 env patch keys,后续再决定是否进入轮换流程。', + ), + ) + + def build_sections( + self, + *, + validate_payload: dict[str, Any] | None, + public_payload: dict[str, Any] | None, + env: str, + ) -> list[DetailSectionSnapshot]: + """ + 构建传输加密页全部详情分区。 + + :param validate_payload: `crypto validate` 结果负载 + :param public_payload: `crypto export-public` 结果负载 + :param env: 当前运行环境 + :return: 详情分区列表 + """ + return [ + self.build_overview_section(validate_payload, public_payload), + self.build_validate_section(validate_payload), + self.build_public_identity_section(public_payload), + self.build_supported_kids_section(public_payload), + self.build_public_preview_section(public_payload), + self.build_keygen_entry_section(env), + self.build_rotation_entry_section(env), + ] + + +class CryptoDetailAdapter(BaseDetailAdapter): + """ + 传输加密详情页适配器。 + + 该适配器负责采集传输加密相关 CLI 结果,并组装为 TUI 详情页快照。 + 页面私有的解析逻辑、分区构建逻辑和快照采集流程统一收口在该类中。 + + :param section_builder: 传输加密详情分区构建器 + """ + + def __init__( + self, + section_builder: CryptoSectionBuilder | None = None, + snapshot_collector: CryptoDetailSnapshotCollector | None = None, + ) -> None: + """ + 初始化传输加密详情页适配器。 + + :param section_builder: 传输加密详情分区构建器 + :param snapshot_collector: 传输加密详情页数据采集器 + :return: None + """ + super().__init__( + page_title='传输加密', + search_view_key='crypto', + default_suggestions=[ + '总览判断', + '运行校验', + '公钥身份', + '兼容版本', + '公钥预览', + '密钥生成入口', + '轮换预演入口', + ], + ) + self.section_builder = section_builder or CryptoSectionBuilder() + self.snapshot_collector = snapshot_collector or CryptoDetailSnapshotCollector() + + def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot: + """ + 采集传输加密状态页只读快照。 + + :param env: 当前运行环境 + :param query: 当前搜索词 + :return: 页面快照 + """ + source_payloads = self.snapshot_collector.collect(env) + sections = self.section_builder.build_sections( + validate_payload=source_payloads.validate_payload, + public_payload=source_payloads.public_payload, + env=env, + ) + return DetailPageSnapshot( + title='传输加密', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_crypto_diagnostic_subtitle( + source_payloads.validate_payload, + source_payloads.public_payload, + ), + sections=self.filter_sections(sections, query), + search=self.resolve_search_context(query), + ) + + +CRYPTO_DETAIL_ADAPTER = CryptoDetailAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/database.py b/shierfeng-fastapi-backend/cli/tui/adapters/database.py new file mode 100644 index 0000000..3e27912 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/database.py @@ -0,0 +1,474 @@ +from dataclasses import dataclass +from typing import Any + +from cli.tui.adapters.base import BaseDetailAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + DetailPageSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class DatabaseDetailSourcePayloads: + """ + 数据库详情页原始数据源快照。 + + :param revision_payload: `db current` 结果 + :param check_payload: `db check` 结果 + :param heads_payload: `db heads` 结果 + :param history_payload: `db history` 结果 + :param config_payload: `app config` 结果 + """ + + revision_payload: dict[str, Any] | None + check_payload: dict[str, Any] | None + heads_payload: dict[str, Any] | None + history_payload: dict[str, Any] | None + config_payload: dict[str, Any] | None + + +class DatabaseDetailSnapshotCollector: + """ + 数据库详情页数据采集器。 + + 该对象负责拉取数据库详情页所需的多路 CLI 原始结果, + 让 `DatabaseDetailAdapter` 保持详情页编排职责。 + """ + + def collect(self, env: str) -> DatabaseDetailSourcePayloads: + """ + 采集数据库详情页所需原始结果。 + + :param env: 当前运行环境 + :return: 数据库详情页原始数据源快照 + """ + return DatabaseDetailSourcePayloads( + revision_payload=NESTED_CLI_SUPPORT.run( + 'db', + 'current', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + check_payload=NESTED_CLI_SUPPORT.run( + 'db', + 'check', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + heads_payload=NESTED_CLI_SUPPORT.run( + 'db', + 'heads', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + history_payload=NESTED_CLI_SUPPORT.run( + 'db', + 'history', + f'--env={env}', + '--limit=8', + '--output=json', + parse_json=True, + ).payload, + config_payload=NESTED_CLI_SUPPORT.run( + 'app', + 'config', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + ) + + +class DatabaseSectionBuilder: + """ + 数据库详情分区构建器。 + + 该构建器负责将数据库相关 CLI 结果负载转换为 TUI 详情页分区, + 使详情页适配器本体只保留采集与编排职责。 + """ + + @staticmethod + def extract_revision_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]: + """ + 从 Alembic 结果中提取修订版本列表。 + + :param payload: `db heads` 或 `db history` JSON 负载 + :return: 修订版本列表 + """ + items = payload.get('items') if isinstance(payload, dict) else None + if not isinstance(items, list): + return [] + return [item for item in items if isinstance(item, dict)] + + @staticmethod + def build_revision_item_lines(item: dict[str, Any], *, index: int, item_label: str) -> list[str]: + """ + 构建单条 Alembic 修订版本详情文本。 + + :param item: 修订版本字典 + :param index: 当前索引 + :param item_label: 条目标识名称 + :return: 文本行列表 + """ + down_revisions = item.get('downRevisions') + branch_labels = item.get('branchLabels') + depends_on = item.get('dependsOn') + return [ + f'## {item_label} {index:02d} · {item.get("revision", "-")}', + f'> 下游版本: {",".join(down_revisions) if isinstance(down_revisions, list) and down_revisions else "-"}', + f'> 分支标签: {",".join(branch_labels) if isinstance(branch_labels, list) and branch_labels else "-"}', + f'> 依赖版本: {",".join(depends_on) if isinstance(depends_on, list) and depends_on else "-"}', + f'> 说明: {SHELL_TEXT_FORMATTER.truncate_text(item.get("doc", "-"), 72)}', + f'> 文件: {SHELL_TEXT_FORMATTER.truncate_text(item.get("path", "-"), 72)}', + ] + + @staticmethod + def build_revision_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建数据库迁移状态分区。 + + :param payload: `db current` JSON 负载 + :return: 分区快照 + """ + current_revision = payload.get('currentRevision', '-') if isinstance(payload, dict) else '-' + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='迁移版本', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='迁移版本', empty_value=str(current_revision or '-') + ), + ) + return DetailSectionSnapshot( + title='迁移版本', + status='ok', + lines=[ + '## 当前版本', + f'迁移版本: {current_revision}', + '', + '## 状态说明', + f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}', + ], + ) + + def build_overview_section( + self, + revision_payload: dict[str, Any] | None, + check_payload: dict[str, Any] | None, + heads_payload: dict[str, Any] | None, + history_payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建数据库页总览判断分区。 + + :param revision_payload: `db current` JSON 负载 + :param check_payload: `db check` JSON 负载 + :param heads_payload: `db heads` JSON 负载 + :param history_payload: `db history` JSON 负载 + :return: 分区快照 + """ + revision_ok = bool(isinstance(revision_payload, dict) and revision_payload.get('ok', False)) + check_ok = bool(isinstance(check_payload, dict) and check_payload.get('ok', False)) + heads_ok = bool(isinstance(heads_payload, dict) and heads_payload.get('ok', False)) + current_revision = revision_payload.get('currentRevision', '-') if isinstance(revision_payload, dict) else '-' + head_items = self.extract_revision_items(heads_payload) + history_items = self.extract_revision_items(history_payload) + + status = 'ok' + conclusion = '数据库基线正常,可继续查看连接状态、Heads 与版本链路' + if not revision_ok or not check_ok: + status = 'fail' + conclusion = '数据库存在基础异常,优先确认迁移版本与连通性检查结果' + elif not heads_ok or len(head_items) != 1: + status = 'warn' + conclusion = '数据库存在迁移分叉风险,优先确认 Heads 与版本链路是否一致' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'当前 revision: {current_revision}', + f'连通性检查: {"正常" if check_ok else "异常"}', + f'Heads 数量: {len(head_items)}', + f'版本数量: {history_payload.get("totalCount", len(history_items)) if isinstance(history_payload, dict) else len(history_items)}', + '', + '## 建议入口', + '优先关注:迁移版本 / 连通性检查 / Heads 状态 / 升级入口', + ], + ) + + @staticmethod + def build_profile_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建数据库连接配置分区。 + + :param payload: `app config` JSON 负载 + :return: 分区快照 + """ + config = payload.get('config') if isinstance(payload, dict) else None + if not isinstance(config, dict): + return DetailSectionSnapshot( + title='连接信息', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='连接信息', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='连接信息', + status='ok', + lines=[ + '## 数据库连接', + f'数据库类型: {config.get("dbType", "-")}', + f'连接地址: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}', + f'数据库名: {config.get("dbDatabase", "-")}', + ], + ) + + @staticmethod + def build_check_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建数据库连通性检查分区。 + + :param payload: `db check` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='连通性检查', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='数据库连接', empty_value='异常' + ), + ) + return DetailSectionSnapshot( + title='连通性检查', + status='ok', + lines=[ + '## 当前状态', + '数据库连接: 正常', + '', + '## 结果摘要', + f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}', + ], + ) + + def build_heads_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建 Alembic heads 分区。 + + :param payload: `db heads` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='Heads 状态', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='Heads', empty_value='不可用' + ), + ) + items = self.extract_revision_items(payload) + status = 'ok' if len(items) == 1 else 'warn' + lines = [ + '## Heads 概况', + f'Heads 数量: {len(items)}', + f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}', + ] + if not items: + lines.extend(['', '## Heads 列表', '> 当前仓库未返回可用 heads']) + else: + lines.extend(['', '## Heads 列表']) + for index, item in enumerate(items[:4], start=1): + lines.extend(self.build_revision_item_lines(item, index=index, item_label='Head')) + lines.append('') + if len(lines) > 1 and lines[-1] == '': + lines.pop() + return DetailSectionSnapshot( + title='Heads 状态', + status=status, + lines=lines, + ) + + def build_history_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建 Alembic 历史版本分区。 + + :param payload: `db history` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='历史版本', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='历史版本', empty_value='不可用' + ), + ) + items = self.extract_revision_items(payload) + lines = [ + '## 历史概况', + f'返回数量: {payload.get("count", len(items))}', + f'总版本数: {payload.get("totalCount", len(items))}', + f'查询上限: {payload.get("limit", len(items))}', + '', + '## 历史列表', + ] + if not items: + lines.append('> 当前仓库未返回可展示的历史版本') + else: + for index, item in enumerate(items[:6], start=1): + lines.extend(self.build_revision_item_lines(item, index=index, item_label='版本')) + lines.append('') + if lines[-1] == '': + lines.pop() + return DetailSectionSnapshot( + title='历史版本', + status='ok', + lines=lines, + ) + + @staticmethod + def build_upgrade_entry_section() -> DetailSectionSnapshot: + """ + 构建数据库升级向导入口分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='升级入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备执行数据库升级、校验迁移路径或在生产前做升级预演时,应先通过向导确认目标版本与 dry-run 结果。', + command=TUI_COPY.build_cli_command_hint('wizard', 'db-upgrade', '--output=text'), + guide='向导会继续询问目标环境和目标 revision,并在真正升级前先展示预览信息。', + ), + ) + + @staticmethod + def build_init_entry_section() -> DetailSectionSnapshot: + """ + 构建数据库初始化预演入口分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='初始化预演入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备初始化新库、重建本地环境,或先确认初始化命令会执行到哪些迁移版本时,应先执行 dry-run 预演。', + command=TUI_COPY.build_cli_command_hint('db', 'init', '--dry-run', '--output=text'), + guide='预演只返回命令摘要与执行目录;确认无误后,再通过外部终端或向导执行真实初始化流程。', + ), + ) + + def build_sections( + self, + *, + revision_payload: dict[str, Any] | None, + check_payload: dict[str, Any] | None, + heads_payload: dict[str, Any] | None, + history_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + ) -> list[DetailSectionSnapshot]: + """ + 构建数据库页全部详情分区。 + + :param revision_payload: `db current` 结果负载 + :param check_payload: `db check` 结果负载 + :param heads_payload: `db heads` 结果负载 + :param history_payload: `db history` 结果负载 + :param config_payload: `app config` 结果负载 + :return: 详情分区列表 + """ + return [ + self.build_overview_section(revision_payload, check_payload, heads_payload, history_payload), + self.build_revision_section(revision_payload), + self.build_profile_section(config_payload), + self.build_check_section(check_payload), + self.build_heads_section(heads_payload), + self.build_history_section(history_payload), + self.build_init_entry_section(), + self.build_upgrade_entry_section(), + ] + + +class DatabaseDetailAdapter(BaseDetailAdapter): + """ + 数据库详情页适配器。 + + 该适配器负责采集数据库相关 CLI 结果,并组装为 TUI 详情页快照。 + 页面私有的解析逻辑、分区构建逻辑和快照采集流程统一收口在该类中。 + + :param section_builder: 数据库详情分区构建器 + """ + + def __init__( + self, + section_builder: DatabaseSectionBuilder | None = None, + snapshot_collector: DatabaseDetailSnapshotCollector | None = None, + ) -> None: + """ + 初始化数据库详情页适配器。 + + :param section_builder: 数据库详情分区构建器 + :param snapshot_collector: 数据库详情页数据采集器 + :return: None + """ + super().__init__( + page_title='数据库', + search_view_key='database', + default_suggestions=[ + '总览判断', + '迁移版本', + '连接信息', + '连通性检查', + 'Heads 状态', + '历史版本', + '初始化预演入口', + '升级入口', + ], + ) + self.section_builder = section_builder or DatabaseSectionBuilder() + self.snapshot_collector = snapshot_collector or DatabaseDetailSnapshotCollector() + + def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot: + """ + 采集数据库状态页只读快照。 + + :param env: 当前运行环境 + :param query: 当前搜索词 + :return: 页面快照 + """ + source_payloads = self.snapshot_collector.collect(env) + sections = self.section_builder.build_sections( + revision_payload=source_payloads.revision_payload, + check_payload=source_payloads.check_payload, + heads_payload=source_payloads.heads_payload, + history_payload=source_payloads.history_payload, + config_payload=source_payloads.config_payload, + ) + return DetailPageSnapshot( + title='数据库', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_database_diagnostic_subtitle( + source_payloads.revision_payload, + source_payloads.check_payload, + source_payloads.heads_payload, + ), + sections=self.filter_sections(sections, query), + search=self.resolve_search_context(query), + ) + + +DATABASE_DETAIL_ADAPTER = DatabaseDetailAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/gen.py b/shierfeng-fastapi-backend/cli/tui/adapters/gen.py new file mode 100644 index 0000000..37b7e44 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/gen.py @@ -0,0 +1,807 @@ +from typing import Any + +from cli.tui.adapters.base import BaseBrowserAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + BrowserPageSnapshot, + BrowserRecordSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +class GenRenderingSupport: + """ + 代码生成浏览页渲染支持对象。 + + 该对象负责布尔语义渲染与常见文本辅助逻辑,供分区和记录构建复用。 + """ + + @staticmethod + def render_yes_no(value: object) -> str: + """ + 将常见布尔语义值转换为“是/否”。 + + :param value: 原始值 + :return: 中文布尔文本 + """ + normalized = str(value).strip().lower() + return '是' if normalized in {'1', 'y', 'yes', 'true'} else '否' + + +class GenSectionBuilder: + """ + 代码生成浏览页分区构建器。 + + 该构建器负责构建代码生成页共享分区,以及单条业务表详情分区。 + + :param page_adapter: 代码生成浏览页适配器 + :param rendering: 代码生成浏览页渲染支持对象 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + rendering: GenRenderingSupport, + ) -> None: + """ + 初始化代码生成浏览页分区构建器。 + + :param page_adapter: 代码生成浏览页适配器 + :param rendering: 代码生成浏览页渲染支持对象 + :return: None + """ + self.page_adapter = page_adapter + self.rendering = rendering + + def build_gen_focus_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建业务表概览分区。 + + :param payload: `gen detail` JSON 负载 + :return: 分区快照 + """ + detail = payload.get('detail') if isinstance(payload, dict) else None + info = detail.get('info') if isinstance(detail, dict) else None + if not isinstance(info, dict): + return DetailSectionSnapshot( + title='业务表摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='详情', empty_value='无'), + ) + return DetailSectionSnapshot( + title='业务表摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 表身份', + f'表 ID: {payload.get("tableId", "-")}', + f'表名: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("tableName", "-"), 40)}', + ], + ) + + def build_gen_generation_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建代码生成配置分区。 + + :param payload: `gen detail` JSON 负载 + :return: 分区快照 + """ + detail = payload.get('detail') if isinstance(payload, dict) else None + info = detail.get('info') if isinstance(detail, dict) else None + if not isinstance(info, dict): + return DetailSectionSnapshot( + title='生成配置', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='生成配置', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='生成配置', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 生成类信息', + f'生成类名: {SHELL_TEXT_FORMATTER.truncate_text(info.get("className", "-"), 32)}', + f'所属模块: {SHELL_TEXT_FORMATTER.truncate_text(info.get("moduleName", "-"), 24)}', + '', + '## 业务信息', + f'业务标识: {SHELL_TEXT_FORMATTER.truncate_text(info.get("businessName", "-"), 24)}', + f'功能名称: {SHELL_TEXT_FORMATTER.truncate_text(info.get("functionName", "-"), 24)}', + ], + ) + + def build_gen_column_summary_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建字段概览分区。 + + :param payload: `gen detail` JSON 负载 + :return: 分区快照 + """ + detail = payload.get('detail') if isinstance(payload, dict) else None + rows = detail.get('rows') if isinstance(detail, dict) else None + if not isinstance(rows, list): + return DetailSectionSnapshot( + title='字段摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='字段摘要', empty_value='不可用' + ), + ) + row_dicts = [row for row in rows if isinstance(row, dict)] + primary_key_count = sum(1 for row in row_dicts if self.rendering.render_yes_no(row.get('isPk', '-')) == '是') + required_count = sum(1 for row in row_dicts if self.rendering.render_yes_no(row.get('isRequired', '-')) == '是') + return DetailSectionSnapshot( + title='字段摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 字段规模', + f'字段总数: {payload.get("columnCount", "-")}', + f'主键字段: {primary_key_count}', + f'必填字段: {required_count}', + ], + ) + + def build_gen_columns_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建业务表字段分区。 + + :param payload: `gen detail` JSON 负载 + :return: 分区快照 + """ + detail = payload.get('detail') if isinstance(payload, dict) else None + rows = detail.get('rows') if isinstance(detail, dict) else None + if not isinstance(rows, list) or not rows: + return DetailSectionSnapshot( + title='字段列表', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='字段', empty_value='无'), + ) + lines: list[str] = [] + for row in rows[:12]: + if not isinstance(row, dict): + continue + lines.extend( + [ + f'## {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnName", "-"), 24)} · {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnType", "-"), 20)}', + ( + f'> 主键: {self.rendering.render_yes_no(row.get("isPk", "-"))} | ' + f'必填: {self.rendering.render_yes_no(row.get("isRequired", "-"))} | ' + f'查询: {row.get("queryType", "-")}' + ), + f'> 说明: {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnComment", "-"), 40)}', + '', + ] + ) + return DetailSectionSnapshot( + title='字段列表', + status='ok' if payload.get('ok', False) else 'fail', + lines=(lines[:-1] if lines and lines[-1] == '' else lines) + or TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='字段信息', + empty_value='0 列', + detail='当前业务表没有可展示的字段信息', + ), + ) + + def build_gen_precheck_section( + self, + detail_payload: dict[str, Any] | None, + preview_payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建代码生成前校验分区。 + + :param detail_payload: `gen detail` JSON 负载 + :param preview_payload: `gen preview` JSON 负载 + :return: 分区快照 + """ + detail = detail_payload.get('detail') if isinstance(detail_payload, dict) else None + info = detail.get('info') if isinstance(detail, dict) else None + rows = detail.get('rows') if isinstance(detail, dict) and isinstance(detail.get('rows'), list) else [] + preview = preview_payload.get('preview') if isinstance(preview_payload, dict) else None + preview_templates = preview if isinstance(preview, dict) else {} + row_dicts = [row for row in rows if isinstance(row, dict)] + + has_class_name = bool(isinstance(info, dict) and str(info.get('className', '') or '').strip()) + has_module_name = bool(isinstance(info, dict) and str(info.get('moduleName', '') or '').strip()) + has_business_name = bool(isinstance(info, dict) and str(info.get('businessName', '') or '').strip()) + has_primary_key = any(self.rendering.render_yes_no(row.get('isPk', '-')) == '是' for row in row_dicts) + column_count = len(row_dicts) + template_count = len(preview_templates) + + risk_items: list[str] = [] + if not has_class_name: + risk_items.append('生成类名缺失') + if not has_module_name: + risk_items.append('所属模块缺失') + if not has_business_name: + risk_items.append('业务标识缺失') + if column_count <= 0: + risk_items.append('字段列表为空') + if not has_primary_key: + risk_items.append('未识别到主键字段') + if template_count <= 0: + risk_items.append('未生成可预览模板') + + status = 'ok' if not risk_items else 'warn' + return DetailSectionSnapshot( + title='生成前校验', + status=status, + lines=[ + '## 校验结果', + f'生成类名: {"通过" if has_class_name else "缺失"}', + f'所属模块: {"通过" if has_module_name else "缺失"}', + f'业务标识: {"通过" if has_business_name else "缺失"}', + f'字段数量: {column_count}', + f'主键字段: {"通过" if has_primary_key else "缺失"}', + f'模板预览: {template_count} 份', + '', + '## 风险提示', + *(risk_items if risk_items else ['当前未发现阻断生成的明显风险']), + ], + ) + + def build_gen_sync_precheck_section( + self, + table_name: str, + payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建数据库表结构同步前检查分区。 + + :param table_name: 业务表名称 + :param payload: `gen db-list --table-name=...` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='同步预检查', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='同步预检查', empty_value='不可用' + ), + ) + + rows = self.page_adapter.extract_page_rows(payload) + matched_row = next( + (row for row in rows if str(row.get('tableName', '') or '').strip().lower() == table_name.strip().lower()), + None, + ) + if matched_row is None: + return DetailSectionSnapshot( + title='同步预检查', + status='warn', + lines=[ + '## 当前状态', + f'目标业务表: {table_name}', + '数据库物理表: 未匹配', + '', + '## 建议操作', + '当前数据库中未找到同名物理表,执行同步前应先确认表结构来源', + ], + ) + + return DetailSectionSnapshot( + title='同步预检查', + status='ok', + lines=[ + '## 当前状态', + f'目标业务表: {table_name}', + f'数据库物理表: {matched_row.get("tableName", "-")}', + f'表注释: {SHELL_TEXT_FORMATTER.truncate_text(matched_row.get("tableComment", "-"), 56)}', + '', + '## 建议操作', + '已匹配到同名物理表,可继续使用同步数据库表结构动作', + ], + ) + + def build_gen_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建代码预览分区。 + + :param payload: `gen preview` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='代码预览', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='代码预览', empty_value='不可用' + ), + ) + preview_payload = payload.get('preview') if isinstance(payload.get('preview'), dict) else {} + lines = [ + '## 模板规模', + f'模板数量: {payload.get("templateCount", len(preview_payload))}', + ] + if not preview_payload: + lines.extend( + [ + '', + *TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='模板样本', + empty_value='0 份', + detail='当前业务表没有可预览的模板输出', + ), + ] + ) + return DetailSectionSnapshot(title='代码预览', status='info', lines=lines) + + lines.extend(['', '## 模板样本']) + for template_name, template_content in list(preview_payload.items())[:4]: + preview_lines = str(template_content).splitlines()[:3] + lines.append(f'## {SHELL_TEXT_FORMATTER.truncate_text(template_name, 48)}') + lines.extend(f'> {SHELL_TEXT_FORMATTER.truncate_text(line, 72)}' for line in preview_lines) + lines.append('') + if lines[-1] == '': + lines.pop() + return DetailSectionSnapshot( + title='代码预览', + status='ok', + lines=lines, + ) + + def build_gen_export_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建代码导出预演分区。 + + :param payload: `gen export --dry-run` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='导出预览', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='导出预览', empty_value='不可用' + ), + ) + + results = payload.get('results') if isinstance(payload.get('results'), list) else [] + lines = [ + '## 预演结果', + f'执行模式: {payload.get("mode", "-")}', + f'dry-run: {"是" if payload.get("dryRun", False) else "否"}', + f'表数量: {len(payload.get("tableNames", [])) if isinstance(payload.get("tableNames"), list) else 0}', + f'结果数量: {len(results)}', + '', + '## 摘要', + f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}', + ] + if payload.get('outputFile'): + lines.append(f'输出文件: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("outputFile", "-"), 72)}') + if payload.get('genPath'): + lines.append(f'输出目录: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("genPath", "-"), 72)}') + if results: + lines.extend(['', '## 结果样本']) + for item in results[:4]: + if not isinstance(item, dict): + continue + lines.append( + f'> {item.get("tableName", "-")} · {"成功" if item.get("ok", False) else "失败"} · ' + f'{SHELL_TEXT_FORMATTER.truncate_text(item.get("message", "-"), 48)}' + ) + return DetailSectionSnapshot( + title='导出预览', + status='ok', + lines=lines, + ) + + def build_importable_tables_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建数据库可导入表共享分区。 + + :param payload: `gen db-list` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='可导入数据表', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='数据表', empty_value='不可用' + ), + ) + rows = self.page_adapter.extract_page_rows(payload) + lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='可导入数据表', + empty_value='0 张', + detail='当前环境没有可导入的数据库表', + ) + if rows: + lines = [ + f'{SHELL_TEXT_FORMATTER.truncate_text(row.get("tableName", "-"), 40)} · {SHELL_TEXT_FORMATTER.truncate_text(row.get("tableComment", "-"), 48)}' + for row in rows[:8] + ] + return DetailSectionSnapshot( + title='可导入数据表', + status='ok', + lines=lines, + ) + + @staticmethod + def build_gen_export_entry_section(table_name: str) -> DetailSectionSnapshot: + """ + 构建代码导出向导入口分区。 + + :param table_name: 当前业务表名称 + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='导出入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario=f'准备导出当前业务表 {table_name} 的生成结果时,应先通过向导确认目标表、输出目录和覆盖范围。', + command=TUI_COPY.build_cli_command_hint('wizard', 'gen-export', '--output=text'), + guide='进入向导后优先选择当前业务表,并在最终确认前检查导出目录和文件覆盖预览。', + ), + ) + + @staticmethod + def build_gen_import_entry_section(importable_rows: list[dict[str, Any]]) -> DetailSectionSnapshot: + """ + 构建物理表导入入口分区。 + + :param importable_rows: 可导入物理表列表 + :return: 分区快照 + """ + suggested_table_names = [ + str(row.get('tableName', '') or '').strip() + for row in importable_rows[:3] + if str(row.get('tableName', '') or '').strip() + ] + suggested_tables = ' '.join(suggested_table_names) if suggested_table_names else '' + return DetailSectionSnapshot( + title='导入入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备把数据库物理表纳入代码生成管理时,应先核对物理表名、注释和目标环境,再通过导入命令执行 dry-run 预演。', + command=TUI_COPY.build_cli_command_hint( + 'gen', 'import-table', suggested_tables, '--dry-run', '--output=text' + ), + guide='建议先从“可导入数据表”中确认目标表名,必要时一次只导入 1 到 3 张表,确认 dry-run 输出后再执行真实导入。', + ), + ) + + @staticmethod + def build_gen_create_entry_section() -> DetailSectionSnapshot: + """ + 构建建表 SQL 导入入口分区。 + + :return: 分区快照 + """ + sql_template = 'CREATE TABLE demo_table (id bigint primary key);' + return DetailSectionSnapshot( + title='建表入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备根据建表 SQL 直接创建物理表并导入代码生成配置时,应先使用单条 SQL 执行 dry-run 预演,确认语句可解析且只包含目标表。', + command=TUI_COPY.build_cli_command_hint( + 'gen', 'create-table', '--dry-run', '--sql', sql_template, '--output=text' + ), + guide='建议优先使用单条 CREATE TABLE 语句做预演;若 SQL 较长或来自文件,可改用 --sql-file 方式,但不要同时传入 --sql 与 --sql-file。', + ), + ) + + def load_gen_detail_sections( + self, + table_row: dict[str, Any], + env: str, + ) -> list[DetailSectionSnapshot]: + """ + 按需加载单条业务表详情分区。 + + :param table_row: 业务表列表行数据 + :param env: 当前运行环境 + :return: 详情分区列表 + """ + table_id = table_row.get('tableId') + detail_payload = NESTED_CLI_SUPPORT.run( + 'gen', + 'detail', + str(table_id), + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + preview_payload = NESTED_CLI_SUPPORT.run( + 'gen', + 'preview', + str(table_id), + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + table_name = str(table_row.get('tableName', '-') or '-') + export_payload = NESTED_CLI_SUPPORT.run( + 'gen', + 'export', + table_name, + f'--env={env}', + '--dry-run', + '--mode=zip', + '--output=json', + '--yes', + parse_json=True, + ).payload + sync_check_payload = NESTED_CLI_SUPPORT.run( + 'gen', + 'db-list', + f'--env={env}', + f'--table-name={table_name}', + '--paged', + '--page-size=5', + '--output=json', + parse_json=True, + ).payload + return [ + self.build_gen_focus_section(detail_payload), + self.build_gen_generation_section(detail_payload), + self.build_gen_column_summary_section(detail_payload), + self.build_gen_precheck_section(detail_payload, preview_payload), + self.build_gen_sync_precheck_section(table_name, sync_check_payload), + self.build_gen_columns_section(detail_payload), + self.build_gen_preview_section(preview_payload), + self.build_gen_export_preview_section(export_payload), + self.build_gen_export_entry_section(table_name), + ] + + def build_gen_overview_section( + self, + gen_rows: list[dict[str, Any]], + filtered_rows: list[dict[str, Any]], + importable_rows: list[dict[str, Any]], + ) -> DetailSectionSnapshot: + """ + 构建代码生成页总览判断共享分区。 + + :param gen_rows: 全量业务表行列表 + :param filtered_rows: 当前筛选后的业务表行列表 + :param importable_rows: 可导入物理表行列表 + :return: 分区快照 + """ + incomplete_count = sum( + 1 + for row in gen_rows + if not str(row.get('className', '') or '').strip() + or not str(row.get('moduleName', '') or '').strip() + or not str(row.get('businessName', '') or '').strip() + ) + status = 'ok' + conclusion = '当前生成配置可继续下钻,优先查看预检查、代码预览和导出预演' + if incomplete_count > 0: + status = 'warn' + conclusion = '存在生成配置不完整的业务表,建议先处理生成前校验风险' + elif not gen_rows and importable_rows: + status = 'info' + conclusion = '当前还没有业务表配置,可先从可导入物理表中选择目标' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'已加载业务表: {len(gen_rows)} 张', + f'当前匹配: {len(filtered_rows)} 张', + f'可导入物理表: {len(importable_rows)} 张', + f'配置待补全: {incomplete_count} 张', + '', + '## 建议入口', + '优先关注:生成前校验 / 同步预检查 / 代码预览 / 导出预览 / 导入入口 / 建表入口', + ], + ) + + +class GenRecordBuilder: + """ + 代码生成浏览记录构建器。 + + 该对象负责构建代码生成页单条业务表浏览记录与失败兜底记录。 + + :param page_adapter: 代码生成浏览页适配器 + :param section_builder: 代码生成浏览页分区构建器 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + section_builder: GenSectionBuilder, + ) -> None: + """ + 初始化代码生成浏览记录构建器。 + + :param page_adapter: 代码生成浏览页适配器 + :param section_builder: 代码生成浏览页分区构建器 + :return: None + """ + self.page_adapter = page_adapter + self.section_builder = section_builder + + def build_gen_record(self, table_row: dict[str, Any], env: str) -> BrowserRecordSnapshot: + """ + 构建单条业务表浏览记录。 + + :param table_row: 业务表列表行数据 + :param env: 当前运行环境 + :return: 浏览记录快照 + """ + table_id = table_row.get('tableId', '-') + table_name = str(table_row.get('tableName', '-') or '-') + class_name = str(table_row.get('className', '-') or '-') + module_name = str(table_row.get('moduleName', '-') or '-') + + return BrowserRecordSnapshot( + key=f'gen:{table_id}', + title=SHELL_TEXT_FORMATTER.truncate_text(table_name, 40), + status='ok', + summary=f'生成类 {SHELL_TEXT_FORMATTER.truncate_text(class_name, 24)} · 模块 {SHELL_TEXT_FORMATTER.truncate_text(module_name, 20)}', + metadata_lines=[ + '## 表身份', + f'表 ID: {table_id}', + '', + '## 生成信息', + f'生成类名: {SHELL_TEXT_FORMATTER.truncate_text(class_name, 32)}', + f'所属模块: {SHELL_TEXT_FORMATTER.truncate_text(module_name, 24)}', + f'业务标识: {SHELL_TEXT_FORMATTER.truncate_text(table_row.get("businessName", "-"), 24)}', + ], + detail_sections=[], + detail_loader=lambda table_row=table_row, env=env: self.section_builder.load_gen_detail_sections( + table_row, env + ), + ) + + def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot: + """ + 构建代码生成页失败兜底记录。 + + :param payload: 失败结果负载 + :return: 浏览记录快照 + """ + return self.page_adapter.build_failure_record( + key='gen:unavailable', + subject='代码生成', + section_subject='业务表列表', + payload=payload, + ) + + +class GenBrowserAdapter(BaseBrowserAdapter): + """ + 代码生成浏览页适配器。 + + 该适配器负责采集业务表列表、详情、预览与可导入表清单,并委托 + 协作对象完成共享分区、详情分区和记录构建。 + """ + + def __init__( + self, + rendering: GenRenderingSupport | None = None, + section_builder: GenSectionBuilder | None = None, + record_builder: GenRecordBuilder | None = None, + ) -> None: + """ + 初始化代码生成浏览页适配器。 + + :param rendering: 代码生成浏览页渲染支持对象 + :param section_builder: 代码生成浏览页分区构建器 + :param record_builder: 代码生成浏览记录构建器 + :return: None + """ + super().__init__( + page_title='代码生成', + search_view_key='gen', + filter_options=(), + ) + self.rendering = rendering or GenRenderingSupport() + self.section_builder = section_builder or GenSectionBuilder(self, self.rendering) + self.record_builder = record_builder or GenRecordBuilder(self, self.section_builder) + + @staticmethod + def apply_gen_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]: + """ + 按业务表名、生成类名或模块名查询词过滤业务表行数据。 + + :param rows: 原始业务表行列表 + :param query: 当前搜索词 + :return: 过滤后的业务表行列表 + """ + normalized_query = str(query).strip().lower() + if not normalized_query: + return rows + return [ + row + for row in rows + if normalized_query in str(row.get('tableName', '') or '').strip().lower() + or normalized_query in str(row.get('className', '') or '').strip().lower() + or normalized_query in str(row.get('moduleName', '') or '').strip().lower() + ] + + def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot: + """ + 采集代码生成浏览页只读快照。 + + :param env: 当前运行环境 + :param query: 当前搜索词 + :return: 浏览页快照 + """ + search_context = self.resolve_search_context(query) + gen_tables_payload = NESTED_CLI_SUPPORT.run( + 'gen', + 'list', + f'--env={env}', + '--paged', + '--page-size=8', + '--output=json', + parse_json=True, + ).payload + db_tables_payload = NESTED_CLI_SUPPORT.run( + 'gen', + 'db-list', + f'--env={env}', + '--paged', + '--page-size=8', + '--output=json', + parse_json=True, + ).payload + importable_rows = self.extract_page_rows(db_tables_payload) + + if not isinstance(gen_tables_payload, dict) or not gen_tables_payload.get('ok', False): + return BrowserPageSnapshot( + title='代码生成', + subtitle=TUI_COPY.build_unavailable_subtitle( + '业务表', + SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(gen_tables_payload), 72), + ), + records=[self.record_builder.build_failure_record(gen_tables_payload)], + shared_sections=[ + self.section_builder.build_gen_overview_section([], [], importable_rows), + self.section_builder.build_importable_tables_section(db_tables_payload), + self.section_builder.build_gen_import_entry_section(importable_rows), + self.section_builder.build_gen_create_entry_section(), + ], + search=search_context, + ) + + rows = self.extract_page_rows(gen_tables_payload) + filtered_rows = self.apply_gen_query(rows, query) + records = [self.record_builder.build_gen_record(table_row, env) for table_row in filtered_rows[:8]] + if not records: + records = [ + self.build_empty_record( + key='gen:none', + subject='业务表', + empty_label='业务表', + has_source_rows=bool(rows), + filtered_summary='当前搜索条件下没有匹配业务表', + empty_summary='当前环境中还没有纳入代码生成的业务表', + filtered_empty_value='暂无匹配', + empty_empty_value='暂无配置', + filtered_detail='当前搜索条件下没有匹配业务表', + empty_detail='当前环境中尚未配置代码生成业务表', + ) + ] + + return BrowserPageSnapshot( + title='代码生成', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_gen_diagnostic_subtitle( + len(filtered_rows), + len(importable_rows), + ), + records=records, + shared_sections=[ + self.section_builder.build_gen_overview_section(rows, filtered_rows, importable_rows), + self.section_builder.build_importable_tables_section(db_tables_payload), + self.section_builder.build_gen_import_entry_section(importable_rows), + self.section_builder.build_gen_create_entry_section(), + ], + search=search_context, + ) + + +GEN_BROWSER_ADAPTER = GenBrowserAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/health.py b/shierfeng-fastapi-backend/cli/tui/adapters/health.py new file mode 100644 index 0000000..8aefde4 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/health.py @@ -0,0 +1,988 @@ +from dataclasses import dataclass, field +from typing import Any + +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.tui.keymaps import TUI_KEYMAP_REGISTRY +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + +DEPENDENCY_CHECK_TOTAL = 3 +DASHBOARD_PANEL_MAX_LINES = 8 +DASHBOARD_PANEL_LINE_MAX_LENGTH = 38 +DASHBOARD_PANEL_MORE_HINT = TUI_COPY.build_more_detail_hint() + + +@dataclass(frozen=True) +class DashboardPanelSnapshot: + """ + TUI 首页单个面板快照。 + + :param title: 面板标题 + :param status: 面板状态 + :param lines: 面板正文文本行 + """ + + title: str + status: str + lines: list[str] + + +@dataclass(frozen=True) +class DashboardMetricSnapshot: + """ + TUI 首页单个指标卡快照。 + + :param title: 指标标题 + :param value: 指标主值 + :param status: 指标状态 + :param hint: 指标补充说明 + """ + + title: str + value: str + status: str + hint: str + + +@dataclass(frozen=True) +class DashboardSnapshot: + """ + TUI 首页聚合快照。 + + :param env: 当前运行环境 + :param panels: 面板列表 + """ + + env: str + panels: list[DashboardPanelSnapshot] + metrics: list[DashboardMetricSnapshot] = field(default_factory=list) + + +@dataclass(frozen=True) +class DashboardSourcePayloads: + """ + TUI 首页聚合数据源快照。 + + :param app_env_payload: 应用环境结果 + :param app_routes_payload: 应用路由结果 + :param doctor_payload: 应用检查结果 + :param database_payload: 数据库版本结果 + :param cache_payload: 缓存统计结果 + :param deps_payload: 依赖检查结果 + :param server_payload: 服务器摘要结果 + """ + + app_env_payload: dict[str, Any] | None + app_routes_payload: dict[str, Any] | None + doctor_payload: dict[str, Any] | None + database_payload: dict[str, Any] | None + cache_payload: dict[str, Any] | None + deps_payload: dict[str, Any] | None + server_payload: dict[str, Any] | None + + +class DashboardSnapshotCollector: + """ + TUI 首页聚合数据采集器。 + + 该对象负责从多路 CLI 命令收集 dashboard 所需原始结果, + 让 `DashboardAdapter` 保持首页装配职责,而不继续承载所有采集细节。 + """ + + def collect(self, env: str) -> DashboardSourcePayloads: + """ + 采集首页聚合所需的多路原始结果。 + + :param env: 当前运行环境 + :return: 首页聚合数据源快照 + """ + return DashboardSourcePayloads( + app_env_payload=NESTED_CLI_SUPPORT.run( + 'app', + 'env', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + app_routes_payload=NESTED_CLI_SUPPORT.run( + 'app', + 'routes', + f'--env={env}', + '--group-by=tag', + '--output=json', + parse_json=True, + ).payload, + doctor_payload=NESTED_CLI_SUPPORT.run( + 'app', + 'doctor', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + database_payload=NESTED_CLI_SUPPORT.run( + 'db', + 'current', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + cache_payload=NESTED_CLI_SUPPORT.run( + 'cache', + 'stats', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + deps_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'deps', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + server_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'server-info', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + ) + + +class DashboardFormattingSupport: + """ + 首页仪表盘格式化支持对象。 + + 该对象负责统一处理摘要消息提取、状态优先级、布尔文本、信号条和 + 面板空态/失败态文案,供面板与指标构建对象共享。 + """ + + @staticmethod + def truncate_dashboard_line(line: str) -> str: + """ + 截断首页面板单行文本,并保留结构化前缀。 + + :param line: 原始文本行 + :return: 截断后的文本行 + """ + stripped = str(line).strip() + if not stripped: + return '' + if stripped.startswith('## '): + return f'## {SHELL_TEXT_FORMATTER.truncate_text(stripped[3:].strip(), DASHBOARD_PANEL_LINE_MAX_LENGTH - 3)}' + if stripped.startswith('> '): + return f'> {SHELL_TEXT_FORMATTER.truncate_text(stripped[2:].strip(), DASHBOARD_PANEL_LINE_MAX_LENGTH - 2)}' + return SHELL_TEXT_FORMATTER.truncate_text(stripped, DASHBOARD_PANEL_LINE_MAX_LENGTH) + + @staticmethod + def resolve_status_priority(status: str) -> int: + """ + 解析状态优先级,值越小越需要优先关注。 + + :param status: 状态文本 + :return: 优先级 + """ + return { + 'fail': 0, + 'warn': 1, + 'ok': 2, + 'info': 3, + }.get(str(status).strip().lower(), 9) + + @staticmethod + def render_bool_flag(value: object) -> str: + """ + 将布尔语义值标准化为文本。 + + :param value: 原始值 + :return: 规范化后的文本 + """ + return '是' if bool(value) else '否' + + @staticmethod + def render_signal_bar( + passed: int, + total: int, + *, + width: int = 8, + on: str = '#', + off: str = '-', + ) -> str: + """ + 将通过数量渲染为 ASCII 信号条。 + + :param passed: 已通过数量 + :param total: 总数量 + :param width: 信号条宽度 + :param on: 点亮字符 + :param off: 熄灭字符 + :return: ASCII 信号条 + """ + if total <= 0: + return f'[{off * width}]' + safe_passed = max(0, min(passed, total)) + filled = round((safe_passed / total) * width) + return f'[{on * filled}{off * max(0, width - filled)}]' + + @staticmethod + def extract_payload_message(payload: dict[str, Any] | None) -> str: + """ + 提取结果负载中的摘要消息。 + + :param payload: 标准结果负载 + :return: 摘要文本 + """ + if not isinstance(payload, dict): + return '-' + if payload.get('message'): + return str(payload.get('message')) + if payload.get('error'): + return SHELL_TEXT_FORMATTER.truncate_text(payload.get('error'), 120) + return '-' + + def build_failure_lines( + self, + payload: dict[str, Any] | None, + *, + summary_label: str, + summary_value: str, + suggestion: str = TUI_COPY.build_dashboard_failure_suggestion(), + ) -> list[str]: + """ + 构建首页面板统一失败态摘要。 + + :param payload: 标准结果负载 + :param summary_label: 主字段名称 + :param summary_value: 主字段值 + :param suggestion: 建议操作 + :return: 面板正文 + """ + return [ + '## 当前状态', + f'{summary_label}: {summary_value}', + '', + '## 错误摘要', + f'> {self.extract_payload_message(payload)}', + '', + '## 建议操作', + suggestion, + ] + + @staticmethod + def build_empty_lines( + *, + summary_label: str, + summary_value: str, + detail: str, + suggestion: str = TUI_COPY.build_dashboard_empty_suggestion(), + ) -> list[str]: + """ + 构建首页面板统一空态摘要。 + + :param summary_label: 主字段名称 + :param summary_value: 主字段值 + :param detail: 空态说明 + :param suggestion: 建议操作 + :return: 面板正文 + """ + return [ + '## 当前状态', + f'{summary_label}: {summary_value}', + '', + '## 说明', + detail, + '', + '## 建议操作', + suggestion, + ] + + +class DashboardPanelCompressor: + """ + 首页面板压缩器。 + + 该对象负责将原始多行面板压缩为适合首页展示的统一密度摘要卡。 + + :param formatting: 首页仪表盘格式化支持对象 + """ + + def __init__(self, formatting: DashboardFormattingSupport) -> None: + """ + 初始化首页面板压缩器。 + + :param formatting: 首页仪表盘格式化支持对象 + :return: None + """ + self.formatting = formatting + + def compact_panel_lines(self, lines: list[str]) -> list[str]: + """ + 将首页面板正文压缩为统一长度的摘要卡内容。 + + :param lines: 原始正文文本行 + :return: 压缩后的摘要行列表 + """ + compact_lines: list[str] = [] + for raw_line in lines: + compact_line = self.formatting.truncate_dashboard_line(raw_line) + if not compact_line: + if compact_lines and compact_lines[-1] != '': + compact_lines.append('') + continue + compact_lines.append(compact_line) + + while compact_lines and compact_lines[0] == '': + compact_lines.pop(0) + while compact_lines and compact_lines[-1] == '': + compact_lines.pop() + + if len(compact_lines) <= DASHBOARD_PANEL_MAX_LINES: + return compact_lines + + summarized_lines = compact_lines[: DASHBOARD_PANEL_MAX_LINES - 1] + while summarized_lines and summarized_lines[-1] == '': + summarized_lines.pop() + summarized_lines.append(DASHBOARD_PANEL_MORE_HINT) + return summarized_lines + + def compact_panels(self, panels: list[DashboardPanelSnapshot]) -> list[DashboardPanelSnapshot]: + """ + 将首页所有面板压缩为统一密度的摘要卡。 + + :param panels: 原始面板列表 + :return: 压缩后的面板列表 + """ + return [ + DashboardPanelSnapshot( + title=panel.title, + status=panel.status, + lines=self.compact_panel_lines(panel.lines), + ) + for panel in panels + ] + + +class DashboardPanelBuilder: + """ + 首页面板构建器。 + + 该对象负责根据各个 CLI 结果负载构建首页展示面板,并统一风险热区与 + 建议入口逻辑。 + + :param formatting: 首页仪表盘格式化支持对象 + """ + + def __init__(self, formatting: DashboardFormattingSupport) -> None: + """ + 初始化首页面板构建器。 + + :param formatting: 首页仪表盘格式化支持对象 + :return: None + """ + self.formatting = formatting + + def build_app_env_panel( + self, + env: str, + env_payload: dict[str, Any] | None, + routes_payload: dict[str, Any] | None, + ) -> DashboardPanelSnapshot: + """ + 构建应用环境总览面板。 + + :param env: 当前运行环境 + :param env_payload: `app env` JSON 负载 + :param routes_payload: `app routes` JSON 负载 + :return: 面板快照 + """ + runtime = env_payload.get('runtime') if isinstance(env_payload, dict) else None + route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0 + grouped_routes = routes_payload.get('groupedRoutes') if isinstance(routes_payload, dict) else None + group_count = len(grouped_routes) if isinstance(grouped_routes, dict) else 0 + if not isinstance(runtime, dict): + return DashboardPanelSnapshot( + title='应用摘要', + status='fail', + lines=self.formatting.build_failure_lines( + env_payload, + summary_label='环境', + summary_value=env, + suggestion=TUI_COPY.build_dashboard_page_suggestion('应用', '检查环境解析结果'), + ), + ) + return DashboardPanelSnapshot( + title='应用摘要', + status='ok' if env_payload.get('ok', False) else 'fail', + lines=[ + '## 环境映射', + f'当前环境: {env} | CLI 目标环境: {runtime.get("cliEnv", "-")}', + f'配置文件环境: {runtime.get("configEnv", "-")}', + '## 路由摘要', + f'注册路由: {route_count} 条 | 标签分组: {group_count} 个', + '## 环境文件', + f'已加载环境文件: {runtime.get("envFile", "-")}', + f'环境文件存在: {self.formatting.render_bool_flag(runtime.get("envFileExists", False))}', + ], + ) + + def build_health_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot: + """ + 构建健康检查面板。 + + :param payload: `app doctor` JSON 负载 + :return: 面板快照 + """ + if not isinstance(payload, dict): + return DashboardPanelSnapshot( + title='系统摘要', + status='fail', + lines=self.formatting.build_failure_lines( + payload, + summary_label='巡检结果', + summary_value='不可用', + suggestion=TUI_COPY.build_dashboard_page_suggestion('运维/健康检查', '查看原因'), + ), + ) + + database = payload.get('database') if isinstance(payload.get('database'), dict) else {} + redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {} + crypto = payload.get('crypto') if isinstance(payload.get('crypto'), dict) else {} + return DashboardPanelSnapshot( + title='系统摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 总体态势', + f'整体结果: {"通过" if payload.get("ok", False) else "异常"}', + '', + '## 依赖检查', + f'数据库: {"正常" if database.get("ok", False) else "异常"}', + f'> {self.formatting.extract_payload_message(database)}', + f'Redis: {"正常" if redis.get("ok", False) else "异常"}', + f'> {self.formatting.extract_payload_message(redis)}', + f'加密组件: {"正常" if crypto.get("ok", False) else "异常"}', + f'> {self.formatting.extract_payload_message(crypto)}', + ], + ) + + def build_recommended_entry_panel( + self, + doctor_payload: dict[str, Any] | None, + database_payload: dict[str, Any] | None, + cache_payload: dict[str, Any] | None, + ) -> DashboardPanelSnapshot: + """ + 构建首页建议入口面板。 + + :param doctor_payload: 健康检查负载 + :param database_payload: 数据库状态负载 + :param cache_payload: 缓存状态负载 + :return: 面板快照 + """ + database_ok = ( + bool(doctor_payload.get('database', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False + ) + redis_ok = bool(doctor_payload.get('redis', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False + crypto_ok = ( + bool(doctor_payload.get('crypto', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False + ) + db_revision = str(database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-') + cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-' + + if not database_ok: + status = 'fail' + lines = [ + '## 首选入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("database").upper()}] 数据库', + f'> {TUI_DIAGNOSTIC_SERVICE.build_database_focus_hint()}', + '', + '## 次级入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维', + f'> {TUI_DIAGNOSTIC_SERVICE.build_ops_focus_hint()}', + '', + '## 当前判断', + f'迁移版本: {db_revision}', + '> 数据库异常会优先阻断后续业务巡检', + ] + elif not redis_ok: + status = 'fail' + lines = [ + '## 首选入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("cache").upper()}] 缓存', + f'> {TUI_DIAGNOSTIC_SERVICE.build_cache_focus_hint()}', + '', + '## 次级入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维', + f'> {TUI_DIAGNOSTIC_SERVICE.build_ops_focus_hint()}', + '', + '## 当前判断', + f'Redis 键数: {cache_size}', + '> 缓存异常通常会影响配置读取和任务运行', + ] + elif not crypto_ok: + status = 'warn' + lines = [ + '## 首选入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("crypto").upper()}] 加密', + '> 先检查运行校验、公钥身份和兼容版本', + '', + '## 次级入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维', + '> 对照依赖版本,确认是否缺少加密相关运行依赖', + '', + '## 当前判断', + '> 加密异常不会立即阻断全部能力,但会影响安全链路', + ] + else: + status = 'ok' + lines = [ + '## 首选入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("jobs").upper()}] 任务 · {TUI_DIAGNOSTIC_SERVICE.build_jobs_focus_hint()}', + '## 次级入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("configs").upper()}] 参数配置 · {TUI_DIAGNOSTIC_SERVICE.build_configs_focus_hint()}', + '## 扩展入口', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("gen").upper()}] 代码生成 · {TUI_DIAGNOSTIC_SERVICE.build_gen_focus_hint()}', + f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("app").upper()}] 应用 · 配置摘要 / 路由状态', + ] + + return DashboardPanelSnapshot( + title='建议摘要', + status=status, + lines=lines, + ) + + def build_inspection_conclusion_panel( + self, + doctor_payload: dict[str, Any] | None, + database_payload: dict[str, Any] | None, + cache_payload: dict[str, Any] | None, + ) -> DashboardPanelSnapshot: + """ + 构建首页巡检结论面板。 + + :param doctor_payload: 健康检查负载 + :param database_payload: 数据库状态负载 + :param cache_payload: 缓存状态负载 + :return: 面板快照 + """ + database_ok = ( + bool(doctor_payload.get('database', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False + ) + redis_ok = bool(doctor_payload.get('redis', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False + crypto_ok = ( + bool(doctor_payload.get('crypto', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False + ) + db_revision = database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-' + cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-' + + if not database_ok: + status = 'fail' + conclusion = '数据库存在异常,建议优先处理连接或迁移问题' + next_step = '优先进入数据库页面,确认迁移版本和连接配置' + elif not redis_ok: + status = 'fail' + conclusion = '缓存服务存在异常,建议优先检查 Redis 可用性' + next_step = '优先进入缓存页面,确认连接数、键数量和状态说明' + elif not crypto_ok: + status = 'warn' + conclusion = '加密组件状态异常,建议尽快核对运行依赖' + next_step = '先查看系统摘要面板,再排查加密组件依赖' + else: + status = 'ok' + conclusion = '当前核心依赖状态正常,可继续查看业务分区' + next_step = '优先查看任务与参数配置页面,确认业务侧是否有风险' + + return DashboardPanelSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 建议操作', + next_step, + '', + '## 当前快照', + f'迁移版本: {db_revision}', + f'Redis 键数: {cache_size}', + ], + ) + + def build_database_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot: + """ + 构建数据库状态面板。 + + :param payload: `db current` JSON 负载 + :return: 面板快照 + """ + current_revision = payload.get('currentRevision', '-') if isinstance(payload, dict) else '-' + if not isinstance(payload, dict) or not payload.get('ok', False): + return DashboardPanelSnapshot( + title='数据库摘要', + status='fail', + lines=self.formatting.build_failure_lines( + payload, + summary_label='迁移版本', + summary_value=str(current_revision or '-'), + suggestion=TUI_COPY.build_dashboard_page_suggestion('数据库', '确认迁移版本与连接状态'), + ), + ) + return DashboardPanelSnapshot( + title='数据库摘要', + status='ok', + lines=[ + '## 迁移信息', + f'迁移版本: {current_revision}', + '', + '## 状态说明', + f'> {self.formatting.extract_payload_message(payload)}', + ], + ) + + def build_cache_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot: + """ + 构建缓存状态面板。 + + :param payload: `cache stats` JSON 负载 + :return: 面板快照 + """ + if not isinstance(payload, dict): + return DashboardPanelSnapshot( + title='缓存摘要', + status='fail', + lines=self.formatting.build_failure_lines( + payload, + summary_label='缓存状态', + summary_value='不可用', + suggestion=TUI_COPY.build_dashboard_page_suggestion('缓存', '查看 Redis 状态'), + ), + ) + info = payload.get('info') if isinstance(payload.get('info'), dict) else {} + cache_names = payload.get('cacheNames') if isinstance(payload.get('cacheNames'), list) else [] + return DashboardPanelSnapshot( + title='缓存摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## Redis 概况', + f'当前键数: {payload.get("dbSize", "-")}', + f'Redis 版本: {info.get("redis_version", "-")}', + f'客户端连接数: {info.get("connected_clients", "-")}', + '', + '## 缓存资源', + f'已登记缓存名: {len(cache_names)} 个', + ], + ) + + def build_dependency_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot: + """ + 构建首页依赖版本摘要面板。 + + :param payload: `ops deps` JSON 负载 + :return: 面板快照 + """ + if not isinstance(payload, dict): + return DashboardPanelSnapshot( + title='依赖版本', + status='fail', + lines=self.formatting.build_failure_lines( + payload, + summary_label='依赖检查', + summary_value='不可用', + suggestion=TUI_COPY.build_dashboard_page_suggestion('运维', '检查依赖详情'), + ), + ) + packages = payload.get('packages') if isinstance(payload.get('packages'), dict) else {} + missing_required = payload.get('missingRequired') if isinstance(payload.get('missingRequired'), list) else [] + lines = [ + '## 依赖结论', + f'检查结果: {"通过" if payload.get("ok", False) else "异常"}', + f'缺失核心依赖: {len(missing_required)} 个', + '', + '## 关键版本', + ] + for package_name in ('python', 'fastapi', 'sqlalchemy', 'redis'): + package_payload = packages.get(package_name) + if not isinstance(package_payload, dict): + continue + installed = '已安装' if package_payload.get('installed', False) else '缺失' + lines.append(f'{package_name}: {installed} · {package_payload.get("version", "-") or "-"}') + return DashboardPanelSnapshot( + title='依赖版本', + status='ok' if payload.get('ok', False) else 'fail', + lines=lines, + ) + + def build_server_info_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot: + """ + 构建首页服务器概况面板。 + + :param payload: `ops server-info` JSON 负载 + :return: 面板快照 + """ + server = payload.get('server') if isinstance(payload, dict) else None + if not isinstance(server, dict): + return DashboardPanelSnapshot( + title='服务器摘要', + status='fail', + lines=self.formatting.build_failure_lines( + payload, + summary_label='服务器摘要', + summary_value='不可用', + suggestion=TUI_COPY.build_dashboard_page_suggestion('运维', '重新采集服务器摘要'), + ), + ) + sys_info = server.get('sys') if isinstance(server.get('sys'), dict) else {} + cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {} + mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {} + return DashboardPanelSnapshot( + title='服务器摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 主机状态', + f'主机名: {sys_info.get("computerName", "-")}', + f'主机 IP: {sys_info.get("computerIp", "-")}', + '', + '## 资源概况', + f'CPU 使用率: {cpu_info.get("used", "-")}%', + f'内存使用率: {mem_info.get("usage", "-")}%', + ], + ) + + def build_risk_heatmap_panel(self, panels: list[DashboardPanelSnapshot]) -> DashboardPanelSnapshot: + """ + 构建首页风险热区面板。 + + :param panels: 当前已构建的面板列表 + :return: 风险热区面板 + """ + sorted_panels = sorted( + panels, key=lambda item: (self.formatting.resolve_status_priority(item.status), item.title) + ) + fail_panels = [panel for panel in sorted_panels if panel.status == 'fail'] + warn_panels = [panel for panel in sorted_panels if panel.status == 'warn'] + primary_targets = fail_panels[:3] or warn_panels[:3] or sorted_panels[:3] + + lines = [ + '## 热区摘要', + f'失败面板: {len(fail_panels)} 个', + f'警告面板: {len(warn_panels)} 个', + '', + '## 优先关注', + ] + if not primary_targets: + lines.extend( + self.formatting.build_empty_lines( + summary_label='风险热区', + summary_value='0 个', + detail='当前没有需要优先处理的失败或警告面板', + suggestion='可继续浏览任务、缓存、参数配置等业务页面', + ) + ) + else: + for index, panel in enumerate(primary_targets, start=1): + lead_line = SHELL_TEXT_FORMATTER.truncate_text( + panel.lines[1] if len(panel.lines) > 1 else panel.lines[0], 56 + ) + lines.extend( + [ + f'> HOT-{index:02d} [{panel.status.upper()}] {panel.title}', + f'> {lead_line}', + ] + ) + + status = 'fail' if fail_panels else 'warn' if warn_panels else 'ok' + return DashboardPanelSnapshot( + title='风险摘要', + status=status, + lines=lines, + ) + + def build_panels( + self, + *, + env: str, + app_env_payload: dict[str, Any] | None, + app_routes_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + database_payload: dict[str, Any] | None, + cache_payload: dict[str, Any] | None, + deps_payload: dict[str, Any] | None, + server_payload: dict[str, Any] | None, + ) -> list[DashboardPanelSnapshot]: + """ + 构建首页全部原始面板。 + + :param env: 当前运行环境 + :param app_env_payload: 应用环境负载 + :param app_routes_payload: 应用路由负载 + :param doctor_payload: 健康检查负载 + :param database_payload: 数据库状态负载 + :param cache_payload: 缓存状态负载 + :param deps_payload: 运维依赖负载 + :param server_payload: 服务器摘要负载 + :return: 原始面板列表 + """ + panels = [ + self.build_app_env_panel(env, app_env_payload, app_routes_payload), + self.build_health_panel(doctor_payload), + self.build_inspection_conclusion_panel(doctor_payload, database_payload, cache_payload), + self.build_recommended_entry_panel(doctor_payload, database_payload, cache_payload), + self.build_database_panel(database_payload), + self.build_cache_panel(cache_payload), + self.build_dependency_panel(deps_payload), + self.build_server_info_panel(server_payload), + ] + panels.append(self.build_risk_heatmap_panel(panels)) + return panels + + +class DashboardMetricBuilder: + """ + 首页指标卡构建器。 + + 该对象负责根据健康检查、数据库和缓存状态构建首页驾驶舱指标卡。 + + :param formatting: 首页仪表盘格式化支持对象 + """ + + def __init__(self, formatting: DashboardFormattingSupport) -> None: + """ + 初始化首页指标卡构建器。 + + :param formatting: 首页仪表盘格式化支持对象 + :return: None + """ + self.formatting = formatting + + def build_metrics( + self, + doctor_payload: dict[str, Any] | None, + database_payload: dict[str, Any] | None, + cache_payload: dict[str, Any] | None, + ) -> list[DashboardMetricSnapshot]: + """ + 构建首页驾驶舱指标卡。 + + :param doctor_payload: 健康检查负载 + :param database_payload: 数据库状态负载 + :param cache_payload: 缓存状态负载 + :return: 指标卡列表 + """ + database = doctor_payload.get('database') if isinstance(doctor_payload, dict) else None + redis = doctor_payload.get('redis') if isinstance(doctor_payload, dict) else None + crypto = doctor_payload.get('crypto') if isinstance(doctor_payload, dict) else None + + dependency_results = [ + bool(database.get('ok', False)) if isinstance(database, dict) else False, + bool(redis.get('ok', False)) if isinstance(redis, dict) else False, + bool(crypto.get('ok', False)) if isinstance(crypto, dict) else False, + ] + passed_dependencies = sum(1 for item in dependency_results if item) + posture = '风险' if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False) else '稳定' + posture_status = 'fail' if posture == '风险' else 'ok' + current_revision = database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-' + cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-' + + metrics = [ + DashboardMetricSnapshot( + title='当前态势', + value=f'{self.formatting.render_signal_bar(passed_dependencies, DEPENDENCY_CHECK_TOTAL)} {posture}', + status=posture_status, + hint=f'依赖通过 {passed_dependencies}/{DEPENDENCY_CHECK_TOTAL},健康检查自动判定', + ), + DashboardMetricSnapshot( + title='依赖通过率', + value=( + f'{self.formatting.render_signal_bar(passed_dependencies, DEPENDENCY_CHECK_TOTAL)} ' + f'{passed_dependencies}/{DEPENDENCY_CHECK_TOTAL}' + ), + status=( + 'ok' + if passed_dependencies == DEPENDENCY_CHECK_TOTAL + else 'warn' + if passed_dependencies > 0 + else 'fail' + ), + hint='数据库、Redis、加密组件', + ), + DashboardMetricSnapshot( + title='迁移版本', + value=f'[{SHELL_TEXT_FORMATTER.truncate_text(current_revision, 16)}]', + status='ok' if current_revision != '-' else 'warn', + hint='当前数据库 revision 基线', + ), + DashboardMetricSnapshot( + title='Redis 键数', + value=f'[KEYS] {cache_size}', + status='info', + hint='当前缓存库 key 数量观测值', + ), + ] + return sorted( + metrics, + key=lambda item: self.formatting.resolve_status_priority(item.status), + ) + + +class DashboardAdapter: + """ + TUI 首页巡检聚合适配器。 + + 该适配器负责采集应用、健康检查、数据库、缓存与运维只读快照, + 并委托面板构建器、指标构建器和压缩器组装首页结果。 + """ + + def __init__( + self, + formatting: DashboardFormattingSupport | None = None, + panel_builder: DashboardPanelBuilder | None = None, + metric_builder: DashboardMetricBuilder | None = None, + panel_compressor: DashboardPanelCompressor | None = None, + snapshot_collector: DashboardSnapshotCollector | None = None, + ) -> None: + """ + 初始化首页巡检聚合适配器。 + + :param formatting: 首页仪表盘格式化支持对象 + :param panel_builder: 首页面板构建器 + :param metric_builder: 首页指标卡构建器 + :param panel_compressor: 首页面板压缩器 + :param snapshot_collector: 首页聚合数据采集器 + :return: None + """ + self.formatting = formatting or DashboardFormattingSupport() + self.panel_builder = panel_builder or DashboardPanelBuilder(self.formatting) + self.metric_builder = metric_builder or DashboardMetricBuilder(self.formatting) + self.panel_compressor = panel_compressor or DashboardPanelCompressor(self.formatting) + self.snapshot_collector = snapshot_collector or DashboardSnapshotCollector() + + def collect_snapshot(self, env: str) -> DashboardSnapshot: + """ + 采集 TUI 首页只读巡检快照。 + + :param env: 当前运行环境 + :return: 首页聚合快照 + """ + source_payloads = self.snapshot_collector.collect(env) + panels = self.panel_builder.build_panels( + env=env, + app_env_payload=source_payloads.app_env_payload, + app_routes_payload=source_payloads.app_routes_payload, + doctor_payload=source_payloads.doctor_payload, + database_payload=source_payloads.database_payload, + cache_payload=source_payloads.cache_payload, + deps_payload=source_payloads.deps_payload, + server_payload=source_payloads.server_payload, + ) + return DashboardSnapshot( + env=env, + metrics=self.metric_builder.build_metrics( + source_payloads.doctor_payload, + source_payloads.database_payload, + source_payloads.cache_payload, + ), + panels=self.panel_compressor.compact_panels(panels), + ) + + +DASHBOARD_ADAPTER = DashboardAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/jobs.py b/shierfeng-fastapi-backend/cli/tui/adapters/jobs.py new file mode 100644 index 0000000..d5b3e5b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/jobs.py @@ -0,0 +1,747 @@ +from typing import Any + +from cli.tui.adapters.base import BaseBrowserAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + BrowserPageSnapshot, + BrowserRecordSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.tui.search import JOB_FILTER_OPTIONS +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +class JobRenderingSupport: + """ + 任务浏览页渲染支持对象。 + + 该对象负责状态文本、时间线标记、成功率信号条和失败任务提取等基础 + 逻辑,供筛选、分区和记录构建复用。 + + :param page_adapter: 任务浏览页适配器 + """ + + def __init__(self, page_adapter: BaseBrowserAdapter) -> None: + """ + 初始化任务浏览页渲染支持对象。 + + :param page_adapter: 任务浏览页适配器 + :return: None + """ + self.page_adapter = page_adapter + + def extract_failed_job_names(self, payload: dict[str, Any] | None) -> set[str]: + """ + 从失败日志结果中提取任务名称集合。 + + :param payload: 失败日志负载 + :return: 失败任务名称集合 + """ + rows = self.page_adapter.extract_page_rows(payload) + failed_job_names: set[str] = set() + for row in rows: + job_name = str(row.get('jobName', '') or '').strip() + if job_name: + failed_job_names.add(job_name) + return failed_job_names + + @staticmethod + def render_job_status(value: object) -> str: + """ + 将任务状态码转换为可读文本。 + + :param value: 原始状态值 + :return: 中文状态文本 + """ + normalized = str(value).strip() + if normalized == '0': + return '正常' + if normalized == '1': + return '暂停' + return normalized or '-' + + @staticmethod + def render_job_log_status(value: object) -> str: + """ + 将任务日志状态码转换为可读文本。 + + :param value: 原始状态值 + :return: 中文状态文本 + """ + normalized = str(value).strip() + if normalized == '0': + return '成功' + if normalized == '1': + return '失败' + return normalized or '-' + + def render_job_log_timeline_title(self, row: dict[str, Any]) -> str: + """ + 渲染单条任务日志时间线标题。 + + :param row: 日志行数据 + :return: 时间线标题文本 + """ + created_at = SHELL_TEXT_FORMATTER.truncate_text(row.get('createTime', '-'), 24) + if created_at and created_at != '-': + return f'{created_at} · {self.render_job_log_status(row.get("status", "-"))}' + return f'日志 {row.get("jobLogId", "-")} · {self.render_job_log_status(row.get("status", "-"))}' + + @staticmethod + def render_timeline_marker(status: object) -> str: + """ + 根据日志状态渲染时间线节点标记。 + + :param status: 原始日志状态 + :return: 节点标记 + """ + return 'x' if str(status).strip() == '1' else 'o' + + @staticmethod + def render_signal_bar(passed: int, total: int, *, width: int = 8) -> str: + """ + 渲染 ASCII 成功率条。 + + :param passed: 成功数量 + :param total: 总数量 + :param width: 条形宽度 + :return: ASCII 条形 + """ + if total <= 0: + return f'[{"-" * width}]' + safe_passed = max(0, min(passed, total)) + filled = round((safe_passed / total) * width) + return f'[{"#" * filled}{"-" * max(0, width - filled)}]' + + @staticmethod + def extract_latest_log_time(rows: list[dict[str, Any]]) -> str: + """ + 提取日志列表中的最近时间。 + + :param rows: 日志行数据 + :return: 最近时间文本 + """ + if not rows: + return '-' + latest_time = str(rows[0].get('createTime', '-') or '-').strip() + return latest_time or '-' + + +class JobRowFilter: + """ + 任务浏览行过滤器。 + + 该对象负责按筛选键和搜索词过滤任务行数据。 + """ + + @staticmethod + def apply_job_filter( + rows: list[dict[str, Any]], + failed_job_names: set[str], + filter_key: str, + ) -> list[dict[str, Any]]: + """ + 按筛选键过滤任务行数据。 + + :param rows: 原始任务行列表 + :param failed_job_names: 最近失败任务名称集合 + :param filter_key: 当前筛选键 + :return: 过滤后的任务行列表 + """ + normalized_filter = str(filter_key).strip().lower() + if normalized_filter == 'failed': + return [row for row in rows if str(row.get('jobName', '') or '').strip() in failed_job_names] + if normalized_filter == 'paused': + return [row for row in rows if str(row.get('status', '')).strip() == '1'] + if normalized_filter == 'ok': + return [ + row + for row in rows + if str(row.get('jobName', '') or '').strip() not in failed_job_names + and str(row.get('status', '')).strip() != '1' + ] + return rows + + @staticmethod + def apply_job_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]: + """ + 按任务名称查询词过滤任务行数据。 + + :param rows: 原始任务行列表 + :param query: 当前搜索词 + :return: 过滤后的任务行列表 + """ + normalized_query = str(query).strip().lower() + if not normalized_query: + return rows + return [ + row + for row in rows + if normalized_query in str(row.get('jobName', '') or '').strip().lower() + or normalized_query in str(row.get('jobGroup', '') or '').strip().lower() + ] + + +class JobSectionBuilder: + """ + 任务浏览页分区构建器。 + + 该构建器负责构建任务页共享分区以及单条任务详情分区。 + + :param page_adapter: 任务浏览页适配器 + :param rendering: 任务浏览页渲染支持对象 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + rendering: JobRenderingSupport, + ) -> None: + """ + 初始化任务浏览页分区构建器。 + + :param page_adapter: 任务浏览页适配器 + :param rendering: 任务浏览页渲染支持对象 + :return: None + """ + self.page_adapter = page_adapter + self.rendering = rendering + + def build_job_failure_aggregate_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建任务失败聚合共享分区。 + + :param payload: 失败日志 JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title='失败聚合', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='失败聚合', empty_value='不可用' + ), + ) + + rows = self.page_adapter.extract_page_rows(payload) + if not rows: + return DetailSectionSnapshot( + title='失败聚合', + status='ok', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='失败日志', + empty_value='0 条', + detail='当前失败日志池为空,最近没有采集到失败执行记录', + ), + ) + + grouped_rows: dict[str, list[dict[str, Any]]] = {} + for row in rows: + job_name = str(row.get('jobName', '-') or '-').strip() or '-' + grouped_rows.setdefault(job_name, []).append(row) + + sorted_groups = sorted( + grouped_rows.items(), + key=lambda item: (-len(item[1]), self.rendering.extract_latest_log_time(item[1]), item[0]), + reverse=False, + ) + lines = [ + '## 失败池', + f'失败日志: {len(rows)} 条', + f'涉及任务: {len(grouped_rows)} 个', + f'最近失败: {self.rendering.extract_latest_log_time(rows)}', + '', + '## 高频失败任务', + ] + for index, (job_name, job_rows) in enumerate(sorted_groups[:6], start=1): + latest_row = job_rows[0] + latest_time = self.rendering.extract_latest_log_time(job_rows) + latest_message = SHELL_TEXT_FORMATTER.truncate_text(latest_row.get('jobMessage', '-'), 48) + lines.extend( + [ + f'[{index}] {SHELL_TEXT_FORMATTER.truncate_text(job_name, 28)} · {len(job_rows)} 次', + f'> 最近失败: {latest_time}', + f'> 最近结果: {latest_message}', + ] + ) + exception_info = SHELL_TEXT_FORMATTER.truncate_text(latest_row.get('exceptionInfo', ''), 48) + if exception_info: + lines.append(f'> 异常摘要: {exception_info}') + lines.append('') + + if lines[-1] == '': + lines.pop() + return DetailSectionSnapshot( + title='失败聚合', + status='warn', + lines=lines, + ) + + def build_jobs_overview_section( + self, + rows: list[dict[str, Any]], + filtered_rows: list[dict[str, Any]], + failed_job_names: set[str], + paused_count: int, + failed_logs_payload: dict[str, Any] | None, + filter_label: str, + ) -> DetailSectionSnapshot: + """ + 构建任务页总览判断共享分区。 + + :param rows: 原始任务行列表 + :param filtered_rows: 当前筛选后的任务行列表 + :param failed_job_names: 最近失败任务名称集合 + :param paused_count: 暂停任务数量 + :param failed_logs_payload: 失败日志负载 + :param filter_label: 当前筛选标签 + :return: 分区快照 + """ + failed_rows = self.page_adapter.extract_page_rows(failed_logs_payload) + latest_failure = self.rendering.extract_latest_log_time(failed_rows) + status = 'ok' + conclusion = '当前任务基线正常,可继续查看执行轨迹与最近执行记录' + if failed_job_names: + status = 'warn' + conclusion = '存在失败任务,优先查看失败聚合与失败执行记录' + elif paused_count > 0: + status = 'warn' + conclusion = '存在暂停任务,建议确认停用原因与恢复窗口' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'当前筛选: {filter_label}', + f'已加载任务: {len(rows)} 条', + f'当前匹配: {len(filtered_rows)} 条', + f'失败任务: {len(failed_job_names)} 个', + f'暂停任务: {paused_count} 个', + f'最近失败: {latest_failure}', + '', + '## 建议入口', + '优先关注:失败聚合 / 暂停任务 / 执行轨迹', + ], + ) + + def build_job_focus_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建任务概览分区。 + + :param payload: `job detail` JSON 负载 + :return: 分区快照 + """ + job = payload.get('job') if isinstance(payload, dict) else None + if not isinstance(job, dict): + return DetailSectionSnapshot( + title='任务摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='任务', empty_value='无'), + ) + return DetailSectionSnapshot( + title='任务摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 任务身份', + f'任务 ID: {job.get("jobId", "-")}', + f'任务名称: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobName", "-"), 40)}', + f'任务分组: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobGroup", "-"), 24)}', + f'运行状态: {self.rendering.render_job_status(job.get("status", "-"))}', + ], + ) + + @staticmethod + def build_job_schedule_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建任务调度配置分区。 + + :param payload: `job detail` JSON 负载 + :return: 分区快照 + """ + job = payload.get('job') if isinstance(payload, dict) else None + if not isinstance(job, dict): + return DetailSectionSnapshot( + title='调度配置', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='调度配置', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='调度配置', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 调度表达式', + f'Cron 表达式: {SHELL_TEXT_FORMATTER.truncate_text(job.get("cronExpression", "-"), 48)}', + '', + '## 执行入口', + f'调用目标: {SHELL_TEXT_FORMATTER.truncate_text(job.get("invokeTarget", "-"), 72)}', + ], + ) + + def build_job_logs_section(self, payload: dict[str, Any] | None, *, title: str) -> DetailSectionSnapshot: + """ + 构建任务日志分区。 + + :param payload: `job logs` JSON 负载 + :param title: 分区标题 + :return: 分区快照 + """ + if not isinstance(payload, dict) or not payload.get('ok', False): + return DetailSectionSnapshot( + title=title, + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='日志', empty_value='不可用'), + ) + rows = self.page_adapter.extract_page_rows(payload) + lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='日志记录', + empty_value='0 条', + detail='当前筛选条件下没有相关日志', + ) + section_status = 'ok' + if rows: + lines = [] + if any(str(row.get('status', '')).strip() == '1' for row in rows): + section_status = 'warn' + for row in rows[:8]: + exception_info = SHELL_TEXT_FORMATTER.truncate_text(row.get('exceptionInfo', '-'), 56) + timeline_marker = self.rendering.render_timeline_marker(row.get('status', '-')) + rendered_status = self.rendering.render_job_log_status(row.get('status', '-')) + lines.extend( + [ + f'## 节点 {row.get("jobLogId", "-")} · {self.rendering.render_job_log_timeline_title(row)}', + f'> {timeline_marker} 状态: {rendered_status}', + f'> 时间: {SHELL_TEXT_FORMATTER.truncate_text(row.get("createTime", "-"), 24)}', + f'> 任务: {SHELL_TEXT_FORMATTER.truncate_text(row.get("jobName", "-"), 24)}', + f'> 结果: {SHELL_TEXT_FORMATTER.truncate_text(row.get("jobMessage", "-"), 56)}', + *([f'> 异常: {exception_info}'] if exception_info and exception_info != '-' else []), + '> 轨道: ├─采集 ├─执行 └─落盘', + f'> 轨迹: {"●" if timeline_marker == "o" else "▲"}─{"●" if timeline_marker == "o" else "▲"}─◎', + '', + ] + ) + return DetailSectionSnapshot( + title=title, + status=section_status, + lines=lines[:-1] if len(lines) > 1 and lines[-1] == '' else lines, + ) + + def build_job_log_summary_section( + self, + recent_logs_payload: dict[str, Any] | None, + failed_logs_payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建任务日志摘要分区。 + + :param recent_logs_payload: 最近日志 JSON 负载 + :param failed_logs_payload: 失败日志 JSON 负载 + :return: 分区快照 + """ + if not isinstance(recent_logs_payload, dict) or not recent_logs_payload.get('ok', False): + return DetailSectionSnapshot( + title='执行摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + recent_logs_payload, empty_label='日志摘要', empty_value='不可用' + ), + ) + recent_rows = self.page_adapter.extract_page_rows(recent_logs_payload) + failed_rows = self.page_adapter.extract_page_rows(failed_logs_payload) + success_count = sum(1 for row in recent_rows if str(row.get('status', '')).strip() == '0') + failed_count = sum(1 for row in recent_rows if str(row.get('status', '')).strip() not in {'', '0'}) + status = 'warn' if failed_count > 0 or failed_rows else 'ok' + total_count = len(recent_rows) + latest_run = self.rendering.extract_latest_log_time(recent_rows) + latest_failure = self.rendering.extract_latest_log_time(failed_rows) + return DetailSectionSnapshot( + title='执行摘要', + status=status, + lines=[ + '## 执行统计', + f'最近采样日志: {total_count} 条', + f'执行成功: {success_count} 条', + f'执行失败: {failed_count} 条', + f'成功率信号: {self.rendering.render_signal_bar(success_count, total_count)} {success_count}/{total_count or 0}', + '', + '## 时间线锚点', + f'最近一次执行: {latest_run}', + f'最近一次失败: {latest_failure}', + '', + '## 轨道视图', + f'执行轨道: {"─".join("●" if str(row.get("status", "")).strip() == "0" else "▲" for row in recent_rows[:6]) or "-"}', + f'失败轨道: {"─".join("▲" for _ in failed_rows[:6]) or "-"}', + f'轨道窗口: {"[执行流]" if recent_rows else "-"} {"[失败流]" if failed_rows else ""}'.strip(), + '', + '## 风险信号', + f'失败记录池: {len(failed_rows)} 条', + ], + ) + + def load_job_detail_sections(self, job_row: dict[str, Any], env: str) -> list[DetailSectionSnapshot]: + """ + 按需加载单条任务详情与日志分区。 + + :param job_row: 任务列表行数据 + :param env: 当前运行环境 + :return: 详情分区列表 + """ + job_id = job_row.get('jobId', '-') + job_name = str(job_row.get('jobName', '-') or '-') + + detail_payload = NESTED_CLI_SUPPORT.run( + 'job', + 'detail', + str(job_id), + f'--env={env}', + '--output=json', + parse_json=True, + ).payload + + log_arguments = ['job', 'logs', f'--env={env}', '--paged', '--page-size=8', '--output=json'] + if job_name.strip() and job_name != '-': + log_arguments.append(f'--job-name={job_name}') + recent_logs_payload = NESTED_CLI_SUPPORT.run(*log_arguments, parse_json=True).payload + failed_logs_payload = NESTED_CLI_SUPPORT.run(*[*log_arguments, '--status=1'], parse_json=True).payload + + return [ + self.build_job_focus_section(detail_payload), + self.build_job_schedule_section(detail_payload), + self.build_job_log_summary_section(recent_logs_payload, failed_logs_payload), + self.build_job_logs_section(recent_logs_payload, title='最近执行记录'), + self.build_job_logs_section(failed_logs_payload, title='失败执行记录'), + ] + + +class JobRecordBuilder: + """ + 任务浏览记录构建器。 + + 该对象负责构建任务页单条浏览记录与失败兜底记录。 + + :param page_adapter: 任务浏览页适配器 + :param rendering: 任务浏览页渲染支持对象 + :param section_builder: 任务浏览页分区构建器 + """ + + def __init__( + self, + page_adapter: BaseBrowserAdapter, + rendering: JobRenderingSupport, + section_builder: JobSectionBuilder, + ) -> None: + """ + 初始化任务浏览记录构建器。 + + :param page_adapter: 任务浏览页适配器 + :param rendering: 任务浏览页渲染支持对象 + :param section_builder: 任务浏览页分区构建器 + :return: None + """ + self.page_adapter = page_adapter + self.rendering = rendering + self.section_builder = section_builder + + def build_job_record(self, job_row: dict[str, Any], env: str) -> BrowserRecordSnapshot: + """ + 构建单条任务浏览记录。 + + :param job_row: 任务列表行数据 + :param env: 当前运行环境 + :return: 浏览记录快照 + """ + job_id = job_row.get('jobId', '-') + job_name = str(job_row.get('jobName', '-') or '-') + job_status = str(job_row.get('status', '-') or '-') + job_group = str(job_row.get('jobGroup', '-') or '-') + cron_expression = str(job_row.get('cronExpression', '-') or '-') + rendered_status = self.rendering.render_job_status(job_status) + + return BrowserRecordSnapshot( + key=f'job:{job_id}', + title=SHELL_TEXT_FORMATTER.truncate_text(job_name, 40), + status='warn' if str(job_status).strip() == '1' else 'ok', + summary=f'{rendered_status} · Cron {SHELL_TEXT_FORMATTER.truncate_text(cron_expression, 32)}', + metadata_lines=[ + '## 任务标识', + f'任务 ID: {job_id}', + f'任务分组: {SHELL_TEXT_FORMATTER.truncate_text(job_group, 24)}', + f'运行状态: {rendered_status}', + '', + '## 调度信息', + f'Cron 表达式: {SHELL_TEXT_FORMATTER.truncate_text(cron_expression, 48)}', + ], + detail_sections=[], + detail_loader=lambda job_row=job_row, env=env: self.section_builder.load_job_detail_sections(job_row, env), + ) + + def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot: + """ + 构建任务页失败兜底记录。 + + :param payload: 失败结果负载 + :return: 浏览记录快照 + """ + return self.page_adapter.build_failure_record( + key='job:unavailable', + subject='任务', + section_subject='任务列表', + payload=payload, + ) + + +class JobsBrowserAdapter(BaseBrowserAdapter): + """ + 任务浏览页适配器。 + + 该适配器负责采集任务列表、失败日志和单条任务详情,并委托协作对象 + 完成过滤、共享分区、详情分区和记录构建。 + """ + + def __init__( + self, + rendering: JobRenderingSupport | None = None, + row_filter: JobRowFilter | None = None, + section_builder: JobSectionBuilder | None = None, + record_builder: JobRecordBuilder | None = None, + ) -> None: + """ + 初始化任务浏览页适配器。 + + :param rendering: 任务浏览页渲染支持对象 + :param row_filter: 任务浏览行过滤器 + :param section_builder: 任务浏览页分区构建器 + :param record_builder: 任务浏览记录构建器 + :return: None + """ + super().__init__( + page_title='任务', + search_view_key='jobs', + filter_options=JOB_FILTER_OPTIONS, + ) + self.rendering = rendering or JobRenderingSupport(self) + self.row_filter = row_filter or JobRowFilter() + self.section_builder = section_builder or JobSectionBuilder(self, self.rendering) + self.record_builder = record_builder or JobRecordBuilder(self, self.rendering, self.section_builder) + + def collect_snapshot(self, env: str, filter_key: str = 'all', query: str = '') -> BrowserPageSnapshot: + """ + 采集定时任务浏览页只读快照。 + + :param env: 当前运行环境 + :param filter_key: 当前筛选键 + :param query: 当前搜索词 + :return: 浏览页快照 + """ + active_filter_option = self.resolve_active_filter(filter_key) + active_filter = active_filter_option.key + active_filter_label = active_filter_option.label + search_context = self.resolve_search_context(query) + jobs_payload = NESTED_CLI_SUPPORT.run( + 'job', + 'list', + f'--env={env}', + '--paged', + '--page-size=8', + '--output=json', + parse_json=True, + ).payload + failed_logs_payload = NESTED_CLI_SUPPORT.run( + 'job', + 'logs', + f'--env={env}', + '--paged', + '--page-size=20', + '--status=1', + '--output=json', + parse_json=True, + ).payload + failed_job_names = self.rendering.extract_failed_job_names(failed_logs_payload) + + if not isinstance(jobs_payload, dict) or not jobs_payload.get('ok', False): + return BrowserPageSnapshot( + title='任务', + subtitle=TUI_COPY.build_unavailable_subtitle( + '任务', + SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(jobs_payload), 72), + ), + records=[self.record_builder.build_failure_record(jobs_payload)], + shared_sections=[ + self.section_builder.build_jobs_overview_section( + [], [], failed_job_names, 0, failed_logs_payload, active_filter_label + ), + self.section_builder.build_job_failure_aggregate_section(failed_logs_payload), + ], + filters=list(self.filter_options), + active_filter_key=active_filter, + search=search_context, + ) + + rows = self.extract_page_rows(jobs_payload) + sorted_rows = sorted( + rows, + key=lambda row: ( + 0 + if str(row.get('jobName', '') or '').strip() in failed_job_names + else 1 + if str(row.get('status', '')).strip() == '1' + else 2, + str(row.get('jobName', '') or ''), + ), + ) + filtered_rows = self.row_filter.apply_job_query( + self.row_filter.apply_job_filter(sorted_rows, failed_job_names, active_filter), + query, + ) + records = [self.record_builder.build_job_record(job_row, env) for job_row in filtered_rows[:8]] + if not records: + records = [ + self.build_empty_record( + key='job:none', + subject='任务', + empty_label='任务列表', + has_source_rows=bool(sorted_rows), + filtered_summary='当前筛选条件下没有匹配任务', + empty_summary='当前环境中还没有定时任务', + filtered_empty_value='暂无任务', + empty_empty_value='暂无任务配置', + filtered_detail='当前筛选条件下没有匹配任务', + empty_detail='当前环境中尚未配置定时任务', + ) + ] + paused_count = sum(1 for row in rows if str(row.get('status', '')).strip() == '1') + shared_sections = [ + self.section_builder.build_jobs_overview_section( + rows, + filtered_rows, + failed_job_names, + paused_count, + failed_logs_payload, + active_filter_label, + ), + self.section_builder.build_job_failure_aggregate_section(failed_logs_payload), + ] + return BrowserPageSnapshot( + title='任务', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_jobs_diagnostic_subtitle( + active_filter_label, + len(filtered_rows), + failed_job_names, + paused_count, + ), + records=records, + shared_sections=shared_sections, + filters=list(self.filter_options), + active_filter_key=active_filter, + search=search_context, + ) + + +JOBS_BROWSER_ADAPTER = JobsBrowserAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/models.py b/shierfeng-fastapi-backend/cli/tui/adapters/models.py new file mode 100644 index 0000000..b0bca76 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/models.py @@ -0,0 +1,227 @@ +from collections.abc import Callable +from dataclasses import dataclass, field + +from cli.tui.copy import TUI_COPY +from cli.tui.search import PageFilterOption, PageSearchContext +from cli.utils import SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class DetailSectionSnapshot: + """ + TUI 详情页单个分区快照。 + + :param title: 分区标题 + :param status: 分区状态 + :param lines: 分区正文文本行 + """ + + title: str + status: str + lines: list[str] + + +@dataclass(frozen=True) +class DetailPageSnapshot: + """ + TUI 详情页聚合快照。 + + :param title: 页面标题 + :param subtitle: 页面副标题 + :param sections: 页面分区列表 + :param search: 当前页面搜索上下文 + """ + + title: str + subtitle: str + sections: list[DetailSectionSnapshot] + search: PageSearchContext | None = None + + +@dataclass(frozen=True) +class BrowserRecordSnapshot: + """ + TUI 浏览页单条记录快照。 + + :param key: 记录唯一标识 + :param title: 记录标题 + :param status: 记录状态 + :param summary: 记录摘要 + :param metadata_lines: 记录元信息文本行 + :param detail_sections: 记录联动详情分区列表 + """ + + key: str + title: str + status: str + summary: str + metadata_lines: list[str] + detail_sections: list[DetailSectionSnapshot] + detail_loader: Callable[[], list[DetailSectionSnapshot]] | None = None + _cached_detail_sections: tuple[DetailSectionSnapshot, ...] | None = field( + default=None, + init=False, + repr=False, + compare=False, + ) + + def resolve_detail_sections(self) -> list[DetailSectionSnapshot]: + """ + 获取当前记录详情分区,必要时按需加载并缓存。 + + :return: 详情分区列表 + """ + if self.detail_loader is None: + return self.detail_sections + if self._cached_detail_sections is None: + object.__setattr__(self, '_cached_detail_sections', tuple(self.detail_loader())) + return list(self._cached_detail_sections) + + +@dataclass(frozen=True) +class BrowserPageSnapshot: + """ + TUI 浏览页聚合快照。 + + :param title: 页面标题 + :param subtitle: 页面副标题 + :param records: 可浏览记录列表 + :param shared_sections: 全局共享分区列表 + :param filters: 页面可用筛选项 + :param active_filter_key: 当前激活筛选键 + :param search: 当前页面搜索上下文 + """ + + title: str + subtitle: str + records: list[BrowserRecordSnapshot] + shared_sections: list[DetailSectionSnapshot] + filters: list[PageFilterOption] = field(default_factory=list) + active_filter_key: str | None = None + search: PageSearchContext | None = None + + +class TuiAdapterModelRenderService: + """ + TUI 适配层模型渲染服务。 + + 该对象集中封装结果负载摘要提取,以及空态、加载态、失败态文本行 + 的统一构建逻辑,避免这些模块级辅助函数继续散落扩张。 + """ + + @staticmethod + def extract_payload_message(payload: dict[str, object] | None) -> str: + """ + 提取结果负载中的可读消息。 + + :param payload: 标准结果负载 + :return: 摘要消息文本 + """ + if not isinstance(payload, dict): + return '-' + if payload.get('message'): + return SHELL_TEXT_FORMATTER.truncate_text(payload.get('message', '-'), 120) + if payload.get('error'): + return SHELL_TEXT_FORMATTER.truncate_text(payload.get('error', '-'), 120) + return '-' + + @staticmethod + def build_empty_lines( + *, + empty_label: str, + empty_value: str, + detail: str, + suggestion: str = TUI_COPY.build_empty_state_suggestion(), + ) -> list[str]: + """ + 构建统一的空态文本行。 + + :param empty_label: 空态主字段名称 + :param empty_value: 空态主字段值 + :param detail: 空态说明 + :param suggestion: 建议操作 + :return: 空态文本行列表 + """ + return [ + TUI_COPY.build_state_section_title('status'), + f'{empty_label}: {empty_value}', + '', + TUI_COPY.build_state_section_title('detail'), + detail, + '', + TUI_COPY.build_state_section_title('suggestion'), + suggestion, + ] + + @staticmethod + def build_loading_lines( + *, + loading_label: str, + loading_value: str, + detail: str, + suggestion: str = TUI_COPY.build_loading_state_suggestion(), + ) -> list[str]: + """ + 构建统一的加载中文本行。 + + :param loading_label: 加载态主字段名称 + :param loading_value: 加载态主字段值 + :param detail: 加载说明 + :param suggestion: 建议操作 + :return: 加载态文本行列表 + """ + return [ + TUI_COPY.build_state_section_title('status'), + f'{loading_label}: {loading_value}', + '', + TUI_COPY.build_state_section_title('detail'), + detail, + '', + TUI_COPY.build_state_section_title('suggestion'), + suggestion, + ] + + def build_failure_lines( + self, + payload: dict[str, object] | None, + *, + empty_label: str, + empty_value: str, + ) -> list[str]: + """ + 为失败状态构建统一的可读文本行。 + + :param payload: 标准结果负载 + :param empty_label: 主字段标签 + :param empty_value: 主字段兜底值 + :return: 失败状态文本行列表 + """ + lines = [ + TUI_COPY.build_state_section_title('status'), + f'{empty_label}: {empty_value}', + '', + TUI_COPY.build_state_section_title('error'), + ] + if not isinstance(payload, dict): + lines.append('结果消息: -') + lines.extend( + ['', TUI_COPY.build_state_section_title('suggestion'), TUI_COPY.build_failure_state_suggestion()] + ) + return lines + lines.append(f'结果消息: {self.extract_payload_message(payload)}') + detail_lines: list[str] = [] + if payload.get('hint'): + detail_lines.append(f'建议提示: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("hint", "-"), 120)}') + if payload.get('error'): + detail_lines.append(f'错误信息: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("error", "-"), 120)}') + if payload.get('stderr'): + detail_lines.append(f'标准错误: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("stderr", "-"), 120)}') + if payload.get('stdout'): + detail_lines.append(f'标准输出: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("stdout", "-"), 120)}') + if detail_lines: + lines.extend(['', TUI_COPY.build_state_section_title('diagnostic'), *detail_lines]) + lines.extend(['', TUI_COPY.build_state_section_title('suggestion'), TUI_COPY.build_failure_state_suggestion()]) + return lines + + +TUI_ADAPTER_MODEL_RENDERER = TuiAdapterModelRenderService() diff --git a/shierfeng-fastapi-backend/cli/tui/adapters/ops.py b/shierfeng-fastapi-backend/cli/tui/adapters/ops.py new file mode 100644 index 0000000..b5222fc --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/adapters/ops.py @@ -0,0 +1,482 @@ +from dataclasses import dataclass +from typing import Any + +from cli.tui.adapters.base import BaseDetailAdapter +from cli.tui.adapters.models import ( + TUI_ADAPTER_MODEL_RENDERER, + DetailPageSnapshot, + DetailSectionSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class OpsDetailSourcePayloads: + """ + 运维详情页原始数据源快照。 + + :param health_payload: `ops health` 结果 + :param ping_db_payload: `ops ping-db` 结果 + :param ping_redis_payload: `ops ping-redis` 结果 + :param deps_payload: `ops deps` 结果 + :param server_payload: `ops server-info` 结果 + """ + + health_payload: dict[str, Any] | None + ping_db_payload: dict[str, Any] | None + ping_redis_payload: dict[str, Any] | None + deps_payload: dict[str, Any] | None + server_payload: dict[str, Any] | None + + +class OpsDetailSnapshotCollector: + """ + 运维详情页数据采集器。 + + 该对象负责拉取运维详情页所需的多路 CLI 原始结果, + 让 `OpsDetailAdapter` 保持详情页编排职责。 + """ + + def collect(self, env: str) -> OpsDetailSourcePayloads: + """ + 采集运维详情页所需原始结果。 + + :param env: 当前运行环境 + :return: 运维详情页原始数据源快照 + """ + return OpsDetailSourcePayloads( + health_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'health', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + ping_db_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'ping-db', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + ping_redis_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'ping-redis', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + deps_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'deps', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + server_payload=NESTED_CLI_SUPPORT.run( + 'ops', + 'server-info', + f'--env={env}', + '--output=json', + parse_json=True, + ).payload, + ) + + +class OpsSectionBuilder: + """ + 运维详情分区构建器。 + + 该构建器负责将运维相关 CLI 结果负载转换为 TUI 详情页分区, + 使详情页适配器本体只保留采集与编排职责。 + """ + + @staticmethod + def build_health_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建运维健康检查分区。 + + :param payload: `ops health` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict): + return DetailSectionSnapshot( + title='健康检查', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='健康检查', empty_value='不可用' + ), + ) + database = payload.get('database') if isinstance(payload.get('database'), dict) else {} + redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {} + return DetailSectionSnapshot( + title='健康检查', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 基础连通性', + f'环境: {payload.get("env", "-")}', + f'数据库: {"正常" if database.get("ok", False) else "异常"}', + f'> {SHELL_TEXT_FORMATTER.truncate_text(database.get("message", "-"), 64)}', + f'Redis: {"正常" if redis.get("ok", False) else "异常"}', + f'> {SHELL_TEXT_FORMATTER.truncate_text(redis.get("message", "-"), 64)}', + ], + ) + + @staticmethod + def build_ping_db_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建数据库探活结果分区。 + + :param payload: `ops ping-db` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict): + return DetailSectionSnapshot( + title='数据库探活', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='数据库探活', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='数据库探活', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 探活结果', + f'数据库连接: {"正常" if payload.get("ok", False) else "异常"}', + f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}', + ], + ) + + @staticmethod + def build_ping_redis_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建 Redis 探活结果分区。 + + :param payload: `ops ping-redis` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict): + return DetailSectionSnapshot( + title='Redis 探活', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='Redis 探活', empty_value='不可用' + ), + ) + return DetailSectionSnapshot( + title='Redis 探活', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 探活结果', + f'Redis 连接: {"正常" if payload.get("ok", False) else "异常"}', + f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}', + ], + ) + + @staticmethod + def build_dependency_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建运维依赖版本分区。 + + :param payload: `ops deps` JSON 负载 + :return: 分区快照 + """ + if not isinstance(payload, dict): + return DetailSectionSnapshot( + title='依赖版本', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='依赖', empty_value='不可用'), + ) + packages = payload.get('packages') if isinstance(payload.get('packages'), dict) else {} + missing_required = payload.get('missingRequired') if isinstance(payload.get('missingRequired'), list) else [] + lines = [ + '## 总体结论', + f'检查结果: {"通过" if payload.get("ok", False) else "异常"}', + f'缺失核心依赖: {len(missing_required)} 个', + f'> {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}', + '', + '## 关键版本', + ] + for package_name in ('python', 'fastapi', 'sqlalchemy', 'redis', 'typer', 'alembic'): + package_payload = packages.get(package_name) + if not isinstance(package_payload, dict): + continue + installed = '已安装' if package_payload.get('installed', False) else '缺失' + lines.append(f'> {package_name} · {installed} · {package_payload.get("version", "-") or "-"}') + if missing_required: + lines.extend(['', '## 缺失项']) + lines.extend(f'> {item}' for item in missing_required[:8]) + return DetailSectionSnapshot( + title='依赖版本', + status='ok' if payload.get('ok', False) else 'fail', + lines=lines, + ) + + @staticmethod + def build_server_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建服务器运行概况分区。 + + :param payload: `ops server-info` JSON 负载 + :return: 分区快照 + """ + server = payload.get('server') if isinstance(payload, dict) else None + if not isinstance(server, dict): + return DetailSectionSnapshot( + title='服务器摘要', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='服务器信息', empty_value='不可用' + ), + ) + + sys_info = server.get('sys') if isinstance(server.get('sys'), dict) else {} + cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {} + mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {} + py_info = server.get('py') if isinstance(server.get('py'), dict) else {} + return DetailSectionSnapshot( + title='服务器摘要', + status='ok' if payload.get('ok', False) else 'fail', + lines=[ + '## 主机信息', + f'主机名: {sys_info.get("computerName", "-")}', + f'主机 IP: {sys_info.get("computerIp", "-")}', + f'操作系统: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("osName", "-"), 56)}', + f'系统架构: {sys_info.get("osArch", "-")}', + '', + '## 资源负载', + f'CPU 核心: {cpu_info.get("cpuNum", "-")}', + f'CPU 使用率: {cpu_info.get("used", "-")}%', + f'内存总量: {mem_info.get("total", "-")}', + f'内存使用率: {mem_info.get("usage", "-")}%', + '', + '## Python 进程', + f'版本: {py_info.get("version", "-")}', + f'运行时长: {py_info.get("runTime", "-")}', + f'进程内存: {py_info.get("used", "-")} / {py_info.get("total", "-")}', + ], + ) + + def build_disk_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot: + """ + 构建服务器磁盘样本分区。 + + :param payload: `ops server-info` JSON 负载 + :return: 分区快照 + """ + server = payload.get('server') if isinstance(payload, dict) else None + if not isinstance(server, dict): + return DetailSectionSnapshot( + title='磁盘样本', + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + payload, empty_label='磁盘样本', empty_value='不可用' + ), + ) + sys_files = server.get('sysFiles') if isinstance(server.get('sysFiles'), list) else [] + lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label='磁盘样本', + empty_value='0 个', + detail='当前服务器信息中没有返回磁盘分区样本', + suggestion=TUI_COPY.build_refresh_page_suggestion('运维', '重新采集服务器信息'), + ) + section_status = 'info' + if sys_files: + section_status = 'ok' + lines = [] + for item in sys_files[:8]: + if not isinstance(item, dict): + continue + lines.extend( + [ + f'## {SHELL_TEXT_FORMATTER.truncate_text(item.get("dirName", "-"), 36)}', + ( + f'> 已用 {item.get("used", "-")} / 总量 {item.get("total", "-")} ' + f'| 使用率 {item.get("usage", "-")}' + ), + f'> 可用 {item.get("free", "-")}', + '', + ] + ) + if lines and lines[-1] == '': + lines.pop() + return DetailSectionSnapshot( + title='磁盘样本', + status=section_status, + lines=lines, + ) + + @staticmethod + def build_prod_check_entry_section() -> DetailSectionSnapshot: + """ + 构建生产巡检向导入口分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title='生产巡检入口', + status='info', + lines=TUI_COPY.build_command_hint_lines( + scenario='准备做生产环境巡检或上线前复核时,应先通过向导统一检查数据库、缓存和运行配置。', + command=TUI_COPY.build_cli_command_hint('wizard', 'prod-check', '--output=text'), + guide='向导会继续询问目标环境并输出预览结果,确认后再执行完整巡检。', + ), + ) + + @staticmethod + def build_overview_section( + health_payload: dict[str, Any] | None, + deps_payload: dict[str, Any] | None, + server_payload: dict[str, Any] | None, + ) -> DetailSectionSnapshot: + """ + 构建运维页总览判断分区。 + + :param health_payload: `ops health` JSON 负载 + :param deps_payload: `ops deps` JSON 负载 + :param server_payload: `ops server-info` JSON 负载 + :return: 分区快照 + """ + health_ok = bool(isinstance(health_payload, dict) and health_payload.get('ok', False)) + deps_ok = bool(isinstance(deps_payload, dict) and deps_payload.get('ok', False)) + server_ok = bool(isinstance(server_payload, dict) and server_payload.get('ok', False)) + missing_required = deps_payload.get('missingRequired') if isinstance(deps_payload, dict) else None + missing_count = len(missing_required) if isinstance(missing_required, list) else 0 + server = ( + server_payload.get('server') + if isinstance(server_payload, dict) and isinstance(server_payload.get('server'), dict) + else {} + ) + cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {} + mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {} + + status = 'ok' + conclusion = '基础探活与运行依赖正常,可继续查看服务器资源与磁盘样本' + if not health_ok: + status = 'fail' + conclusion = '运维探活存在异常,优先处理数据库或 Redis 连通性问题' + elif not deps_ok or missing_count > 0: + status = 'warn' + conclusion = '运行依赖存在缺口,建议优先补齐核心依赖并确认版本兼容性' + elif not server_ok: + status = 'warn' + conclusion = '服务器信息采集异常,建议重新采集资源与磁盘状态' + + return DetailSectionSnapshot( + title='总览判断', + status=status, + lines=[ + '## 当前结论', + conclusion, + '', + '## 核心指标', + f'探活状态: {"正常" if health_ok else "异常"}', + f'缺失核心依赖: {missing_count} 个', + f'CPU 使用率: {cpu_info.get("used", "-")}%', + f'内存使用率: {mem_info.get("usage", "-")}%', + '', + '## 建议入口', + '优先关注:健康检查 / 依赖版本 / 服务器摘要 / 生产巡检入口', + ], + ) + + def build_sections( + self, + *, + health_payload: dict[str, Any] | None, + ping_db_payload: dict[str, Any] | None, + ping_redis_payload: dict[str, Any] | None, + deps_payload: dict[str, Any] | None, + server_payload: dict[str, Any] | None, + ) -> list[DetailSectionSnapshot]: + """ + 构建运维页全部详情分区。 + + :param health_payload: `ops health` 结果负载 + :param ping_db_payload: `ops ping-db` 结果负载 + :param ping_redis_payload: `ops ping-redis` 结果负载 + :param deps_payload: `ops deps` 结果负载 + :param server_payload: `ops server-info` 结果负载 + :return: 详情分区列表 + """ + return [ + self.build_overview_section(health_payload, deps_payload, server_payload), + self.build_health_section(health_payload), + self.build_ping_db_section(ping_db_payload), + self.build_ping_redis_section(ping_redis_payload), + self.build_dependency_section(deps_payload), + self.build_server_section(server_payload), + self.build_disk_section(server_payload), + self.build_prod_check_entry_section(), + ] + + +class OpsDetailAdapter(BaseDetailAdapter): + """ + 运维详情页适配器。 + + 该适配器负责采集运维相关 CLI 结果,并委托分区构建器组装为 + TUI 详情页快照。 + """ + + def __init__( + self, + section_builder: OpsSectionBuilder | None = None, + snapshot_collector: OpsDetailSnapshotCollector | None = None, + ) -> None: + """ + 初始化运维详情页适配器。 + + :param section_builder: 运维详情分区构建器 + :param snapshot_collector: 运维详情页数据采集器 + :return: None + """ + super().__init__( + page_title='运维', + search_view_key='ops', + default_suggestions=[ + '总览判断', + '健康检查', + '数据库探活', + 'Redis 探活', + '依赖版本', + '服务器摘要', + '磁盘样本', + '生产巡检入口', + ], + ) + self.section_builder = section_builder or OpsSectionBuilder() + self.snapshot_collector = snapshot_collector or OpsDetailSnapshotCollector() + + def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot: + """ + 采集运维状态页只读快照。 + + :param env: 当前运行环境 + :param query: 当前搜索词 + :return: 页面快照 + """ + source_payloads = self.snapshot_collector.collect(env) + sections = self.section_builder.build_sections( + health_payload=source_payloads.health_payload, + ping_db_payload=source_payloads.ping_db_payload, + ping_redis_payload=source_payloads.ping_redis_payload, + deps_payload=source_payloads.deps_payload, + server_payload=source_payloads.server_payload, + ) + return DetailPageSnapshot( + title='运维', + subtitle=TUI_DIAGNOSTIC_SERVICE.build_ops_diagnostic_subtitle( + source_payloads.health_payload, + source_payloads.deps_payload, + source_payloads.server_payload, + ), + sections=self.filter_sections(sections, query), + search=self.resolve_search_context(query), + ) + + +OPS_DETAIL_ADAPTER = OpsDetailAdapter() diff --git a/shierfeng-fastapi-backend/cli/tui/app.py b/shierfeng-fastapi-backend/cli/tui/app.py new file mode 100644 index 0000000..d95da40 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/app.py @@ -0,0 +1,898 @@ +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from textual.app import App +from textual.css.query import NoMatches + +from cli.tui.adapters import ( + TUI_SNAPSHOT_COLLECTOR_REGISTRY, + BrowserPageSnapshot, + DashboardSnapshot, + DetailPageSnapshot, +) +from cli.tui.copy import TUI_COPY +from cli.tui.keymaps import TUI_KEYMAP_REGISTRY +from cli.tui.screens import BrowserScreen, DashboardScreen, DetailScreen +from cli.tui.widgets import NavigationItem, WorkspaceSidebar + +SnapshotCollector = Callable[['ShierfengTuiApp'], BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot] + + +@dataclass(frozen=True) +class TuiViewSpec: + """ + TUI 视图规格定义。 + + :param view_key: 视图标识 + :param include_query: 是否注入搜索词 + :param include_filter: 是否注入筛选键 + """ + + view_key: str + include_query: bool = False + include_filter: bool = False + + +@dataclass(frozen=True) +class TuiViewDefinition: + """ + TUI 视图定义。 + + :param view_key: 视图标识 + :param collector: 页面快照采集器 + """ + + view_key: str + collector: SnapshotCollector + + +@dataclass(frozen=True) +class TuiViewRegistry: + """ + TUI 视图注册表。 + + 该对象集中维护视图打开、刷新与顺序导航所需的元数据, + 避免 `ShierfengTuiApp` 内继续手工维护多份视图映射。 + + :param definitions: 视图定义映射 + :param navigation_items: 导航项列表 + """ + + definitions: dict[str, TuiViewDefinition] + navigation_items: list[NavigationItem] + + @staticmethod + def normalize_view_key(view_key: str) -> str: + """ + 规范化视图标识。 + + :param view_key: 原始视图标识 + :return: 规范化后的视图标识 + """ + return str(view_key).strip().lower() + + def resolve_view_key(self, view_key: str) -> str: + """ + 解析可用视图标识,不存在时回退到 dashboard。 + + :param view_key: 原始视图标识 + :return: 可用视图标识 + """ + normalized_view_key = self.normalize_view_key(view_key) + if normalized_view_key in self.definitions: + return normalized_view_key + return 'dashboard' + + def collect_snapshot( + self, app: 'ShierfengTuiApp', view_key: str + ) -> BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot: + """ + 采集指定视图的页面快照。 + + :param app: 当前 TUI 应用实例 + :param view_key: 目标视图标识 + :return: 页面快照 + """ + resolved_view_key = self.resolve_view_key(view_key) + return self.definitions[resolved_view_key].collector(app) + + def get_navigation_index(self, view_key: str) -> int: + """ + 获取视图在导航列表中的索引。 + + :param view_key: 目标视图标识 + :return: 导航索引 + """ + resolved_view_key = self.resolve_view_key(view_key) + return next( + (index for index, item in enumerate(self.navigation_items) if item.view_key == resolved_view_key), + 0, + ) + + def get_relative_view_key(self, view_key: str, offset: int) -> str: + """ + 获取相对当前视图偏移后的目标视图标识。 + + :param view_key: 当前视图标识 + :param offset: 偏移量 + :return: 目标视图标识 + """ + current_index = self.get_navigation_index(view_key) + target_index = (current_index + offset) % len(self.navigation_items) + return self.navigation_items[target_index].view_key + + +_TUI_VIEW_SPECS: tuple[TuiViewSpec, ...] = ( + TuiViewSpec('dashboard'), + TuiViewSpec('app', include_query=True), + TuiViewSpec('ops', include_query=True), + TuiViewSpec('database', include_query=True), + TuiViewSpec('cache', include_query=True), + TuiViewSpec('jobs', include_query=True, include_filter=True), + TuiViewSpec('gen', include_query=True), + TuiViewSpec('configs', include_query=True, include_filter=True), + TuiViewSpec('crypto', include_query=True), +) + +_TUI_APP_CSS = """ +Screen { + layout: vertical; + background: #04080f; + color: #d8f7ff; + scrollbar-size-horizontal: 1; + scrollbar-size-vertical: 1; + scrollbar-background: #06111b; + scrollbar-background-hover: #0a1723; + scrollbar-background-active: #0d1f2d; + scrollbar-color: #1f6f8d; + scrollbar-color-hover: #38d8ff; + scrollbar-color-active: #6df7ff; +} + +Widget { + scrollbar-size-horizontal: 1; + scrollbar-size-vertical: 1; + scrollbar-background: #06111b; + scrollbar-background-hover: #0a1723; + scrollbar-background-active: #0d1f2d; + scrollbar-color: #1f6f8d; + scrollbar-color-hover: #38d8ff; + scrollbar-color-active: #6df7ff; +} + +#workspace-shell { + height: 1fr; + background: #050c15; +} + +#workspace-main { + padding: 1 2 1 1; + background: #050c15; + border: round #18425d; + margin: 1 1 1 0; + overflow-x: auto; + overflow-y: auto; + scrollbar-size-horizontal: 1; + scrollbar-size-vertical: 1; + scrollbar-background: #06111b; + scrollbar-background-hover: #0a1723; + scrollbar-background-active: #0d1f2d; + scrollbar-color: #1f6f8d; + scrollbar-color-hover: #38d8ff; + scrollbar-color-active: #6df7ff; +} + +#workspace-main:focus { + border: heavy #6df7ff; + background: #071320; +} + +#detail-body { + height: auto; + margin-top: 1; + border: double #18425d; + background: #06111b; + padding: 1; + overflow-x: auto; + overflow-y: auto; +} + +#browser-body { + height: auto; + margin-top: 1; + border: double #18425d; + background: #06111b; + padding: 1; + overflow-x: auto; + overflow-y: auto; +} + +#browser-filter-bar { + border: round #163c57; + padding: 1 2; + margin-bottom: 1; + background: #07131f; + color: #d9fbff; +} + +#browser-detail-pane { + width: 1fr; + min-width: 48; + border: round #163c57; + padding: 1; + background: #07131f; + overflow-x: auto; + overflow-y: auto; +} + +#browser-detail-pane.is-ok { + border: round #22c983; +} + +#browser-detail-pane.is-fail { + border: round #ff6b7a; + background: #190d12; +} + +#browser-detail-pane.is-warn { + border: round #f2b84b; + background: #171108; +} + +#browser-detail-pane.is-info { + border: round #38d8ff; +} + +#browser-section-body { + height: auto; + border-top: solid #163c57; + padding-top: 1; + background: #06111b; + overflow-x: auto; + overflow-y: auto; +} + +#dashboard-metrics { + grid-size: 4; + grid-gutter: 1 2; + grid-columns: 1fr 1fr 1fr 1fr; + height: auto; + margin-top: 1; + margin-bottom: 1; + padding: 1 0; +} + +#dashboard-grid, #detail-grid { + grid-size: 2; + grid-gutter: 1 2; + grid-columns: 1fr 1fr; + height: auto; + margin-top: 1; +} + +#detail-grid { + margin-bottom: 1; +} + +Footer { + background: #060d16; + color: #b6efff; + border-top: heavy #163c57; +} + +FooterKey { + background: #0b2130; + color: #ecfcff; +} +""" + + +@dataclass(frozen=True) +class TuiBindingSpec: + """ + TUI 快捷键绑定定义。 + + :param key: 快捷键 + :param action: 动作名称 + :param label: 展示标签 + """ + + key: str + action: str + label: str + + +class TuiBindingBuilder: + """ + TUI 快捷键绑定构建器。 + + 该对象负责统一装配应用级固定绑定和视图跳转绑定,避免 `ShierfengTuiApp` + 继续手工维护重复的绑定元组列表。 + """ + + def build_base_bindings(self) -> list[TuiBindingSpec]: + """ + 构建应用级固定绑定。 + + :return: 绑定定义列表 + """ + return [ + TuiBindingSpec('q', 'quit', TUI_COPY.build_app_binding_label('quit')), + TuiBindingSpec('s', 'focus_sidebar', TUI_COPY.build_app_binding_label('sidebar')), + TuiBindingSpec('[', 'show_previous_view', TUI_COPY.build_app_binding_label('previous')), + TuiBindingSpec(']', 'show_next_view', TUI_COPY.build_app_binding_label('next')), + TuiBindingSpec('r', 'refresh_current_view', TUI_COPY.build_app_binding_label('refresh')), + ] + + def build_navigation_bindings(self) -> list[TuiBindingSpec]: + """ + 构建页面导航绑定。 + + :return: 绑定定义列表 + """ + return [ + TuiBindingSpec( + TUI_KEYMAP_REGISTRY.get_navigation_shortcut(spec.view_key), + f'show_{spec.view_key}', + TUI_COPY.render_view_label(spec.view_key), + ) + for spec in _TUI_VIEW_SPECS + ] + + def build(self) -> list[tuple[str, str, str]]: + """ + 构建 Textual 所需的绑定元组列表。 + + :return: 绑定元组列表 + """ + return [ + (binding.key, binding.action, binding.label) + for binding in [*self.build_base_bindings(), *self.build_navigation_bindings()] + ] + + +TUI_APP_BINDINGS = TuiBindingBuilder().build() + + +@dataclass(frozen=True) +class TuiViewRegistryBuilder: + """ + TUI 视图注册表构建器。 + + 该对象负责根据统一规格构建导航项和快照采集定义,避免模块内 + 继续维护重复的 `lambda collect(...)` 配置块。 + """ + + def build_navigation_items(self) -> list[NavigationItem]: + """ + 构建工作台导航项列表。 + + :return: 导航项列表 + """ + return [ + NavigationItem( + view_key, + TUI_COPY.render_view_label(view_key), + shortcut, + TUI_COPY.render_navigation_description(view_key), + ) + for view_key, shortcut in TUI_KEYMAP_REGISTRY.navigation_shortcuts.items() + ] + + def build_collector(self, spec: TuiViewSpec) -> SnapshotCollector: + """ + 根据视图规格构建页面快照采集器。 + + :param spec: 视图规格 + :return: 页面快照采集器 + """ + + def collect(app: 'ShierfengTuiApp') -> BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot: + collect_kwargs: dict[str, str] = {} + if spec.include_filter: + collect_kwargs['filter_key'] = app.get_browser_filter(spec.view_key) + if spec.include_query: + collect_kwargs['query'] = app.get_browser_query(spec.view_key) + return TUI_SNAPSHOT_COLLECTOR_REGISTRY.collect(spec.view_key, app.env, **collect_kwargs) + + return collect + + def build_definitions(self) -> dict[str, TuiViewDefinition]: + """ + 构建视图定义映射。 + + :return: 视图定义映射 + """ + return {spec.view_key: TuiViewDefinition(spec.view_key, self.build_collector(spec)) for spec in _TUI_VIEW_SPECS} + + def build(self) -> TuiViewRegistry: + """ + 构建 TUI 视图注册表。 + + :return: 视图注册表 + """ + return TuiViewRegistry( + definitions=self.build_definitions(), + navigation_items=self.build_navigation_items(), + ) + + +@dataclass +class TuiViewStateStore: + """ + TUI 视图状态存储。 + + 该对象集中维护页面级动作反馈、浏览页筛选键和搜索词,避免 + `ShierfengTuiApp` 本体继续直接操作多份同类字典状态。 + + :param action_feedback_by_view: 动作反馈映射 + :param browser_filter_by_view: 浏览页筛选映射 + :param browser_query_by_view: 浏览页搜索词映射 + """ + + action_feedback_by_view: dict[str, list[str]] + browser_filter_by_view: dict[str, str] + browser_query_by_view: dict[str, str] + + @classmethod + def create_default(cls) -> 'TuiViewStateStore': + """ + 创建默认视图状态存储。 + + :return: 视图状态存储 + """ + return cls( + action_feedback_by_view={}, + browser_filter_by_view={'jobs': 'all', 'configs': 'all'}, + browser_query_by_view={}, + ) + + def remember_action_feedback(self, view_key: str, lines: list[str]) -> None: + """ + 记录指定页面最近一次动作反馈。 + + :param view_key: 页面视图标识 + :param lines: 反馈文本行 + :return: None + """ + self.action_feedback_by_view[view_key] = list(lines) + + def get_action_feedback_lines(self, view_key: str) -> list[str]: + """ + 读取指定页面最近一次动作反馈。 + + :param view_key: 页面视图标识 + :return: 反馈文本行 + """ + return list(self.action_feedback_by_view.get(view_key, [])) + + def remember_browser_filter(self, view_key: str, filter_key: str) -> None: + """ + 记录指定浏览页当前筛选键。 + + :param view_key: 页面视图标识 + :param filter_key: 当前筛选键 + :return: None + """ + self.browser_filter_by_view[view_key] = filter_key + + def get_browser_filter(self, view_key: str, default: str = 'all') -> str: + """ + 读取指定浏览页当前筛选键。 + + :param view_key: 页面视图标识 + :param default: 默认筛选键 + :return: 当前筛选键 + """ + return str(self.browser_filter_by_view.get(view_key, default) or default) + + def remember_browser_query(self, view_key: str, query: str) -> None: + """ + 记录指定浏览页当前搜索词。 + + :param view_key: 页面视图标识 + :param query: 当前搜索词 + :return: None + """ + normalized_query = str(query or '').strip() + if normalized_query: + self.browser_query_by_view[view_key] = normalized_query + return + self.browser_query_by_view.pop(view_key, None) + + def get_browser_query(self, view_key: str, default: str = '') -> str: + """ + 读取指定浏览页当前搜索词。 + + :param view_key: 页面视图标识 + :param default: 默认搜索词 + :return: 当前搜索词 + """ + return str(self.browser_query_by_view.get(view_key, default) or default) + + +TUI_VIEW_REGISTRY = TuiViewRegistryBuilder().build() +NAVIGATION_ITEMS = TUI_VIEW_REGISTRY.navigation_items + + +@dataclass(frozen=True) +class TuiScreenFactory: + """ + TUI 工作台页面工厂。 + + 该对象负责把页面快照转换为具体 screen 实例,避免 `ShierfengTuiApp` + 同时承担状态协调和 screen 构建职责。 + """ + + @staticmethod + def build_refresh_timestamp() -> str: + """ + 构建当前页面刷新时间文本。 + + :return: 格式化后的刷新时间 + """ + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + def build( + self, + *, + snapshot: BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot, + env: str, + active_view: str, + navigation_items: list[NavigationItem], + action_feedback_lines: list[str], + ) -> BrowserScreen | DashboardScreen | DetailScreen: + """ + 根据快照类型构建对应的工作台页面。 + + :param snapshot: 页面快照 + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :param navigation_items: 导航项列表 + :param action_feedback_lines: 当前视图动作反馈 + :return: 对应的页面对象 + """ + refreshed_at = self.build_refresh_timestamp() + if isinstance(snapshot, DashboardSnapshot): + return DashboardScreen( + snapshot, + env=env, + active_view=active_view, + navigation_items=navigation_items, + refreshed_at=refreshed_at, + ) + if isinstance(snapshot, BrowserPageSnapshot): + return BrowserScreen( + snapshot, + env=env, + active_view=active_view, + navigation_items=navigation_items, + refreshed_at=refreshed_at, + action_feedback_lines=action_feedback_lines, + ) + return DetailScreen( + snapshot, + env=env, + active_view=active_view, + navigation_items=navigation_items, + refreshed_at=refreshed_at, + ) + + +@dataclass +class TuiScreenNavigator: + """ + TUI 工作台页面导航器。 + + 该对象负责处理首屏展示与后续切屏差异,让应用壳更专注于状态切换。 + + :param app: 当前 TUI 应用实例 + :param initialized: 工作台屏幕是否已初始化 + """ + + app: 'ShierfengTuiApp' + initialized: bool = False + + def show(self, screen: BrowserScreen | DashboardScreen | DetailScreen) -> None: + """ + 显示工作台页面,并兼容首屏初始化与后续切屏。 + + Textual 在默认根屏阶段直接调用 `switch_screen()` 会触发内部回调栈异常, + 因此首次展示页面需要使用 `push_screen()` 建立活动屏,后续再改用 + `switch_screen()` 做同层页面替换。 + + :param screen: 待显示的页面对象 + :return: None + """ + if not self.initialized: + self.app.push_screen(screen) + self.initialized = True + return + self.app.switch_screen(screen) + + +@dataclass(frozen=True) +class TuiViewOpeningCoordinator: + """ + TUI 视图打开协调器。 + + 该对象负责串联视图解析、快照采集、screen 构建与切屏展示, + 让 `ShierfengTuiApp` 更专注于状态暴露与 Textual action 桥接。 + + :param view_registry: 视图注册表 + :param screen_factory: 工作台页面工厂 + :param navigation_items: 导航项列表 + """ + + view_registry: TuiViewRegistry + screen_factory: TuiScreenFactory + navigation_items: list[NavigationItem] + + def show(self, app: 'ShierfengTuiApp', view_key: str) -> None: + """ + 切换并展示指定视图。 + + :param app: 当前 TUI 应用实例 + :param view_key: 目标视图标识 + :return: None + """ + resolved_view_key = self.view_registry.resolve_view_key(view_key) + app.current_view = resolved_view_key + snapshot = self.view_registry.collect_snapshot(app, resolved_view_key) + screen = self.screen_factory.build( + snapshot=snapshot, + env=app.env, + active_view=app.current_view, + navigation_items=self.navigation_items, + action_feedback_lines=app.get_action_feedback_lines(app.current_view), + ) + app.screen_navigator.show(screen) + + +class ShierfengTuiApp(App[None]): + """ + ShiErFeng CLI Textual 应用。 + + :param env: 当前运行环境 + """ + + CSS = _TUI_APP_CSS + BINDINGS = TUI_APP_BINDINGS + + def __init__(self, env: str) -> None: + """ + 初始化 TUI 应用实例。 + + :param env: 当前运行环境 + :return: None + """ + self.env = env + self.current_view = 'dashboard' + self.view_state_store = TuiViewStateStore.create_default() + self.screen_factory = TuiScreenFactory() + self.screen_navigator = TuiScreenNavigator(self) + self.view_opening_coordinator = TuiViewOpeningCoordinator( + view_registry=TUI_VIEW_REGISTRY, + screen_factory=self.screen_factory, + navigation_items=NAVIGATION_ITEMS, + ) + super().__init__() + + def show_view(self, view_key: str) -> None: + """ + 切换并展示指定视图。 + + :param view_key: 目标视图标识 + :return: None + """ + self.view_opening_coordinator.show(self, view_key) + + def remember_action_feedback(self, view_key: str, lines: list[str]) -> None: + """ + 记录指定页面最近一次动作反馈。 + + :param view_key: 页面视图标识 + :param lines: 反馈文本行 + :return: None + """ + self.view_state_store.remember_action_feedback(view_key, lines) + + def get_action_feedback_lines(self, view_key: str) -> list[str]: + """ + 读取指定页面最近一次动作反馈。 + + :param view_key: 页面视图标识 + :return: 反馈文本行 + """ + return self.view_state_store.get_action_feedback_lines(view_key) + + def remember_browser_filter(self, view_key: str, filter_key: str) -> None: + """ + 记录指定浏览页当前筛选键。 + + :param view_key: 页面视图标识 + :param filter_key: 当前筛选键 + :return: None + """ + self.view_state_store.remember_browser_filter(view_key, filter_key) + + def get_browser_filter(self, view_key: str, default: str = 'all') -> str: + """ + 读取指定浏览页当前筛选键。 + + :param view_key: 页面视图标识 + :param default: 默认筛选键 + :return: 当前筛选键 + """ + return self.view_state_store.get_browser_filter(view_key, default) + + def remember_browser_query(self, view_key: str, query: str) -> None: + """ + 记录指定浏览页当前搜索词。 + + :param view_key: 页面视图标识 + :param query: 当前搜索词 + :return: None + """ + self.view_state_store.remember_browser_query(view_key, query) + + def get_browser_query(self, view_key: str, default: str = '') -> str: + """ + 读取指定浏览页当前搜索词。 + + :param view_key: 页面视图标识 + :param default: 默认搜索词 + :return: 当前搜索词 + """ + return self.view_state_store.get_browser_query(view_key, default) + + def on_mount(self) -> None: + """ + 在应用启动时载入首页。 + + :return: None + """ + self.show_view('dashboard') + + def open_view(self, view_key: str) -> None: + """ + 按视图标识打开对应页面。 + + :param view_key: 视图标识 + :return: None + """ + self.show_view(view_key) + + def action_focus_sidebar(self) -> None: + """ + 将焦点切换到左侧导航栏。 + + :return: None + """ + try: + self.screen.query_one(WorkspaceSidebar).focus() + except NoMatches: + return + + def action_show_previous_view(self) -> None: + """ + 切换到前一个工作台视图。 + + :return: None + """ + self.open_view(TUI_VIEW_REGISTRY.get_relative_view_key(self.current_view, -1)) + + def action_show_next_view(self) -> None: + """ + 切换到后一个工作台视图。 + + :return: None + """ + self.open_view(TUI_VIEW_REGISTRY.get_relative_view_key(self.current_view, 1)) + + def action_refresh_current_view(self) -> None: + """ + 刷新当前视图数据。 + + :return: None + """ + self.show_view(self.current_view) + + def action_show_dashboard(self) -> None: + """ + 切换到首页视图。 + + :return: None + """ + self.open_view('dashboard') + + def action_show_app(self) -> None: + """ + 切换到应用详情视图。 + + :return: None + """ + self.open_view('app') + + def action_show_ops(self) -> None: + """ + 切换到运维详情视图。 + + :return: None + """ + self.open_view('ops') + + def action_show_database(self) -> None: + """ + 切换到数据库详情视图。 + + :return: None + """ + self.open_view('database') + + def action_show_cache(self) -> None: + """ + 切换到缓存浏览视图。 + + :return: None + """ + self.open_view('cache') + + def action_show_jobs(self) -> None: + """ + 切换到任务浏览视图。 + + :return: None + """ + self.open_view('jobs') + + def action_show_gen(self) -> None: + """ + 切换到代码生成浏览视图。 + + :return: None + """ + self.open_view('gen') + + def action_show_configs(self) -> None: + """ + 切换到配置浏览视图。 + + :return: None + """ + self.open_view('configs') + + def action_show_crypto(self) -> None: + """ + 切换到传输加密详情视图。 + + :return: None + """ + self.open_view('crypto') + + +@dataclass(frozen=True) +class TuiAppRunner: + """ + TUI 应用启动器。 + + 该对象负责按环境创建并运行 Textual 应用实例,使命令入口不再依赖 + 模块级启动函数。 + + :param app_factory: TUI 应用工厂 + """ + + app_factory: Callable[[str], ShierfengTuiApp] + + def run(self, env: str) -> None: + """ + 启动 TUI 应用。 + + :param env: 当前运行环境 + :return: None + """ + self.app_factory(env).run() + + +TUI_APP_RUNNER = TuiAppRunner(ShierfengTuiApp) diff --git a/shierfeng-fastapi-backend/cli/tui/capabilities.py b/shierfeng-fastapi-backend/cli/tui/capabilities.py new file mode 100644 index 0000000..95f7b12 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/capabilities.py @@ -0,0 +1,209 @@ +from dataclasses import dataclass +from typing import Literal + +from cli.tui.copy import TUI_COPY + +CapabilityKind = Literal['read_only', 'preview', 'low_risk_action', 'wizard_entry', 'command_hint'] +CapabilityScope = Literal['browser', 'detail'] + + +@dataclass(frozen=True) +class TuiCapability: + """ + TUI 页面能力定义。 + + :param kind: 能力类型 + :param slot: 绑定的动作槽位 + :param key: 绑定的快捷键 + :param label: 展示标签 + :param hint_label: 用于顶部提示的简洁标签 + :param scope: 能力所在页面范围 + """ + + kind: CapabilityKind + slot: str + key: str + label: str + hint_label: str + scope: CapabilityScope + + +@dataclass(frozen=True) +class TuiCapabilitySpec: + """ + TUI 页面能力规格定义。 + + :param capability_key: 能力标识 + :param kind: 能力类型 + :param slot: 绑定动作槽位 + :param key: 绑定快捷键 + """ + + capability_key: str + kind: CapabilityKind + slot: str + key: str + + +_BROWSER_CAPABILITY_SPECS: dict[str, tuple[TuiCapabilitySpec, ...]] = { + 'jobs': ( + TuiCapabilitySpec('job_run_once', 'low_risk_action', 'primary', 'X'), + TuiCapabilitySpec('job_toggle', 'low_risk_action', 'secondary', 'Z'), + TuiCapabilitySpec('job_sync', 'preview', 'global', 'Y'), + ), + 'configs': (TuiCapabilitySpec('config_sync', 'low_risk_action', 'global', 'Y'),), + 'cache': ( + TuiCapabilitySpec('cache_clear_wizard', 'wizard_entry', 'global', 'Y'), + TuiCapabilitySpec('cache_warmup', 'low_risk_action', 'utility', 'W'), + ), + 'gen': ( + TuiCapabilitySpec('gen_export_wizard', 'wizard_entry', 'primary', 'X'), + TuiCapabilitySpec('gen_import_wizard', 'wizard_entry', 'secondary', 'Z'), + TuiCapabilitySpec('gen_export_dry_run', 'preview', 'global', 'Y'), + TuiCapabilitySpec('gen_sync_db', 'low_risk_action', 'utility', 'W'), + ), +} + +_DETAIL_CAPABILITY_SPECS: dict[str, tuple[TuiCapabilitySpec, ...]] = { + 'app': ( + TuiCapabilitySpec('app_run', 'wizard_entry', 'primary', 'X'), + TuiCapabilitySpec('app_run_wizard', 'wizard_entry', 'global', 'Y'), + TuiCapabilitySpec('completion_install', 'command_hint', 'utility', 'W'), + ), + 'database': ( + TuiCapabilitySpec('db_upgrade_wizard', 'wizard_entry', 'global', 'Y'), + TuiCapabilitySpec('db_init_dry_run', 'preview', 'utility', 'W'), + ), + 'ops': ( + TuiCapabilitySpec('ops_ping_db', 'low_risk_action', 'primary', 'X'), + TuiCapabilitySpec('ops_ping_redis', 'low_risk_action', 'secondary', 'Z'), + TuiCapabilitySpec('prod_check_wizard', 'wizard_entry', 'global', 'Y'), + ), + 'crypto': ( + TuiCapabilitySpec('crypto_keygen', 'wizard_entry', 'primary', 'X'), + TuiCapabilitySpec('crypto_rotate_dry_run', 'preview', 'global', 'Y'), + ), +} + + +@dataclass(frozen=True) +class TuiCapabilityRegistry: + """ + TUI 页面能力注册表。 + + 该对象集中维护浏览页与详情页的能力描述,避免模块级散落 + 的字典常量在后续扩展时继续增长。 + + :param browser_capabilities: 浏览页能力映射 + :param detail_capabilities: 详情页能力映射 + """ + + browser_capabilities: dict[str, tuple[TuiCapability, ...]] + detail_capabilities: dict[str, tuple[TuiCapability, ...]] + + @staticmethod + def normalize_view_key(view_key: str) -> str: + """ + 规范化页面标识。 + + :param view_key: 原始页面标识 + :return: 规范化后的页面标识 + """ + return str(view_key).strip().lower() + + def get_browser_capabilities(self, view_key: str) -> tuple[TuiCapability, ...]: + """ + 获取浏览页支持的能力列表。 + + :param view_key: 页面视图标识 + :return: 能力列表 + """ + return self.browser_capabilities.get(self.normalize_view_key(view_key), ()) + + def get_detail_capabilities(self, view_key: str) -> tuple[TuiCapability, ...]: + """ + 获取详情页支持的能力列表。 + + :param view_key: 页面视图标识 + :return: 能力列表 + """ + return self.detail_capabilities.get(self.normalize_view_key(view_key), ()) + + +@dataclass(frozen=True) +class TuiCapabilityRegistryBuilder: + """ + TUI 页面能力注册表构建器。 + + 该对象负责通过统一文案服务构建页面能力定义,避免模块内继续 + 直接堆叠硬编码的标题和提示标签。 + """ + + def build_capability( + self, + *, + capability_key: str, + kind: CapabilityKind, + slot: str, + key: str, + scope: CapabilityScope, + ) -> TuiCapability: + """ + 构建单条能力定义。 + + :param capability_key: 能力标识 + :param kind: 能力类型 + :param slot: 绑定动作槽位 + :param key: 绑定快捷键 + :param scope: 页面范围 + :return: 能力定义 + """ + return TuiCapability( + kind=kind, + slot=slot, + key=key, + label=TUI_COPY.build_capability_label(capability_key), + hint_label=TUI_COPY.build_capability_hint_label(capability_key), + scope=scope, + ) + + def build_scope_capabilities( + self, + specs_by_view: dict[str, tuple[TuiCapabilitySpec, ...]], + *, + scope: CapabilityScope, + ) -> dict[str, tuple[TuiCapability, ...]]: + """ + 根据规格映射构建指定页面范围的能力注册表。 + + :param specs_by_view: 页面能力规格映射 + :param scope: 页面范围 + :return: 构建后的能力映射 + """ + return { + view_key: tuple( + self.build_capability( + capability_key=spec.capability_key, + kind=spec.kind, + slot=spec.slot, + key=spec.key, + scope=scope, + ) + for spec in specs + ) + for view_key, specs in specs_by_view.items() + } + + def build(self) -> TuiCapabilityRegistry: + """ + 构建页面能力注册表。 + + :return: 能力注册表 + """ + return TuiCapabilityRegistry( + browser_capabilities=self.build_scope_capabilities(_BROWSER_CAPABILITY_SPECS, scope='browser'), + detail_capabilities=self.build_scope_capabilities(_DETAIL_CAPABILITY_SPECS, scope='detail'), + ) + + +TUI_CAPABILITY_REGISTRY = TuiCapabilityRegistryBuilder().build() diff --git a/shierfeng-fastapi-backend/cli/tui/commands.py b/shierfeng-fastapi-backend/cli/tui/commands.py new file mode 100644 index 0000000..e6e62e6 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/commands.py @@ -0,0 +1,145 @@ +from dataclasses import dataclass +from importlib import import_module +from types import ModuleType + +import typer + +from cli.context import EnvOption +from cli.core import DEFAULT_CORE_SERVICES, CliContextFactory, CliExecutionService +from cli.exit_codes import DEPENDENCY_ERROR +from cli.output import CommandResult +from cli.tui.copy import TUI_COPY + + +@dataclass(frozen=True) +class TuiDependencyResultFactory: + """ + TUI 依赖结果构建器。 + + 该对象负责统一构建 Textual/Rich 缺失时的降级结果, + 避免命令注册层继续直接拼接错误负载。 + """ + + def build_missing_dependency_result(self) -> CommandResult: + """ + 构建 TUI 可选依赖缺失时的提示结果。 + + :return: 命令结果对象 + """ + return CommandResult( + data={ + 'ok': False, + 'message': TUI_COPY.build_missing_dependency_message(), + 'hint': TUI_COPY.build_missing_dependency_hint(), + }, + exit_code=DEPENDENCY_ERROR, + ) + + +@dataclass(frozen=True) +class TuiAppModuleLoader: + """ + TUI 应用模块加载器。 + + 该对象负责识别缺失的可选依赖,并在命令执行阶段按需导入 + `cli.tui.app` 模块。 + """ + + def is_missing_dependency_error(self, exc: ModuleNotFoundError) -> bool: + """ + 判断模块导入异常是否由 TUI 可选依赖缺失触发。 + + :param exc: 模块导入异常 + :return: 是否为 TUI 依赖缺失 + """ + missing_module_name = (exc.name or '').split('.', 1)[0] + return missing_module_name in {'textual', 'rich'} + + def load(self) -> ModuleType: + """ + 惰性导入 TUI 应用模块。 + + :return: `cli.tui.app` 模块对象 + """ + return import_module('cli.tui.app') + + +@dataclass(frozen=True) +class TuiCommandRegistration: + """ + TUI 命令注册器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param dependency_result_factory: TUI 缺失依赖结果构建器 + :param module_loader: TUI 应用模块加载器 + """ + + context_factory: CliContextFactory + execution_service: CliExecutionService + dependency_result_factory: TuiDependencyResultFactory + module_loader: TuiAppModuleLoader + + def handle_tui_command(self, env: str) -> None: + """ + 执行 `shierfeng tui` 命令。 + + :param env: 当前命令运行环境 + :return: None + """ + ctx = self.context_factory.build_readonly(env, 'text') + try: + tui_app_module = self.module_loader.load() + except ModuleNotFoundError as exc: + if self.module_loader.is_missing_dependency_error(exc): + self.execution_service.complete_result( + ctx, + self.dependency_result_factory.build_missing_dependency_result(), + ) + return + raise + tui_app_module.TUI_APP_RUNNER.run(env) + + def build(self) -> typer.Typer: + """ + 构建 `tui` 子应用。 + + :return: `tui` 子应用 + """ + app = typer.Typer( + help=TUI_COPY.build_tui_command_help(), + no_args_is_help=False, + invoke_without_command=True, + context_settings={'help_option_names': ['-h', '--help']}, + ) + + @app.callback() + def tui( + env: EnvOption = 'dev', + ) -> None: + """ + 进入只读巡检工作台。 + + :param env: 当前命令运行环境 + :return: None + """ + self.handle_tui_command(env) + + return app + + def register(self, root_cli: typer.Typer) -> None: + """ + 向根 Typer 应用注册 `tui` 命令。 + + :param root_cli: 根 Typer 应用 + :return: None + """ + root_cli.add_typer(self.build(), name='tui') + + +TUI_COMMAND_REGISTRATION = TuiCommandRegistration( + context_factory=DEFAULT_CORE_SERVICES.context_factory, + execution_service=DEFAULT_CORE_SERVICES.execution_service, + dependency_result_factory=TuiDependencyResultFactory(), + module_loader=TuiAppModuleLoader(), +) diff --git a/shierfeng-fastapi-backend/cli/tui/copy/__init__.py b/shierfeng-fastapi-backend/cli/tui/copy/__init__.py new file mode 100644 index 0000000..4c4e3de --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/copy/__init__.py @@ -0,0 +1,23 @@ +from cli.tui.copy.actions import TuiActionCopyMixin +from cli.tui.copy.navigation import TuiNavigationCopyMixin +from cli.tui.copy.workspace import TuiWorkspaceCopyMixin + + +class TuiCopyService(TuiNavigationCopyMixin, TuiActionCopyMixin, TuiWorkspaceCopyMixin): + """ + TUI 文案构造服务。 + + 该服务通过组合导航、动作和工作区文案混入对象,统一对外提供 + TUI 所需的文案构造能力,避免入口模块继续堆积实现细节。 + """ + + +TUI_COPY = TuiCopyService() + +__all__ = [ + 'TUI_COPY', + 'TuiActionCopyMixin', + 'TuiCopyService', + 'TuiNavigationCopyMixin', + 'TuiWorkspaceCopyMixin', +] diff --git a/shierfeng-fastapi-backend/cli/tui/copy/actions.py b/shierfeng-fastapi-backend/cli/tui/copy/actions.py new file mode 100644 index 0000000..6a45126 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/copy/actions.py @@ -0,0 +1,298 @@ +from cli.tui.copy.fragments import ( + ACTION_CONSEQUENCE_TEXTS, + ACTION_FEEDBACK_COPY, + ACTION_HINT_COPY, + ACTION_LABELS, + ACTION_NOTIFICATION_COPY, + ACTION_PREVIEW_FIELD_LABELS, + ACTION_PREVIEW_TITLES, + ACTION_PURPOSE_LABELS, + ACTION_RESULT_FIELD_LABELS, + ACTION_SCOPE_LABELS, + CAPABILITY_HINT_LABELS, + CAPABILITY_LABELS, + COMMAND_HINT_TITLES, + GENERAL_COPY, +) +from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER + + +class TuiActionCopyMixin: + """ + TUI 动作与能力文案混入。 + """ + + @staticmethod + def build_action_confirm_hint() -> str: + """ + 构建动作确认弹窗底部提示文案。 + + :return: 确认提示文案 + """ + return GENERAL_COPY['action_confirm_hint'] + + @staticmethod + def build_action_confirm_cancel_label() -> str: + """ + 构建动作确认弹窗取消按钮文案。 + + :return: 取消按钮文案 + """ + return GENERAL_COPY['action_confirm_cancel_label'] + + @staticmethod + def build_action_label(action_key: str) -> str: + """ + 根据动作标识构建动作标题文案。 + + :param action_key: 动作标识 + :return: 动作标题文案 + """ + normalized_key = str(action_key).strip().lower() + return ACTION_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_action_scope_label(action_key: str) -> str: + """ + 根据动作标识构建作用范围文案。 + + :param action_key: 动作标识 + :return: 作用范围文案 + """ + normalized_key = str(action_key).strip().lower() + return ACTION_SCOPE_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_action_purpose_label(action_key: str) -> str: + """ + 根据动作标识构建用途文案。 + + :param action_key: 动作标识 + :return: 用途文案 + """ + normalized_key = str(action_key).strip().lower() + return ACTION_PURPOSE_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_action_preview_title(title_key: str) -> str: + """ + 根据预览区块标识构建标题文案。 + + :param title_key: 预览区块标识 + :return: 标题文案 + """ + normalized_key = str(title_key).strip().lower() + return ACTION_PREVIEW_TITLES.get(normalized_key, normalized_key) + + @staticmethod + def build_action_preview_field_label(field_key: str) -> str: + """ + 根据预览字段标识构建字段文案。 + + :param field_key: 预览字段标识 + :return: 字段文案 + """ + normalized_key = str(field_key).strip().lower() + return ACTION_PREVIEW_FIELD_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_action_result_field_label(field_key: str) -> str: + """ + 根据结果字段标识构建字段文案。 + + :param field_key: 结果字段标识 + :return: 字段文案 + """ + normalized_key = str(field_key).strip().lower() + return ACTION_RESULT_FIELD_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_action_notification_title() -> str: + """ + 构建动作通知标题文案。 + + :return: 通知标题文案 + """ + return ACTION_NOTIFICATION_COPY['title'] + + @staticmethod + def build_action_unavailable_message() -> str: + """ + 构建无可执行动作时的提示文案。 + + :return: 提示文案 + """ + return ACTION_NOTIFICATION_COPY['unavailable_message'] + + @staticmethod + def build_action_empty_line() -> str: + """ + 构建无可执行动作时的空态占位文案。 + + :return: 空态占位文案 + """ + return ACTION_NOTIFICATION_COPY['empty_line'] + + @staticmethod + def build_action_consequence_text(consequence_key: str) -> str: + """ + 根据后果说明标识构建说明文案。 + + :param consequence_key: 后果说明标识 + :return: 后果说明文案 + """ + normalized_key = str(consequence_key).strip().lower() + return ACTION_CONSEQUENCE_TEXTS.get(normalized_key, normalized_key) + + @staticmethod + def build_capability_label(capability_key: str) -> str: + """ + 根据能力标识构建展示标签文案。 + + :param capability_key: 能力标识 + :return: 展示标签文案 + """ + normalized_key = str(capability_key).strip().lower() + return CAPABILITY_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_capability_hint_label(capability_key: str) -> str: + """ + 根据能力标识构建顶部提示短标签文案。 + + :param capability_key: 能力标识 + :return: 短标签文案 + """ + normalized_key = str(capability_key).strip().lower() + return CAPABILITY_HINT_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_action_running_message(label: str) -> str: + """ + 构建动作执行中的提示文案。 + + :param label: 动作标题 + :return: 提示文案 + """ + return f'正在执行:{label}' + + @staticmethod + def build_browser_action_hint_text(view_key: str, interaction_hint: str) -> str: + """ + 构建浏览页动作提示文本。 + + :param view_key: 当前页面视图标识 + :param interaction_hint: 通用浏览提示 + :return: 动作提示文本 + """ + normalized_view = str(view_key).strip().lower() + template = ACTION_HINT_COPY['browser_templates'].get(normalized_view, ACTION_HINT_COPY['browser_default']) + return template.format(interaction_hint=interaction_hint) + + @staticmethod + def build_detail_action_hint_text(view_key: str, interaction_hint: str) -> str: + """ + 构建详情页动作提示文本。 + + :param view_key: 当前页面视图标识 + :param interaction_hint: 通用浏览提示 + :return: 动作提示文本 + """ + normalized_view = str(view_key).strip().lower() + template = ACTION_HINT_COPY['detail_templates'].get(normalized_view, ACTION_HINT_COPY['detail_default']) + return template.format(interaction_hint=interaction_hint) + + @staticmethod + def build_action_line(key: str, label: str) -> str: + """ + 构建单条动作快捷键信息。 + + :param key: 快捷键 + :param label: 动作标题 + :return: 文本文案 + """ + return f'动作键 [{key}] · {label}' + + @staticmethod + def build_capability_hint_text(capability_labels: list[str], interaction_hint: str, *, fallback: str) -> str: + """ + 根据能力标签列表构建统一动作提示文本。 + + :param capability_labels: 动作标签列表 + :param interaction_hint: 通用浏览提示 + :param fallback: 缺省提示模板 + :return: 动作提示文本 + """ + if not capability_labels: + return fallback.format(interaction_hint=interaction_hint) + return f'动作键:{" ".join(capability_labels)} | {interaction_hint}' + + @staticmethod + def build_action_result_message_line(label: str, value: str) -> str: + """ + 构建动作结果详情的单行文案。 + + :param label: 字段标题 + :param value: 字段值 + :return: 文本文案 + """ + return f'{label}: {value}' + + @staticmethod + def build_cli_command_hint(*command_args: str) -> str: + """ + 构建 TUI 中展示的 CLI 命令提示文本。 + + :param command_args: CLI 命令参数 + :return: 可直接复制执行的命令文本 + """ + return SHELL_TEXT_FORMATTER.format_shell_command(NESTED_CLI_SUPPORT.build_nested_cli_command(*command_args)) + + @staticmethod + def build_command_hint_lines( + *, + scenario: str, + command: str, + guide: str, + ) -> list[str]: + """ + 构建统一的命令入口提示文本。 + + :param scenario: 适用场景说明 + :param command: 推荐命令 + :param guide: 使用说明 + :return: 文本行列表 + """ + return [ + COMMAND_HINT_TITLES['scenario'], + scenario, + '', + COMMAND_HINT_TITLES['command'], + command, + '', + COMMAND_HINT_TITLES['guide'], + guide, + ] + + @staticmethod + def build_labeled_value_line(label: str, value: str) -> str: + """ + 构建通用的“标签: 值”文案。 + + :param label: 字段标题 + :param value: 字段值 + :return: 文本文案 + """ + return f'{label}: {value}' + + @staticmethod + def build_action_result_toast(label: str, outcome: str, message: str) -> str: + """ + 构建动作完成后的通知文案。 + + :param label: 动作标题 + :param outcome: 执行结果 + :param message: 结果摘要 + :return: 通知文案 + """ + return f'{ACTION_FEEDBACK_COPY["toast_prefix"]} · {label} · {outcome} · {message}' diff --git a/shierfeng-fastapi-backend/cli/tui/copy/fragments.py b/shierfeng-fastapi-backend/cli/tui/copy/fragments.py new file mode 100644 index 0000000..d1f32b5 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/copy/fragments.py @@ -0,0 +1,339 @@ +VIEW_LABELS = { + 'dashboard': '总览', + 'app': '应用', + 'ops': '运维', + 'database': '数据库', + 'cache': '缓存', + 'jobs': '任务', + 'gen': '代码生成', + 'configs': '参数配置', + 'crypto': '加密', +} + +STATUS_LABELS = { + 'ok': '正常', + 'success': '正常', + 'healthy': '正常', + 'fail': '失败', + 'error': '失败', + 'down': '失败', + 'warn': '警告', + 'warning': '警告', + 'degraded': '警告', + 'info': '信息', +} + +GENERAL_COPY = { + 'action_confirm_cancel_label': '取消', + 'action_confirm_hint': '按键提示 · [Enter] 确认执行 [Esc] 取消返回', + 'more_detail_hint': '> 进入对应页面查看更多', + 'tui_command_help': '进入只读巡检工作台', + 'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`', + 'tui_missing_dependency_message': '当前环境未安装 TUI 可选依赖,无法启动 `shierfeng tui`', +} + +STATE_SUGGESTIONS = { + 'dashboard_empty': '按 [R] 刷新,或进入对应页面查看更多上下文', + 'dashboard_failure': '按 [R] 刷新,或进入对应页面查看完整诊断信息', + 'empty': '可按 [R] 刷新,或切换到其他页面继续查看', + 'failure': '可按 [R] 刷新,或切换页面后重新进入', + 'loading': '可继续切换其他区域,当前内容加载完成后会自动刷新', +} + +PAGE_SUBTITLES = { + 'crypto': '查看运行校验、公钥身份和兼容版本信息', + 'ops': '查看基础连通性、依赖版本与服务器资源', +} + +NAVIGATION_DESCRIPTIONS = { + 'dashboard': '全链路态势与关键健康信号', + 'app': '环境解析、配置快照、补全诊断与路由摘要', + 'ops': '健康检查、依赖版本与服务器资源', + 'database': '连接、迁移版本与存储状态', + 'cache': 'Redis 容量、热点与命中信号', + 'jobs': '定时任务、执行轨迹与失败样本', + 'gen': '业务表、字段与生成配置', + 'configs': '配置巡检、漂移与异常值', + 'crypto': '传输加密校验、公钥身份与兼容版本', +} + +DASHBOARD_HERO_COPY = { + 'subtitle': '先看整体风险和巡检结论,再进入数据库、缓存、任务等分区继续定位', + 'title': '运行驾驶舱', +} + +STATUS_PANEL_COPY = { + 'empty': '暂无数据', +} + +ACTION_LABELS = { + 'app_run': '直接启动应用', + 'app_run_wizard': '打开启动向导', + 'cache_clear_wizard': '打开缓存清理向导', + 'cache_warmup': '执行缓存预热', + 'completion_install': '安装当前 Shell 补全', + 'config_sync': '刷新参数缓存', + 'crypto_keygen': '打开密钥生成入口', + 'crypto_rotate_dry_run': '执行轮换预演', + 'db_init_dry_run': '执行初始化预演', + 'db_upgrade_wizard': '打开升级向导', + 'gen_export_dry_run': '执行导出预演', + 'gen_export_wizard': '打开导出向导', + 'gen_import_wizard': '打开导入向导', + 'gen_sync_db': '同步数据库表结构', + 'job_pause': '暂停任务', + 'job_resume': '恢复任务', + 'job_run_once': '执行一次任务', + 'job_sync': '同步调度配置', + 'ops_ping_db': '执行数据库探活', + 'ops_ping_redis': '执行 Redis 探活', + 'prod_check_wizard': '打开生产巡检向导', +} + +ACTION_SCOPE_LABELS = { + 'cache_warmup': '系统字典与系统参数基础缓存', + 'completion_install': '当前终端活动 Shell 的补全安装流程', + 'config_sync': '当前环境全部系统参数', + 'db_init_dry_run': '当前环境数据库初始化流程', + 'gen_export_dry_run': '当前业务表的代码导出结果预演', + 'gen_sync_db': '当前业务表对应的生成配置和物理表结构', + 'job_sync': '当前环境调度器配置', + 'ops_ping_db': '当前环境数据库连接与基础读写探活', + 'ops_ping_redis': '当前环境 Redis 连接与基础命令探活', +} + +ACTION_PURPOSE_LABELS = { + 'cache_warmup': '重建运行所需的基础缓存内容', + 'completion_install': '自动识别当前 Shell,写入补全脚本并按需更新 rc 文件激活命令', + 'config_sync': '将数据库中的参数值重新写入 Redis 缓存', + 'db_init_dry_run': '仅预演初始化到最新迁移版本,不直接执行真实升级', + 'gen_export_dry_run': '先演练导出流程并确认输出模式、模板数量和结果摘要', + 'gen_sync_db': '将数据库中的最新字段结构同步回当前业务表配置', + 'job_sync': '将最新任务配置同步到调度执行器', + 'ops_ping_db': '快速确认当前环境数据库是否可连接并返回基础探活结果', + 'ops_ping_redis': '快速确认当前环境 Redis 是否可连接并返回基础命令探活结果', +} + +ACTION_PREVIEW_TITLES = { + 'command': '## 执行命令', + 'consequence': '## 确认后果', + 'env': '## 执行环境', + 'summary': '## 动作信息', +} + +ACTION_PREVIEW_FIELD_LABELS = { + 'current_status': '当前状态', + 'job': '任务', + 'job_id': '任务 ID', + 'purpose': '用途', + 'scope': '作用范围', + 'target_action': '目标动作', + 'target_env': '目标环境', +} + +ACTION_RESULT_FIELD_LABELS = { + 'count': '影响数量', + 'exit_code': '退出码', + 'fail': '失败', + 'hint': '建议', + 'job_id': '任务 ID', + 'name': '动作名称', + 'operation': '操作标签', + 'outcome': '结果', + 'service': '服务反馈', + 'success': '成功', + 'summary': '摘要', +} + +ACTION_CONSEQUENCE_TEXTS = { + 'external': '确认后会暂时挂起 TUI,并在当前终端中执行对应命令;命令结束后自动返回工作台。', + 'preview': '确认后会立即执行对应 CLI 低风险命令,并按结果刷新当前页面。', + 'wizard': '确认后会暂时挂起 TUI,并在当前终端中打开对应向导;向导结束后自动返回工作台。', +} + +APP_BINDING_LABELS = { + 'next': '下一页', + 'previous': '上一页', + 'quit': '退出', + 'refresh': '刷新', + 'sidebar': '侧边栏', +} + +INTERNAL_BINDING_LABELS = { + 'action_global': '同步动作', + 'action_primary': '执行动作', + 'action_secondary': '切换状态', + 'action_utility': '工具动作', + 'clear_search': '清空搜索', + 'confirm_cancel': '取消', + 'confirm_submit': '确认', + 'end': '跳到末尾', + 'filter_1': '筛选项 1', + 'filter_2': '筛选项 2', + 'filter_3': '筛选项 3', + 'filter_4': '筛选项 4', + 'focus_left': '向左聚焦', + 'focus_right': '向右聚焦', + 'home': '回到顶部', + 'page_down': '下翻页', + 'page_up': '上翻页', + 'scroll_down': '向下滚动', + 'scroll_up': '向上滚动', + 'search': '页内搜索', +} + +BROWSER_EMPTY_RECORD_COPY = { + 'detail': '当前页面没有可浏览记录', + 'label': '记录列表', + 'summary': '当前页面没有可浏览记录', + 'title': '暂无记录数据', + 'value': '暂无数据', +} + +BROWSER_LOADING_COPY = { + 'detail': '正在后台加载当前记录详情,请稍候', + 'label': '详情状态', + 'title': '详情加载中', + 'value': '加载中', +} + +DETAIL_EMPTY_SECTION_COPY = { + 'detail_page': '当前页面没有可展示的分区详情', + 'detail_record': '当前记录没有可展示的分区详情', + 'label': '分区详情', + 'title': '暂无分区数据', + 'value': '暂无内容', +} + +CAPABILITY_LABELS = { + 'app_run': ACTION_LABELS['app_run'], + 'app_run_wizard': ACTION_LABELS['app_run_wizard'], + 'cache_clear_wizard': ACTION_LABELS['cache_clear_wizard'], + 'cache_warmup': ACTION_LABELS['cache_warmup'], + 'completion_install': ACTION_LABELS['completion_install'], + 'config_sync': ACTION_LABELS['config_sync'], + 'crypto_keygen': ACTION_LABELS['crypto_keygen'], + 'crypto_rotate_dry_run': ACTION_LABELS['crypto_rotate_dry_run'], + 'db_init_dry_run': ACTION_LABELS['db_init_dry_run'], + 'db_upgrade_wizard': ACTION_LABELS['db_upgrade_wizard'], + 'gen_export_dry_run': ACTION_LABELS['gen_export_dry_run'], + 'gen_export_wizard': ACTION_LABELS['gen_export_wizard'], + 'gen_import_wizard': ACTION_LABELS['gen_import_wizard'], + 'gen_sync_db': ACTION_LABELS['gen_sync_db'], + 'job_run_once': ACTION_LABELS['job_run_once'], + 'job_toggle': '暂停/恢复任务', + 'job_sync': ACTION_LABELS['job_sync'], + 'ops_ping_db': ACTION_LABELS['ops_ping_db'], + 'ops_ping_redis': ACTION_LABELS['ops_ping_redis'], + 'prod_check_wizard': ACTION_LABELS['prod_check_wizard'], +} + +CAPABILITY_HINT_LABELS = { + 'app_run': '直接启动', + 'app_run_wizard': '打开启动向导', + 'cache_clear_wizard': '清理向导', + 'cache_warmup': '执行缓存预热', + 'completion_install': '安装补全', + 'config_sync': '刷新参数缓存', + 'crypto_keygen': '密钥生成', + 'crypto_rotate_dry_run': '执行轮换预演', + 'db_init_dry_run': '初始化预演', + 'db_upgrade_wizard': '打开升级向导', + 'gen_export_dry_run': '导出预演', + 'gen_export_wizard': '导出向导', + 'gen_import_wizard': '导入向导', + 'gen_sync_db': '同步表结构', + 'job_run_once': '执行一次', + 'job_toggle': '暂停/恢复', + 'job_sync': '同步调度', + 'ops_ping_db': '数据库探活', + 'ops_ping_redis': 'Redis 探活', + 'prod_check_wizard': '打开生产巡检向导', +} + +WORKSPACE_LABELS = { + 'current_section': '当前分区', + 'menu': '菜单', + 'overview': '概览', + 'record': '记录', + 'section': '分区', + 'shortcut': '快捷键', + 'status': '状态', +} + +WORKSPACE_TITLES = { + 'action_feedback': '【执行反馈】', + 'detail_content': '详情内容', + 'key_fields': '【关键字段】', + 'key_info': '【关键信息】', +} + +WORKSPACE_EMPTY_TEXTS = { + 'actions': '暂无可执行动作', + 'detail': '暂无详情', + 'key_info': '暂无关键信息', +} + +STATE_SECTION_TITLES = { + 'detail': '## 补充说明', + 'diagnostic': '## 诊断线索', + 'error': '## 错误摘要', + 'status': '## 当前状态', + 'suggestion': '## 下一步', +} + +ACTION_NOTIFICATION_COPY = { + 'empty_line': '当前记录没有可执行的低风险动作', + 'title': 'TUI 动作', + 'unavailable_message': '当前页面没有可执行的对应快捷动作', +} + +BROWSER_ACTION_PANEL_COPY = { + 'action_section_title': '## 可执行动作', + 'operation_section_title': '## 浏览操作', + 'recent_action_section_title': '## 最近动作反馈', +} + +COMMAND_HINT_TITLES = { + 'command': '## 推荐命令', + 'guide': '## 使用说明', + 'scenario': '## 适用场景', +} + +SIGNAL_RAIL_COPY = { + 'empty': '暂无实时信号', + 'title': '系统信号带', +} + +METRIC_COPY = { + 'hint_label': '说明', + 'label': '指标', + 'value_label': '数值', +} + +ACTION_HINT_COPY = { + 'browser_default': '当前页面以浏览为主。{interaction_hint}', + 'browser_templates': { + 'cache': '建议先核对 Redis 概览与键值样本,再决定预热或进入清理向导。{interaction_hint}', + 'configs': '建议先看高风险配置,再决定是否刷新参数缓存。{interaction_hint}', + 'gen': '建议先看生成前校验、同步预检查与代码预览,再决定是否进入导入向导、同步表结构或进入导出向导。{interaction_hint}', + 'jobs': '建议先看失败聚合,再决定执行一次、暂停恢复或同步调度。{interaction_hint}', + }, + 'detail_default': '当前页面以查看分区为主。{interaction_hint}', + 'detail_templates': { + 'app': '建议先确认环境映射、应用配置、启动前检查、补全诊断和路由状态,再决定是安装补全、直接启动应用还是进入启动向导。{interaction_hint}', + 'crypto': '建议先确认运行校验、公钥身份和兼容版本,再决定是否生成新密钥或执行轮换预演。{interaction_hint}', + 'database': '建议先确认 revision、Heads 和历史链路,再决定是否执行初始化预演或进入升级向导。{interaction_hint}', + 'ops': '建议先确认数据库探活、Redis 探活、依赖版本和服务器资源,再决定是否进入生产巡检。{interaction_hint}', + }, + 'operation_hint_lines': [ + '> [←/→] 切换焦点', + '> [J/K] 向上/向下滚动', + '> [PgUp/PgDn] 整页滚动', + '> [Home/End] 跳到首尾', + ], +} + +ACTION_FEEDBACK_COPY = { + 'toast_prefix': '动作结果', +} diff --git a/shierfeng-fastapi-backend/cli/tui/copy/navigation.py b/shierfeng-fastapi-backend/cli/tui/copy/navigation.py new file mode 100644 index 0000000..f335066 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/copy/navigation.py @@ -0,0 +1,133 @@ +from cli.tui.copy.fragments import ( + APP_BINDING_LABELS, + GENERAL_COPY, + INTERNAL_BINDING_LABELS, + NAVIGATION_DESCRIPTIONS, + STATUS_LABELS, + VIEW_LABELS, +) + + +class TuiNavigationCopyMixin: + """ + TUI 导航与基础标签文案混入。 + """ + + @staticmethod + def render_view_label(view_key: str) -> str: + """ + 将内部视图标识转换为中文显示名称。 + + :param view_key: 视图标识 + :return: 中文视图名称 + """ + normalized_view = str(view_key).strip().lower() + return VIEW_LABELS.get(normalized_view, view_key) + + @staticmethod + def render_status_label(status: str, fallback: str = '信息') -> str: + """ + 将状态值转换为中文显示文本。 + + :param status: 原始状态值 + :param fallback: 缺失时兜底文本 + :return: 中文状态文本 + """ + normalized_status = str(status).strip().lower() + if not normalized_status: + return fallback + return STATUS_LABELS.get(normalized_status, str(status).strip().upper()) + + @staticmethod + def render_status_code(status: str) -> str: + """ + 将状态值转换为控制台风格状态码。 + + :param status: 原始状态值 + :return: 状态码文本 + """ + normalized_status = str(status).strip().lower() + status_code_mapping = { + 'ok': 'SYS.OK', + 'success': 'SYS.OK', + 'healthy': 'SYS.OK', + 'fail': 'SYS.FAIL', + 'error': 'SYS.FAIL', + 'down': 'SYS.FAIL', + 'warn': 'SYS.WARN', + 'warning': 'SYS.WARN', + 'degraded': 'SYS.WARN', + 'info': 'SYS.INFO', + } + return status_code_mapping.get(normalized_status, f'SYS.{normalized_status.upper() or "INFO"}') + + @staticmethod + def render_navigation_description(view_key: str) -> str: + """ + 获取指定视图的导航说明。 + + :param view_key: 视图标识 + :return: 导航说明 + """ + normalized_view = str(view_key).strip().lower() + return NAVIGATION_DESCRIPTIONS.get(normalized_view, '') + + @staticmethod + def build_tui_command_help() -> str: + """ + 构建 `shierfeng tui` 命令帮助文案。 + + :return: 命令帮助文案 + """ + return GENERAL_COPY['tui_command_help'] + + @staticmethod + def build_missing_dependency_message() -> str: + """ + 构建 TUI 可选依赖缺失时的提示消息。 + + :return: 缺依赖提示消息 + """ + return GENERAL_COPY['tui_missing_dependency_message'] + + @staticmethod + def build_missing_dependency_hint() -> str: + """ + 构建 TUI 可选依赖缺失时的恢复建议。 + + :return: 恢复建议文案 + """ + return GENERAL_COPY['tui_missing_dependency_hint'] + + @staticmethod + def build_app_binding_label(binding_key: str) -> str: + """ + 根据绑定标识构建应用级快捷键标签文案。 + + :param binding_key: 绑定标识 + :return: 快捷键标签文案 + """ + normalized_key = str(binding_key).strip().lower() + return APP_BINDING_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_confirm_binding_label(binding_key: str) -> str: + """ + 根据确认弹窗绑定标识构建快捷键标签文案。 + + :param binding_key: 绑定标识 + :return: 快捷键标签文案 + """ + normalized_key = str(binding_key).strip().lower() + return INTERNAL_BINDING_LABELS.get(f'confirm_{normalized_key}', normalized_key) + + @staticmethod + def build_internal_binding_label(binding_key: str) -> str: + """ + 根据内部交互绑定标识构建快捷键标签文案。 + + :param binding_key: 绑定标识 + :return: 快捷键标签文案 + """ + normalized_key = str(binding_key).strip().lower() + return INTERNAL_BINDING_LABELS.get(normalized_key, normalized_key) diff --git a/shierfeng-fastapi-backend/cli/tui/copy/workspace.py b/shierfeng-fastapi-backend/cli/tui/copy/workspace.py new file mode 100644 index 0000000..e4f0ff1 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/copy/workspace.py @@ -0,0 +1,593 @@ +from cli.tui.copy.fragments import ( + ACTION_HINT_COPY, + BROWSER_ACTION_PANEL_COPY, + BROWSER_EMPTY_RECORD_COPY, + BROWSER_LOADING_COPY, + DASHBOARD_HERO_COPY, + DETAIL_EMPTY_SECTION_COPY, + GENERAL_COPY, + METRIC_COPY, + SIGNAL_RAIL_COPY, + STATE_SECTION_TITLES, + STATE_SUGGESTIONS, + STATUS_PANEL_COPY, + WORKSPACE_EMPTY_TEXTS, + WORKSPACE_LABELS, + WORKSPACE_TITLES, +) + + +class TuiWorkspaceCopyMixin: + """ + TUI 工作区与状态文案混入。 + """ + + @staticmethod + def build_workspace_label(label_key: str) -> str: + """ + 根据工作台标签标识构建文案。 + + :param label_key: 标签标识 + :return: 标签文案 + """ + normalized_key = str(label_key).strip().lower() + return WORKSPACE_LABELS.get(normalized_key, normalized_key) + + @staticmethod + def build_workspace_title(title_key: str) -> str: + """ + 根据工作台标题标识构建文案。 + + :param title_key: 标题标识 + :return: 标题文案 + """ + normalized_key = str(title_key).strip().lower() + return WORKSPACE_TITLES.get(normalized_key, normalized_key) + + @staticmethod + def build_workspace_empty_text(text_key: str) -> str: + """ + 根据工作台空态标识构建文案。 + + :param text_key: 空态标识 + :return: 空态文案 + """ + normalized_key = str(text_key).strip().lower() + return WORKSPACE_EMPTY_TEXTS.get(normalized_key, normalized_key) + + @staticmethod + def build_status_panel_empty_text() -> str: + """ + 构建状态面板空态提示文案。 + + :return: 空态提示文案 + """ + return STATUS_PANEL_COPY['empty'] + + @staticmethod + def build_signal_rail_empty_text() -> str: + """ + 构建信号带空态提示文案。 + + :return: 空态提示文案 + """ + return SIGNAL_RAIL_COPY['empty'] + + @staticmethod + def build_more_detail_hint() -> str: + """ + 构建查看更多提示文案。 + + :return: 提示文案 + """ + return GENERAL_COPY['more_detail_hint'] + + @staticmethod + def build_empty_state_suggestion() -> str: + """ + 构建通用空态建议文案。 + + :return: 建议文案 + """ + return STATE_SUGGESTIONS['empty'] + + @staticmethod + def build_loading_state_suggestion() -> str: + """ + 构建通用加载态建议文案。 + + :return: 建议文案 + """ + return STATE_SUGGESTIONS['loading'] + + @staticmethod + def build_failure_state_suggestion() -> str: + """ + 构建通用失败态建议文案。 + + :return: 建议文案 + """ + return STATE_SUGGESTIONS['failure'] + + @staticmethod + def build_dashboard_failure_suggestion() -> str: + """ + 构建首页失败态建议文案。 + + :return: 建议文案 + """ + return STATE_SUGGESTIONS['dashboard_failure'] + + @staticmethod + def build_dashboard_empty_suggestion() -> str: + """ + 构建首页空态建议文案。 + + :return: 建议文案 + """ + return STATE_SUGGESTIONS['dashboard_empty'] + + @staticmethod + def build_state_section_title(title_key: str) -> str: + """ + 根据状态区块标识构建标题文案。 + + :param title_key: 区块标识 + :return: 标题文案 + """ + normalized_key = str(title_key).strip().lower() + return STATE_SECTION_TITLES.get(normalized_key, normalized_key) + + @staticmethod + def build_dashboard_hero_title() -> str: + """ + 构建首页 Hero 标题文案。 + + :return: 标题文案 + """ + return DASHBOARD_HERO_COPY['title'] + + @staticmethod + def build_dashboard_hero_subtitle() -> str: + """ + 构建首页 Hero 副标题文案。 + + :return: 副标题文案 + """ + return DASHBOARD_HERO_COPY['subtitle'] + + @staticmethod + def build_browser_empty_record_copy(field_key: str) -> str: + """ + 根据字段标识构建浏览页空记录文案。 + + :param field_key: 字段标识 + :return: 文案 + """ + normalized_key = str(field_key).strip().lower() + return BROWSER_EMPTY_RECORD_COPY.get(normalized_key, normalized_key) + + @staticmethod + def build_browser_loading_copy(field_key: str) -> str: + """ + 根据字段标识构建浏览页加载占位文案。 + + :param field_key: 字段标识 + :return: 文案 + """ + normalized_key = str(field_key).strip().lower() + return BROWSER_LOADING_COPY.get(normalized_key, normalized_key) + + @staticmethod + def build_detail_empty_section_copy(field_key: str) -> str: + """ + 根据字段标识构建详情页空态文案。 + + :param field_key: 字段标识 + :return: 文案 + """ + normalized_key = str(field_key).strip().lower() + return DETAIL_EMPTY_SECTION_COPY.get(normalized_key, normalized_key) + + @staticmethod + def build_refresh_page_suggestion(page_name: str, detail: str) -> str: + """ + 构建详情页场景下的统一建议操作文案。 + + :param page_name: 页面名称 + :param detail: 后续动作描述 + :return: 建议文案 + """ + return f'可按 [R] 刷新,或进入{page_name}页面{detail}' + + @staticmethod + def build_dashboard_page_suggestion(page_name: str, detail: str) -> str: + """ + 构建首页总览面板场景下的统一建议操作文案。 + + :param page_name: 页面名称 + :param detail: 后续动作描述 + :return: 建议文案 + """ + return f'按 [R] 刷新,或进入{page_name}页面{detail}' + + @staticmethod + def build_unavailable_record_title(resource_name: str) -> str: + """ + 构建浏览页失败兜底记录标题。 + + :param resource_name: 资源名称 + :return: 标题文本 + """ + return f'{resource_name}数据暂不可用' + + @staticmethod + def build_load_failure_section_title(resource_name: str) -> str: + """ + 构建浏览页失败兜底分区标题。 + + :param resource_name: 资源名称 + :return: 分区标题 + """ + return f'{resource_name}加载失败' + + @staticmethod + def build_empty_record_title(resource_name: str) -> str: + """ + 构建浏览页空记录标题。 + + :param resource_name: 资源名称 + :return: 标题文本 + """ + return f'暂无{resource_name}' + + @staticmethod + def build_unavailable_subtitle(resource_name: str, message: str) -> str: + """ + 构建浏览页数据不可用副标题。 + + :param resource_name: 资源名称 + :param message: 错误摘要 + :return: 副标题文本 + """ + return f'{resource_name}数据不可用:{message}' + + @staticmethod + def build_empty_record_summary(detail: str) -> str: + """ + 构建浏览页空记录摘要。 + + :param detail: 摘要说明 + :return: 摘要文本 + """ + return detail + + @staticmethod + def build_loaded_collection_subtitle(count: int, unit: str, detail: str) -> str: + """ + 构建浏览页“已加载”副标题。 + + :param count: 数量 + :param unit: 单位 + :param detail: 补充说明 + :return: 副标题文本 + """ + return f'已加载 {count} {unit},{detail}' + + @staticmethod + def build_summary_with_message(summary: str, message: str) -> str: + """ + 构建带结果摘要补充的副标题。 + + :param summary: 主摘要 + :param message: 结果消息 + :return: 副标题文本 + """ + return f'{summary} | {message}' + + @staticmethod + def build_count_detail_subtitle(prefix: str, count: int, unit: str, suffix: str) -> str: + """ + 构建带数量的详情页副标题。 + + :param prefix: 前缀文本 + :param count: 数量 + :param unit: 数量单位 + :param suffix: 后缀文本 + :return: 副标题文本 + """ + return f'{prefix} {count} {unit}{suffix}' + + @staticmethod + def build_value_detail_subtitle(prefix: str, value: str, suffix: str) -> str: + """ + 构建带单值信息的详情页副标题。 + + :param prefix: 前缀文本 + :param value: 值文本 + :param suffix: 后缀文本 + :return: 副标题文本 + """ + return f'{prefix} {value}{suffix}' + + @staticmethod + def build_workspace_hero_lines( + *, + view_label: str, + title: str, + subtitle: str, + env: str, + summary: str, + refreshed_at: str, + shortcut_hint: str, + ) -> list[str]: + """ + 构建工作区顶部摘要文本行。 + + :param view_label: 当前页面显示名称 + :param title: 页面标题 + :param subtitle: 页面副标题 + :param env: 当前环境 + :param summary: 页面摘要 + :param refreshed_at: 刷新时间 + :param shortcut_hint: 快捷键提示 + :return: 文本行列表 + """ + return [ + f'[{view_label} 控制台]', + title, + subtitle, + '', + f'运行环境 · {env}', + f'当前页面 · {view_label}', + f'运行摘要 · {summary}', + f'操作提示 · {shortcut_hint}', + f'最近刷新 · {refreshed_at}', + ] + + @staticmethod + def build_navigation_item_lines(index: int, label: str, shortcut: str, description: str) -> list[str]: + """ + 构建左侧导航项文案。 + + :param index: 导航索引 + :param label: 导航标题 + :param shortcut: 快捷键 + :param description: 导航说明 + :return: 文本行列表 + """ + return [ + f'▌ {WORKSPACE_LABELS["menu"]} {index + 1:02d} · {label}', + f'│ {WORKSPACE_LABELS["shortcut"]} [{shortcut.upper()}] · {description}', + ] + + @staticmethod + def build_section_item_lines(index: int, title: str, status_badge: str, preview: str) -> list[str]: + """ + 构建详情页分区导航项文案。 + + :param index: 分区索引 + :param title: 分区标题 + :param status_badge: 状态徽标 + :param preview: 预览文本 + :return: 文本行列表 + """ + return [ + f'▌ {WORKSPACE_LABELS["section"]} {index + 1:02d} · {title}', + f'│ {status_badge} {preview}', + ] + + @staticmethod + def build_record_item_lines(index: int, title: str, status_badge: str, summary: str) -> list[str]: + """ + 构建浏览页记录导航项文案。 + + :param index: 记录索引 + :param title: 记录标题 + :param status_badge: 状态徽标 + :param summary: 记录摘要 + :return: 文本行列表 + """ + return [ + f'▌ {WORKSPACE_LABELS["record"]} {index + 1:02d} · {title}', + f'│ {status_badge} {summary}', + ] + + @staticmethod + def build_section_detail_lines(title: str, status_label: str, status_badge: str, body: str) -> list[str]: + """ + 构建详情页右侧分区内容文案。 + + :param title: 分区标题 + :param status_label: 分区状态 + :param status_badge: 状态徽标 + :param body: 主体文本 + :return: 文本行列表 + """ + return [ + f'{status_badge} {title}', + f'{WORKSPACE_LABELS["status"]} · {status_label}', + '', + body, + ] + + @staticmethod + def build_browser_action_panel_lines(action_lines: list[str], feedback_lines: list[str]) -> list[str]: + """ + 构建浏览页动作面板文案。 + + :param action_lines: 可执行动作文本 + :param feedback_lines: 最近动作反馈文本 + :return: 文本行列表 + """ + lines = [ + BROWSER_ACTION_PANEL_COPY['action_section_title'], + *[f'> {line}' for line in action_lines], + '', + BROWSER_ACTION_PANEL_COPY['operation_section_title'], + ] + lines.extend(ACTION_HINT_COPY['operation_hint_lines']) + if feedback_lines: + lines.extend( + [ + '', + BROWSER_ACTION_PANEL_COPY['recent_action_section_title'], + *[f'> {line}' for line in feedback_lines], + ] + ) + return lines + + @staticmethod + def build_status_panel_text(title: str, status_code: str, status_label: str, body: str) -> str: + """ + 构建状态面板渲染文本。 + + :param title: 面板标题 + :param status_code: 状态码 + :param status_label: 状态标签 + :param body: 面板正文 + :return: 渲染文本 + """ + return f'{status_code} {title}\n{WORKSPACE_LABELS["status"]} · {status_label}\n\n{body}' + + @staticmethod + def build_metric_panel_text(title: str, value: str, status_code: str, status_label: str, hint: str) -> str: + """ + 构建指标卡渲染文本。 + + :param title: 指标标题 + :param value: 指标值 + :param status_code: 状态码 + :param status_label: 状态标签 + :param hint: 提示文本 + :return: 渲染文本 + """ + return ( + f'{METRIC_COPY["label"]} · {title}\n' + f'────────────────────────\n' + f'{METRIC_COPY["value_label"]} · {value}\n' + f'{WORKSPACE_LABELS["status"]} · {status_code} / {status_label}\n' + f'{METRIC_COPY["hint_label"]} · {hint}' + ) + + @staticmethod + def build_signal_rail_text(pulse: str, body: str) -> str: + """ + 构建首页信号带渲染文本。 + + :param pulse: 当前脉冲字符 + :param body: 主体文本 + :return: 渲染文本 + """ + return '\n'.join( + [ + f'{SIGNAL_RAIL_COPY["title"]} {pulse}', + '────────────────────────', + body or SIGNAL_RAIL_COPY['empty'], + ] + ) + + @staticmethod + def build_workspace_header_lines( + *, + env: str, + view_label: str, + timestamp: str, + shortcut_hint: str, + ) -> list[str]: + """ + 构建工作区顶部状态栏文本行。 + + :param env: 当前环境 + :param view_label: 当前页面显示名称 + :param timestamp: 当前时间 + :param shortcut_hint: 快捷键提示 + :return: 文本行列表 + """ + return [ + f'ShiErFeng 控制台 · 环境 {env.upper()} · 页面 {view_label}', + f'快捷键 · {shortcut_hint} 时间 {timestamp}', + ] + + @staticmethod + def build_dashboard_signal_lines( + *, + status_track: str, + fail_count: int, + warn_count: int, + ok_count: int, + total_count: int, + navigation_shortcut_hint: str, + ) -> list[str]: + """ + 构建首页信号带文案。 + + :param status_track: 面板状态轨迹 + :param fail_count: 失败数量 + :param warn_count: 警告数量 + :param ok_count: 正常数量 + :param total_count: 总数量 + :param navigation_shortcut_hint: 页面快捷键提示 + :return: 文本行列表 + """ + return [ + f'面板轨迹 · {status_track or "-"}', + f'风险分布 · 失败 {fail_count:02d} 警告 {warn_count:02d} 正常 {ok_count:02d} 合计 {total_count:02d}', + f'快捷入口 · 页面 {navigation_shortcut_hint}', + ] + + @staticmethod + def build_dashboard_summary_text(total_count: int, ok_count: int, warn_count: int, fail_count: int) -> str: + """ + 构建首页摘要文本。 + + :param total_count: 面板总数 + :param ok_count: 正常数量 + :param warn_count: 警告数量 + :param fail_count: 失败数量 + :return: 摘要文本 + """ + posture = '存在风险' if fail_count else '需要关注' if warn_count else '运行稳定' + return ( + f'当前态势:{posture}。共 {total_count} 个面板,正常 {ok_count} 个,' + f'警告 {warn_count} 个,失败 {fail_count} 个。' + ) + + @staticmethod + def build_detail_summary_text(total_count: int, ok_count: int, warn_count: int, fail_count: int) -> str: + """ + 构建详情页摘要文本。 + + :param total_count: 分区总数 + :param ok_count: 正常数量 + :param warn_count: 警告数量 + :param fail_count: 失败数量 + :return: 摘要文本 + """ + return ( + f'当前页共 {total_count} 个分区,正常 {ok_count} 个,警告 {warn_count} 个,失败 {fail_count} 个。' + '左侧上下切页,左右切换当前焦点区域。' + ) + + @staticmethod + def build_browser_summary_text( + total_count: int, + ok_count: int, + warn_count: int, + fail_count: int, + action_hint: str, + ) -> str: + """ + 构建浏览页摘要文本。 + + :param total_count: 记录总数 + :param ok_count: 正常数量 + :param warn_count: 警告数量 + :param fail_count: 失败数量 + :param action_hint: 动作提示文案 + :return: 摘要文本 + """ + return ( + f'当前页共 {total_count} 条记录,正常 {ok_count} 条,警告 {warn_count} 条,失败 {fail_count} 条。' + f'切换记录后,右侧会联动展示概览与分区详情。{action_hint}' + ) diff --git a/shierfeng-fastapi-backend/cli/tui/diagnostics.py b/shierfeng-fastapi-backend/cli/tui/diagnostics.py new file mode 100644 index 0000000..05f8b3d --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/diagnostics.py @@ -0,0 +1,581 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from cli.utils import SHELL_TEXT_FORMATTER + + +@dataclass(frozen=True) +class DiagnosticPagePolicy: + """ + TUI 页面诊断策略定义。 + + :param focus_terms: 页面聚焦词 + :param subtitle_builder: 页面摘要构建函数 + """ + + focus_terms: tuple[str, ...] + subtitle_builder: Callable[..., str] + + +@dataclass(frozen=True) +class DiagnosticPagePolicyRegistry: + """ + TUI 页面诊断策略注册表。 + + :param policies: 页面诊断策略映射 + """ + + policies: dict[str, DiagnosticPagePolicy] + + def get(self, page_key: str) -> DiagnosticPagePolicy: + """ + 读取指定页面诊断策略。 + + :param page_key: 页面标识 + :return: 页面诊断策略 + :raises KeyError: 页面未注册时抛出异常 + """ + return self.policies[page_key] + + +class TuiDiagnosticService: + """ + 统一封装 TUI 页面诊断提示与摘要拼装逻辑。 + + 该服务通过页面级诊断策略注册表管理各页面的聚焦词和 subtitle 规则, + 供 adapter、dashboard 等对象复用。 + + :param page_policy_registry: 页面诊断策略注册表 + """ + + def __init__(self, page_policy_registry: DiagnosticPagePolicyRegistry) -> None: + """ + 初始化诊断服务。 + + :param page_policy_registry: 页面诊断策略注册表 + :return: None + """ + self.page_policy_registry = page_policy_registry + + @staticmethod + def build_focus_hint(terms: tuple[str, ...]) -> str: + """ + 将诊断聚焦词渲染为统一提示文本。 + + :param terms: 聚焦词元组 + :return: 提示文本 + """ + return f'聚焦:{" / ".join(terms)}' + + def build_page_focus_hint(self, page_key: str) -> str: + """ + 构建指定页面统一聚焦词。 + + :param page_key: 页面标识 + :return: 聚焦提示 + """ + return self.build_focus_hint(self.page_policy_registry.get(page_key).focus_terms) + + def build_database_focus_hint(self) -> str: + """ + 构建数据库页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('database') + + def build_ops_focus_hint(self) -> str: + """ + 构建运维页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('ops') + + def build_cache_focus_hint(self) -> str: + """ + 构建缓存页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('cache') + + def build_app_focus_hint(self) -> str: + """ + 构建应用页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('app') + + def build_jobs_focus_hint(self) -> str: + """ + 构建任务页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('jobs') + + def build_configs_focus_hint(self) -> str: + """ + 构建参数配置页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('configs') + + def build_gen_focus_hint(self) -> str: + """ + 构建代码生成页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('gen') + + def build_crypto_focus_hint(self) -> str: + """ + 构建加密页统一聚焦词。 + + :return: 聚焦提示 + """ + return self.build_page_focus_hint('crypto') + + @staticmethod + def _build_app_subtitle( + env_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + routes_payload: dict[str, Any] | None, + completion_payload: dict[str, Any] | None, + *, + focus_hint: str, + ) -> str: + """ + 构建应用页诊断摘要。 + + :param env_payload: `app env` 结果 + :param config_payload: `app config` 结果 + :param doctor_payload: `app doctor` 结果 + :param routes_payload: `app routes` 结果 + :param completion_payload: `completion doctor` 结果 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + env_ok = bool(isinstance(env_payload, dict) and env_payload.get('ok', False)) + config_ok = bool(isinstance(config_payload, dict) and config_payload.get('ok', False)) + doctor_ok = bool(isinstance(doctor_payload, dict) and doctor_payload.get('ok', False)) + routes_ok = bool(isinstance(routes_payload, dict) and routes_payload.get('ok', False)) + completion_ok = bool(isinstance(completion_payload, dict) and completion_payload.get('ok', False)) + route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0 + + if not env_ok or not config_ok: + return f'{focus_hint} | 应用基础信息读取异常,优先确认环境映射与配置摘要' + if not doctor_ok: + return f'{focus_hint} | 启动前检查异常,优先确认数据库、Redis 与加密组件状态' + if not completion_ok: + return f'{focus_hint} | 补全诊断读取异常,优先确认项目目录与 shell 补全配置' + if not routes_ok: + return f'{focus_hint} | 路由摘要读取异常,优先确认应用初始化与路由注册状态' + return ( + f'{focus_hint} | 当前已加载 {route_count} 条注册路由,' + '可继续查看环境映射、配置摘要、启动前检查、补全诊断与路由状态' + ) + + @staticmethod + def _build_database_subtitle( + revision_payload: dict[str, Any] | None, + check_payload: dict[str, Any] | None, + heads_payload: dict[str, Any] | None, + *, + focus_hint: str, + ) -> str: + """ + 构建数据库页诊断摘要。 + + :param revision_payload: `db current` 结果 + :param check_payload: `db check` 结果 + :param heads_payload: `db heads` 结果 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + revision = revision_payload.get('currentRevision', '-') if isinstance(revision_payload, dict) else '-' + revision_text = SHELL_TEXT_FORMATTER.truncate_text(str(revision or '-'), 48) + revision_ok = bool(isinstance(revision_payload, dict) and revision_payload.get('ok', False)) + check_ok = bool(isinstance(check_payload, dict) and check_payload.get('ok', False)) + heads_ok = bool(isinstance(heads_payload, dict) and heads_payload.get('ok', False)) + heads_items = heads_payload.get('items') if isinstance(heads_payload, dict) else None + heads_count = len(heads_items) if isinstance(heads_items, list) else 0 + + if not revision_ok or not check_ok: + return f'{focus_hint} | 数据库异常,优先确认迁移版本、连接状态与 heads 信息' + if not heads_ok or heads_count != 1: + return f'{focus_hint} | 数据库存在迁移分叉风险,优先确认 heads 和历史版本' + return f'{focus_hint} | 数据库基线正常,当前 revision {revision_text},可继续查看连接、heads 和历史版本' + + @staticmethod + def _build_ops_subtitle( + health_payload: dict[str, Any] | None, + deps_payload: dict[str, Any] | None, + server_payload: dict[str, Any] | None, + *, + focus_hint: str, + ) -> str: + """ + 构建运维页诊断摘要。 + + :param health_payload: `ops health` 结果 + :param deps_payload: `ops deps` 结果 + :param server_payload: `ops server-info` 结果 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + health_ok = bool(isinstance(health_payload, dict) and health_payload.get('ok', False)) + deps_ok = bool(isinstance(deps_payload, dict) and deps_payload.get('ok', False)) + server_ok = bool(isinstance(server_payload, dict) and server_payload.get('ok', False)) + + if not health_ok: + return f'{focus_hint} | 运维探活存在异常,优先核对数据库/Redis 连通性与依赖版本' + if not deps_ok: + return f'{focus_hint} | 运维依赖存在异常,优先核对缺失依赖与版本兼容性' + if not server_ok: + return f'{focus_hint} | 服务器信息采集异常,优先重新检查主机资源与运行环境' + return f'{focus_hint} | 基础探活和依赖状态正常,可继续查看服务器资源与磁盘样本' + + @staticmethod + def _build_cache_subtitle( + stats_payload: dict[str, Any] | None, + matched_count: int, + *, + focus_hint: str, + ) -> str: + """ + 构建缓存页诊断摘要。 + + :param stats_payload: `cache stats` 结果 + :param matched_count: 当前筛选后缓存名数量 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + db_size = stats_payload.get('dbSize', '-') if isinstance(stats_payload, dict) else '-' + client_count = '-' + if isinstance(stats_payload, dict) and isinstance(stats_payload.get('info'), dict): + client_count = stats_payload['info'].get('connected_clients', '-') # type: ignore[index] + return ( + f'{focus_hint} | 缓存基线正常,当前已加载 {matched_count} 个缓存名,' + f'Redis 键数 {db_size},连接数 {client_count},可继续查看键列表、键值样本和 TTL' + ) + + @staticmethod + def _build_jobs_subtitle( + filter_label: str, + matched_count: int, + failed_job_names: set[str], + paused_count: int, + *, + focus_hint: str, + ) -> str: + """ + 构建任务页诊断摘要。 + + :param filter_label: 当前筛选标签 + :param matched_count: 当前匹配任务数 + :param failed_job_names: 最近失败任务集合 + :param paused_count: 暂停任务数量 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + if failed_job_names: + return ( + f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 条任务,' + f'失败任务 {len(failed_job_names)} 个,暂停任务 {paused_count} 个' + ) + return ( + f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 条任务,' + f'当前没有失败任务,暂停任务 {paused_count} 个' + ) + + @staticmethod + def _build_configs_subtitle( + filter_label: str, + matched_count: int, + mismatch_count: int, + drift_count: int, + *, + focus_hint: str, + ) -> str: + """ + 构建参数配置页诊断摘要。 + + :param filter_label: 当前筛选标签 + :param matched_count: 当前匹配配置数 + :param mismatch_count: 值不一致数量 + :param drift_count: 缓存漂移数量 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + return ( + f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 项配置,' + f'值不一致 {mismatch_count} 项,缓存漂移 {drift_count} 项' + ) + + @staticmethod + def _build_gen_subtitle( + matched_count: int, + importable_count: int, + *, + focus_hint: str, + ) -> str: + """ + 构建代码生成页诊断摘要。 + + :param matched_count: 当前匹配业务表数 + :param importable_count: 可导入物理表数 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + return ( + f'{focus_hint} | 当前已匹配 {matched_count} 张业务表,' + f'可导入物理表 {importable_count} 张,可继续查看表定义、预检查和代码预览' + ) + + @staticmethod + def _build_crypto_subtitle( + validate_payload: dict[str, Any] | None, + public_payload: dict[str, Any] | None, + *, + focus_hint: str, + ) -> str: + """ + 构建加密页诊断摘要。 + + :param validate_payload: `crypto validate` 结果 + :param public_payload: `crypto export-public` 结果 + :param focus_hint: 聚焦提示 + :return: 页面摘要 + """ + validate_ok = bool(isinstance(validate_payload, dict) and validate_payload.get('ok', False)) + public_ok = bool(isinstance(public_payload, dict) and public_payload.get('ok', False)) + public_key_payload = public_payload.get('publicKey') if isinstance(public_payload, dict) else None + current_kid = public_key_payload.get('kid', '-') if isinstance(public_key_payload, dict) else '-' + supported_kids = public_key_payload.get('supportedKids') if isinstance(public_key_payload, dict) else None + supported_count = len(supported_kids) if isinstance(supported_kids, list) else 0 + + if not validate_ok: + return f'{focus_hint} | 运行校验失败,优先确认传输加密配置与环境变量' + if not public_ok or not isinstance(public_key_payload, dict): + return f'{focus_hint} | 公钥导出结果异常,优先确认当前 KID 与公钥内容' + return ( + f'{focus_hint} | 当前 KID {current_kid},兼容版本 {supported_count} 个,' + '可继续查看公钥身份、兼容版本与轮换预演入口' + ) + + def build_app_diagnostic_subtitle( + self, + env_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + routes_payload: dict[str, Any] | None, + completion_payload: dict[str, Any] | None, + ) -> str: + """ + 构建应用页统一诊断摘要。 + + :param env_payload: `app env` 结果 + :param config_payload: `app config` 结果 + :param doctor_payload: `app doctor` 结果 + :param routes_payload: `app routes` 结果 + :param completion_payload: `completion doctor` 结果 + :return: 页面摘要 + """ + return self.page_policy_registry.get('app').subtitle_builder( + env_payload, + config_payload, + doctor_payload, + routes_payload, + completion_payload, + focus_hint=self.build_app_focus_hint(), + ) + + def build_database_diagnostic_subtitle( + self, + revision_payload: dict[str, Any] | None, + check_payload: dict[str, Any] | None, + heads_payload: dict[str, Any] | None, + ) -> str: + """ + 构建数据库页统一诊断摘要。 + + :param revision_payload: `db current` 结果 + :param check_payload: `db check` 结果 + :param heads_payload: `db heads` 结果 + :return: 页面摘要 + """ + return self.page_policy_registry.get('database').subtitle_builder( + revision_payload, + check_payload, + heads_payload, + focus_hint=self.build_database_focus_hint(), + ) + + def build_ops_diagnostic_subtitle( + self, + health_payload: dict[str, Any] | None, + deps_payload: dict[str, Any] | None, + server_payload: dict[str, Any] | None, + ) -> str: + """ + 构建运维页统一诊断摘要。 + + :param health_payload: `ops health` 结果 + :param deps_payload: `ops deps` 结果 + :param server_payload: `ops server-info` 结果 + :return: 页面摘要 + """ + return self.page_policy_registry.get('ops').subtitle_builder( + health_payload, + deps_payload, + server_payload, + focus_hint=self.build_ops_focus_hint(), + ) + + def build_cache_diagnostic_subtitle( + self, + stats_payload: dict[str, Any] | None, + matched_count: int, + ) -> str: + """ + 构建缓存页统一诊断摘要。 + + :param stats_payload: `cache stats` 结果 + :param matched_count: 当前筛选后缓存名数量 + :return: 页面摘要 + """ + return self.page_policy_registry.get('cache').subtitle_builder( + stats_payload, + matched_count, + focus_hint=self.build_cache_focus_hint(), + ) + + def build_jobs_diagnostic_subtitle( + self, + filter_label: str, + matched_count: int, + failed_job_names: set[str], + paused_count: int, + ) -> str: + """ + 构建任务页统一诊断摘要。 + + :param filter_label: 当前筛选标签 + :param matched_count: 当前匹配任务数 + :param failed_job_names: 最近失败任务集合 + :param paused_count: 暂停任务数量 + :return: 页面摘要 + """ + return self.page_policy_registry.get('jobs').subtitle_builder( + filter_label, + matched_count, + failed_job_names, + paused_count, + focus_hint=self.build_jobs_focus_hint(), + ) + + def build_configs_diagnostic_subtitle( + self, + filter_label: str, + matched_count: int, + mismatch_count: int, + drift_count: int, + ) -> str: + """ + 构建参数配置页统一诊断摘要。 + + :param filter_label: 当前筛选标签 + :param matched_count: 当前匹配配置数 + :param mismatch_count: 值不一致数量 + :param drift_count: 缓存漂移数量 + :return: 页面摘要 + """ + return self.page_policy_registry.get('configs').subtitle_builder( + filter_label, + matched_count, + mismatch_count, + drift_count, + focus_hint=self.build_configs_focus_hint(), + ) + + def build_gen_diagnostic_subtitle( + self, + matched_count: int, + importable_count: int, + ) -> str: + """ + 构建代码生成页统一诊断摘要。 + + :param matched_count: 当前匹配业务表数 + :param importable_count: 可导入物理表数 + :return: 页面摘要 + """ + return self.page_policy_registry.get('gen').subtitle_builder( + matched_count, + importable_count, + focus_hint=self.build_gen_focus_hint(), + ) + + def build_crypto_diagnostic_subtitle( + self, + validate_payload: dict[str, Any] | None, + public_payload: dict[str, Any] | None, + ) -> str: + """ + 构建加密页统一诊断摘要。 + + :param validate_payload: `crypto validate` 结果 + :param public_payload: `crypto export-public` 结果 + :return: 页面摘要 + """ + return self.page_policy_registry.get('crypto').subtitle_builder( + validate_payload, + public_payload, + focus_hint=self.build_crypto_focus_hint(), + ) + + +TUI_DIAGNOSTIC_PAGE_POLICY_REGISTRY = DiagnosticPagePolicyRegistry( + policies={ + 'database': DiagnosticPagePolicy( + focus_terms=('迁移版本', '连接状态', 'Heads'), + subtitle_builder=TuiDiagnosticService._build_database_subtitle, + ), + 'ops': DiagnosticPagePolicy( + focus_terms=('数据库连通性', 'Redis 连通性', '依赖版本'), + subtitle_builder=TuiDiagnosticService._build_ops_subtitle, + ), + 'cache': DiagnosticPagePolicy( + focus_terms=('Redis 键数', '连接数', '缓存名前缀'), + subtitle_builder=TuiDiagnosticService._build_cache_subtitle, + ), + 'app': DiagnosticPagePolicy( + focus_terms=('环境映射', '配置摘要', '启动前检查'), + subtitle_builder=TuiDiagnosticService._build_app_subtitle, + ), + 'jobs': DiagnosticPagePolicy( + focus_terms=('失败聚合', '暂停任务', '执行轨迹'), + subtitle_builder=TuiDiagnosticService._build_jobs_subtitle, + ), + 'configs': DiagnosticPagePolicy( + focus_terms=('高风险配置', '值不一致', '缓存漂移'), + subtitle_builder=TuiDiagnosticService._build_configs_subtitle, + ), + 'gen': DiagnosticPagePolicy( + focus_terms=('生成前校验', '同步预检查', '代码预览'), + subtitle_builder=TuiDiagnosticService._build_gen_subtitle, + ), + 'crypto': DiagnosticPagePolicy( + focus_terms=('运行校验', '公钥身份', '兼容版本'), + subtitle_builder=TuiDiagnosticService._build_crypto_subtitle, + ), + } +) +TUI_DIAGNOSTIC_SERVICE = TuiDiagnosticService(TUI_DIAGNOSTIC_PAGE_POLICY_REGISTRY) diff --git a/shierfeng-fastapi-backend/cli/tui/keymaps.py b/shierfeng-fastapi-backend/cli/tui/keymaps.py new file mode 100644 index 0000000..e8392c1 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/keymaps.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class TuiKeymapRegistry: + """ + TUI 键位注册表。 + + 该对象集中维护页面切换快捷键和派生提示文案,避免模块级 + 常量散落并在后续扩展时继续增长。 + + :param navigation_shortcuts: 页面导航快捷键映射 + """ + + navigation_shortcuts: dict[str, str] = field(default_factory=dict) + + @staticmethod + def normalize_view_key(view_key: str) -> str: + """ + 规范化页面标识。 + + :param view_key: 原始页面标识 + :return: 规范化后的页面标识 + """ + return str(view_key).strip().lower() + + def get_navigation_shortcut(self, view_key: str) -> str: + """ + 获取指定页面的导航快捷键。 + + :param view_key: 页面标识 + :return: 快捷键文本 + """ + normalized_view_key = self.normalize_view_key(view_key) + return self.navigation_shortcuts.get(normalized_view_key, '') + + @property + def navigation_shortcut_hint(self) -> str: + """ + 构建页面导航快捷键总提示。 + + :return: 快捷键提示 + """ + ordered_keys = ['dashboard', 'app', 'ops', 'database', 'cache', 'jobs', 'gen', 'configs', 'crypto'] + shortcuts = [ + self.get_navigation_shortcut(view_key).upper() + for view_key in ordered_keys + if self.get_navigation_shortcut(view_key) + ] + return f'[{" / ".join(shortcuts).replace(" / ", "/")}]' + + @property + def sidebar_shortcut_hint(self) -> str: + """ + 获取侧栏快捷键提示。 + + :return: 提示文本 + """ + return '[S] 侧栏' + + @property + def refresh_shortcut_hint(self) -> str: + """ + 获取刷新快捷键提示。 + + :return: 提示文本 + """ + return '[R] 刷新' + + @property + def quit_shortcut_hint(self) -> str: + """ + 获取退出快捷键提示。 + + :return: 提示文本 + """ + return '[Q] 退出' + + @property + def focus_shortcut_hint(self) -> str: + """ + 获取焦点切换快捷键提示。 + + :return: 提示文本 + """ + return '[←/→] 切换焦点' + + @property + def region_switch_shortcut_hint(self) -> str: + """ + 获取区域切换快捷键提示。 + + :return: 提示文本 + """ + return '[←/→] 切换区域' + + @property + def scroll_shortcut_hint(self) -> str: + """ + 获取滚动快捷键提示。 + + :return: 提示文本 + """ + return '[J/K] 滚动' + + @property + def page_scroll_shortcut_hint(self) -> str: + """ + 获取翻页快捷键提示。 + + :return: 提示文本 + """ + return '[PgUp/PgDn] 翻页' + + @property + def home_end_shortcut_hint(self) -> str: + """ + 获取首尾跳转快捷键提示。 + + :return: 提示文本 + """ + return '[Home/End] 首尾' + + @property + def browser_interaction_hint(self) -> str: + """ + 构建浏览页交互提示。 + + :return: 提示文本 + """ + return ( + f'浏览键:{self.focus_shortcut_hint} {self.scroll_shortcut_hint} ' + f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint}' + ) + + @property + def global_shortcut_hint(self) -> str: + """ + 构建全局快捷键提示。 + + :return: 提示文本 + """ + return ( + f'页面 {self.navigation_shortcut_hint} {self.sidebar_shortcut_hint} ' + f'{self.focus_shortcut_hint} {self.scroll_shortcut_hint} ' + f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint} ' + f'{self.refresh_shortcut_hint} {self.quit_shortcut_hint}' + ) + + @property + def hero_shortcut_hint(self) -> str: + """ + 构建首页英雄区快捷键提示。 + + :return: 提示文本 + """ + return ( + f'页面 {self.navigation_shortcut_hint} {self.sidebar_shortcut_hint} ' + f'{self.region_switch_shortcut_hint} {self.scroll_shortcut_hint} ' + f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint} ' + f'{self.refresh_shortcut_hint} {self.quit_shortcut_hint}' + ) + + +TUI_KEYMAP_REGISTRY = TuiKeymapRegistry( + navigation_shortcuts={ + 'dashboard': 'd', + 'app': 'a', + 'ops': 'o', + 'database': 'b', + 'cache': 'c', + 'jobs': 't', + 'gen': 'g', + 'configs': 'p', + 'crypto': 'e', + } +) diff --git a/shierfeng-fastapi-backend/cli/tui/screens/__init__.py b/shierfeng-fastapi-backend/cli/tui/screens/__init__.py new file mode 100644 index 0000000..e26b185 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/__init__.py @@ -0,0 +1,5 @@ +from cli.tui.screens.browser import BrowserScreen +from cli.tui.screens.dashboard import DashboardScreen +from cli.tui.screens.detail import DetailScreen + +__all__ = ['BrowserScreen', 'DashboardScreen', 'DetailScreen'] diff --git a/shierfeng-fastapi-backend/cli/tui/screens/browser.py b/shierfeng-fastapi-backend/cli/tui/screens/browser.py new file mode 100644 index 0000000..a6f1443 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/browser.py @@ -0,0 +1,840 @@ +import asyncio + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, ScrollableContainer, Vertical +from textual.screen import Screen +from textual.widgets import Footer, Static + +from cli.tui.actions import ( + TUI_ACTION_PRESENTATION_SERVICE, + TUI_ACTION_REGISTRY, + TuiActionResult, + TuiActionSpec, +) +from cli.tui.adapters import BrowserPageSnapshot, BrowserRecordSnapshot, DetailSectionSnapshot +from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER +from cli.tui.copy import TUI_COPY +from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin +from cli.tui.screens.interactions import TUI_SCREEN_INTERACTION_SERVICE, ScreenInteractionActionsMixin +from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER +from cli.tui.search import TUI_SEARCH_SERVICE +from cli.tui.widgets import ( + NavigationItem, + RecordNavigator, + RecordSummaryView, + SectionDetailView, + SectionNavigator, + WorkspaceHeader, + WorkspaceHero, + WorkspaceSidebar, +) + + +class BrowserScreenSupport: + """ + 浏览页屏幕支持对象。 + + 该对象负责浏览页摘要、搜索词、动作解析、空态/加载态分区构建和 + 焦点顺序编排,使 `BrowserScreen` 本体主要保留 Textual 事件桥接。 + """ + + @staticmethod + def build_summary_text(snapshot: BrowserPageSnapshot, active_view: str) -> str: + """ + 构建浏览页摘要文本。 + + :param snapshot: 浏览页快照 + :param active_view: 当前激活视图 + :return: 摘要文本 + """ + summary = STATUS_SUMMARY_BUILDER.build(snapshot.records) + return TUI_COPY.build_browser_summary_text( + total_count=summary.total_count, + ok_count=summary.ok_count, + warn_count=summary.warn_count, + fail_count=summary.fail_count, + action_hint=TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint(active_view), + ) + + @staticmethod + def build_filter_bar_text(snapshot: BrowserPageSnapshot) -> str: + """ + 构建当前浏览页筛选条文本。 + + :param snapshot: 浏览页快照 + :return: 筛选条文本 + """ + search_context = snapshot.search + return TUI_SEARCH_SERVICE.build_filter_bar_text( + snapshot.filters, + snapshot.active_filter_key, + search_query=search_context.query if search_context is not None else '', + search_placeholder=search_context.placeholder if search_context is not None else '', + search_suggestions=search_context.suggestions if search_context is not None else [], + ) + + @staticmethod + def current_search_query(snapshot: BrowserPageSnapshot) -> str: + """ + 读取当前浏览页搜索词。 + + :param snapshot: 浏览页快照 + :return: 搜索词 + """ + search_context = snapshot.search + return search_context.query if search_context is not None else '' + + @staticmethod + def resolve_action( + *, + active_view: str, + slot: str, + record: BrowserRecordSnapshot, + env: str, + ) -> TuiActionSpec | None: + """ + 解析当前页面指定槽位对应的动作。 + + :param active_view: 当前激活视图 + :param slot: 动作槽位 + :param record: 当前选中记录 + :param env: 当前运行环境 + :return: 动作定义 + """ + return TUI_ACTION_REGISTRY.resolve_browser_action( + view_key=active_view, + slot=slot, # type: ignore[arg-type] + record=record, + env=env, + ) + + @staticmethod + def build_record_action_lines( + *, + active_view: str, + record: BrowserRecordSnapshot, + env: str, + feedback_lines: list[str], + ) -> list[str]: + """ + 构建当前记录动作面板文本。 + + :param active_view: 当前激活视图 + :param record: 当前记录 + :param env: 当前运行环境 + :param feedback_lines: 最近动作反馈文本 + :return: 动作面板文本 + """ + action_lines = TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_lines( + view_key=active_view, + record=record, + env=env, + ) + return TUI_COPY.build_browser_action_panel_lines(action_lines, feedback_lines) + + @staticmethod + def build_empty_record() -> BrowserRecordSnapshot: + """ + 构建浏览页空态兜底记录。 + + :return: 记录快照 + """ + return BrowserRecordSnapshot( + key='empty', + title=TUI_COPY.build_browser_empty_record_copy('title'), + status='info', + summary=TUI_COPY.build_browser_empty_record_copy('summary'), + metadata_lines=[], + detail_sections=[ + DetailSectionSnapshot( + title=TUI_COPY.build_browser_empty_record_copy('title'), + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label=TUI_COPY.build_browser_empty_record_copy('label'), + empty_value=TUI_COPY.build_browser_empty_record_copy('value'), + detail=TUI_COPY.build_browser_empty_record_copy('detail'), + ), + ) + ], + ) + + @staticmethod + def build_loading_section() -> DetailSectionSnapshot: + """ + 构建详情加载中的占位分区。 + + :return: 占位分区 + """ + return DetailSectionSnapshot( + title=TUI_COPY.build_browser_loading_copy('title'), + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_loading_lines( + loading_label=TUI_COPY.build_browser_loading_copy('label'), + loading_value=TUI_COPY.build_browser_loading_copy('value'), + detail=TUI_COPY.build_browser_loading_copy('detail'), + ), + ) + + @staticmethod + def build_detail_load_failure_sections(error: Exception, resource_name: str) -> list[DetailSectionSnapshot]: + """ + 构建详情加载失败时的兜底分区。 + + :param error: 后台加载异常 + :param resource_name: 资源名称 + :return: 失败分区列表 + """ + return [ + DetailSectionSnapshot( + title=TUI_COPY.build_load_failure_section_title(resource_name), + status='fail', + lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines( + { + 'message': '后台详情加载失败', + 'error': str(error) or error.__class__.__name__, + 'hint': '可按 [R] 刷新当前页面,或切换记录后重试', + }, + empty_label='详情状态', + empty_value='不可用', + ), + ) + ] + + @staticmethod + def build_empty_section() -> DetailSectionSnapshot: + """ + 构建浏览页详情空态兜底分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title=TUI_COPY.build_detail_empty_section_copy('title'), + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label=TUI_COPY.build_detail_empty_section_copy('label'), + empty_value=TUI_COPY.build_detail_empty_section_copy('value'), + detail=TUI_COPY.build_detail_empty_section_copy('detail_record'), + ), + ) + + +class BrowserScreenFocusService(BaseScreenFocusService): + """ + 浏览页焦点服务。 + + 该对象负责定义浏览页可聚焦组件顺序,以及按左右键和滚动键解析 + 当前目标区域。 + """ + + def get_focus_order( + self, + screen: 'BrowserScreen', + ) -> list[WorkspaceSidebar | RecordNavigator | SectionNavigator | RecordSummaryView | SectionDetailView]: + """ + 定义浏览页可通过左右键切换的焦点顺序。 + + :param screen: 当前浏览页屏幕 + :return: 焦点顺序列表 + """ + return [ + screen.query_one(WorkspaceSidebar), + screen.query_one(RecordNavigator), + screen.query_one(SectionNavigator), + screen.query_one(RecordSummaryView), + screen.query_one(SectionDetailView), + ] + + def get_default_scroll_target( + self, + screen: 'BrowserScreen', + ) -> ScrollableContainer: + """ + 返回浏览页默认滚动容器。 + + :param screen: 当前浏览页屏幕 + :return: 主工作区滚动容器 + """ + return screen.query_one('#workspace-main', ScrollableContainer) + + +class BrowserScreen(ScreenInteractionActionsMixin, ScreenFocusActionsMixin, Screen[None]): + """ + TUI 浏览型页面。 + + :param snapshot: 浏览页快照 + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :param navigation_items: 导航项列表 + :param refreshed_at: 本次刷新时间 + """ + + BINDINGS = [ + Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False), + Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False), + Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False), + Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False), + Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False), + Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False), + Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False), + Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False), + Binding('x', 'trigger_primary_action', TUI_COPY.build_internal_binding_label('action_primary'), show=False), + Binding('z', 'trigger_secondary_action', TUI_COPY.build_internal_binding_label('action_secondary'), show=False), + Binding('y', 'trigger_global_action', TUI_COPY.build_internal_binding_label('action_global'), show=False), + Binding('w', 'trigger_utility_action', TUI_COPY.build_internal_binding_label('action_utility'), show=False), + Binding('1', 'apply_filter_1', TUI_COPY.build_internal_binding_label('filter_1'), show=False), + Binding('2', 'apply_filter_2', TUI_COPY.build_internal_binding_label('filter_2'), show=False), + Binding('3', 'apply_filter_3', TUI_COPY.build_internal_binding_label('filter_3'), show=False), + Binding('4', 'apply_filter_4', TUI_COPY.build_internal_binding_label('filter_4'), show=False), + Binding('/', 'open_search', TUI_COPY.build_internal_binding_label('search'), show=False), + Binding('backspace', 'clear_search', TUI_COPY.build_internal_binding_label('clear_search'), show=False), + ] + + def __init__( + self, + snapshot: BrowserPageSnapshot, + env: str, + active_view: str, + navigation_items: list[NavigationItem], + refreshed_at: str, + action_feedback_lines: list[str] | None = None, + support: BrowserScreenSupport | None = None, + focus_service: BrowserScreenFocusService | None = None, + ) -> None: + """ + 初始化浏览型页面。 + + :param snapshot: 浏览页快照 + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :param navigation_items: 导航项列表 + :param refreshed_at: 本次刷新时间 + :param action_feedback_lines: 最近动作反馈文本 + :param support: 浏览页屏幕支持对象 + :param focus_service: 浏览页焦点服务 + :return: None + """ + self.snapshot = snapshot + self.env = env + self.active_view = active_view + self.navigation_items = navigation_items + self.refreshed_at = refreshed_at + self.selected_record_index = 0 + self.selected_section_index = 0 + self._is_syncing_sections = False + self._record_detail_request_id = 0 + self._record_detail_task: asyncio.Task[None] | None = None + self._action_task: asyncio.Task[None] | None = None + self._last_action_result: TuiActionResult | None = None + self._action_feedback_lines = list(action_feedback_lines or []) + self.support = support or BrowserScreenSupport() + self.focus_service = focus_service or BrowserScreenFocusService() + super().__init__() + + def _build_summary_text(self) -> str: + """ + 构建浏览页摘要文本。 + + :return: 摘要文本 + """ + return self.support.build_summary_text(self.snapshot, self.active_view) + + def _build_filter_bar_text(self) -> str: + """ + 构建当前浏览页筛选条文本。 + + :return: 筛选条文本 + """ + return self.support.build_filter_bar_text(self.snapshot) + + def _current_search_query(self) -> str: + return self.support.current_search_query(self.snapshot) + + def _apply_filter_shortcut(self, shortcut: str) -> None: + """ + 按快捷键切换当前浏览页筛选条件。 + + :param shortcut: 筛选快捷键 + :return: None + """ + if not self.snapshot.filters: + return + target = next((option for option in self.snapshot.filters if option.shortcut == shortcut), None) + if target is None or target.key == self.snapshot.active_filter_key: + return + remember_filter = getattr(self.app, 'remember_browser_filter', None) + if callable(remember_filter): + remember_filter(self.active_view, target.key) + self.app.action_refresh_current_view() + + def _open_search(self) -> None: + """ + 打开当前浏览页搜索输入弹窗。 + + :return: None + """ + TUI_SCREEN_INTERACTION_SERVICE.open_search_dialog( + self, + self.snapshot.search, + self._handle_search_submitted, + ) + + def _handle_search_submitted(self, query: str | None) -> None: + """ + 处理页内搜索输入结果。 + + :param query: 搜索词 + :return: None + """ + TUI_SCREEN_INTERACTION_SERVICE.remember_query_and_refresh(self, self.active_view, query) + + def _clear_search(self) -> None: + """ + 清空当前浏览页搜索词。 + + :return: None + """ + TUI_SCREEN_INTERACTION_SERVICE.clear_query_and_refresh( + self, + self.active_view, + self._current_search_query(), + ) + + def _resolve_action(self, slot: str) -> TuiActionSpec | None: + """ + 解析当前页面指定槽位对应的动作。 + + :param slot: 动作槽位 + :return: 动作定义 + """ + return self.support.resolve_action( + active_view=self.active_view, + slot=slot, + record=self._get_record_or_fallback(self.selected_record_index), + env=self.env, + ) + + def _build_record_action_lines(self, record: BrowserRecordSnapshot) -> list[str]: + """ + 构建当前记录动作面板文本。 + + :param record: 当前记录 + :return: 动作面板文本 + """ + return self.support.build_record_action_lines( + active_view=self.active_view, + record=record, + env=self.env, + feedback_lines=self._action_feedback_lines, + ) + + def _open_action_confirm(self, slot: str) -> None: + """ + 打开指定槽位动作的确认弹窗。 + + :param slot: 动作槽位 + :return: None + """ + self._action_task = TUI_SCREEN_INTERACTION_SERVICE.confirm_and_schedule_action( + self, + self._resolve_action(slot), + self._action_task, + self._execute_action, + ) + + async def _execute_action(self, action: TuiActionSpec) -> None: + """ + 执行低风险动作并在结束后通知与刷新页面。 + + :param action: 动作定义 + :return: None + """ + + async def _handle_result(result: TuiActionResult, feedback_lines: list[str]) -> None: + self._last_action_result = result + self._action_feedback_lines = feedback_lines + await self._render_record_detail(eager=False) + + await TUI_SCREEN_INTERACTION_SERVICE.execute_action_with_feedback( + self, + action, + self.env, + self.active_view, + _handle_result, + ) + + def _get_record_or_fallback(self, index: int) -> BrowserRecordSnapshot: + """ + 获取指定索引的记录快照,缺失时返回兜底记录。 + + :param index: 记录索引 + :return: 记录快照 + """ + if 0 <= index < len(self.snapshot.records): + return self.snapshot.records[index] + return self.support.build_empty_record() + + def _build_loading_section(self) -> DetailSectionSnapshot: + """ + 构建详情加载中的占位分区。 + + :return: 占位分区 + """ + return self.support.build_loading_section() + + def _build_detail_load_failure_sections(self, error: Exception) -> list[DetailSectionSnapshot]: + """ + 构建详情加载失败时的兜底分区。 + + :param error: 后台加载异常 + :return: 失败分区列表 + """ + resource_name = self.snapshot.title if '详情' in self.snapshot.title else f'{self.snapshot.title}详情' + return self.support.build_detail_load_failure_sections(error, resource_name) + + @staticmethod + def _cancel_task(task: asyncio.Task[None] | None) -> None: + """ + 取消指定后台任务。 + + :param task: 待取消任务 + :return: None + """ + if task is None or task.done(): + return + task.cancel() + + def _cancel_background_tasks(self) -> None: + """ + 取消当前屏幕持有的后台任务。 + + :return: None + """ + self._record_detail_request_id += 1 + self._cancel_task(self._record_detail_task) + self._cancel_task(self._action_task) + + def _get_sections_for_record(self, index: int, *, eager: bool = False) -> list[DetailSectionSnapshot]: + """ + 获取指定记录对应的详情分区列表。 + + :param index: 记录索引 + :param eager: 是否立即加载按需详情 + :return: 详情分区列表 + """ + record = self._get_record_or_fallback(index) + if eager: + try: + detail_sections = record.resolve_detail_sections() + except Exception as error: + detail_sections = self._build_detail_load_failure_sections(error) + elif record.detail_sections: + detail_sections = record.detail_sections + elif record._cached_detail_sections: + detail_sections = list(record._cached_detail_sections) + elif record.detail_loader is not None: + detail_sections = [self._build_loading_section()] + else: + detail_sections = [] + return [*detail_sections, *self.snapshot.shared_sections] + + def _get_section_or_fallback(self, record_index: int, section_index: int) -> DetailSectionSnapshot: + """ + 获取指定记录下的分区快照,缺失时返回兜底分区。 + + :param record_index: 记录索引 + :param section_index: 分区索引 + :return: 分区快照 + """ + sections = self._get_sections_for_record(record_index) + if 0 <= section_index < len(sections): + return sections[section_index] + return self.support.build_empty_section() + + async def _render_record_detail(self, *, eager: bool = False) -> None: + """ + 根据当前选中记录刷新右侧详情区域。 + + :param eager: 是否立即解析完整详情 + :return: None + """ + record = self._get_record_or_fallback(self.selected_record_index) + sections = self._get_sections_for_record(self.selected_record_index, eager=eager) + selected_section = self._get_section_or_fallback(self.selected_record_index, self.selected_section_index) + self.query_one(RecordSummaryView).show_record( + record, + selected_section, + self._build_record_action_lines(record), + self._current_search_query(), + ) + navigator = self.query_one(SectionNavigator) + detail_view = self.query_one(SectionDetailView) + self._is_syncing_sections = True + try: + await navigator.show_sections( + sections, + initial_index=self.selected_section_index, + query=self._current_search_query(), + ) + detail_view.show_section(selected_section, query=self._current_search_query()) + self._sync_detail_pane_state(selected_section) + finally: + self._is_syncing_sections = False + + def _sync_detail_pane_state(self, section: DetailSectionSnapshot) -> None: + """ + 依据当前分区状态同步右侧详情容器样式。 + + :param section: 当前分区快照 + :return: None + """ + detail_pane = self.query_one('#browser-detail-pane', Vertical) + detail_pane.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info') + normalized = section.status if section.status in {'ok', 'fail', 'warn'} else 'info' + detail_pane.add_class(f'is-{normalized}') + + async def _load_record_detail_async(self, index: int, request_id: int) -> None: + """ + 在后台线程中加载指定记录的详情,并在仍为当前选中记录时回填界面。 + + :param index: 记录索引 + :param request_id: 当前加载请求编号 + :return: None + """ + record = self._get_record_or_fallback(index) + try: + await asyncio.to_thread(record.resolve_detail_sections) + except Exception as error: + failure_sections = self._build_detail_load_failure_sections(error) + object.__setattr__(record, '_cached_detail_sections', tuple(failure_sections)) + if request_id != self._record_detail_request_id: + return + if index != self.selected_record_index: + return + await self._render_record_detail(eager=False) + + def _schedule_record_detail_load(self, index: int) -> None: + """ + 为当前选中记录安排一次后台详情加载。 + + :param index: 记录索引 + :return: None + """ + record = self._get_record_or_fallback(index) + if record.detail_loader is None: + return + if record._cached_detail_sections: + return + self._cancel_task(self._record_detail_task) + self._record_detail_request_id += 1 + self._record_detail_task = asyncio.create_task( + self._load_record_detail_async(index, self._record_detail_request_id) + ) + + async def _update_selected_record(self, index: int) -> None: + """ + 更新当前选中记录,并同步右侧详情视图。 + + :param index: 待选中的记录索引 + :return: None + """ + if not self.snapshot.records: + self.selected_record_index = 0 + self.selected_section_index = 0 + await self._render_record_detail(eager=False) + return + if not 0 <= index < len(self.snapshot.records): + return + if index == self.selected_record_index: + return + self.selected_record_index = index + self.selected_section_index = 0 + await self._render_record_detail(eager=False) + self._schedule_record_detail_load(index) + + def _update_selected_section(self, index: int) -> None: + """ + 更新当前选中分区,并同步最右侧详情内容。 + + :param index: 分区索引 + :return: None + """ + if self._is_syncing_sections: + return + sections = self._get_sections_for_record(self.selected_record_index) + if not sections: + detail_view = self.query_one(SectionDetailView) + fallback = self._get_section_or_fallback(self.selected_record_index, index) + detail_view.show_section(fallback, query=self._current_search_query()) + record = self._get_record_or_fallback(self.selected_record_index) + self.query_one(RecordSummaryView).show_record( + record, + fallback, + self._build_record_action_lines(record), + self._current_search_query(), + ) + self._sync_detail_pane_state(fallback) + return + if not 0 <= index < len(sections): + return + if index == self.selected_section_index: + return + self.selected_section_index = index + current_section = sections[index] + detail_view = self.query_one(SectionDetailView) + detail_view.show_section(current_section, query=self._current_search_query()) + record = self._get_record_or_fallback(self.selected_record_index) + self.query_one(RecordSummaryView).show_record( + record, + current_section, + self._build_record_action_lines(record), + self._current_search_query(), + ) + self._sync_detail_pane_state(current_section) + + def on_mount(self) -> None: + """ + 浏览页挂载后默认将焦点停留在左侧导航。 + + :return: None + """ + self.call_after_refresh(self.query_one(WorkspaceSidebar).focus) + self._schedule_record_detail_load(self.selected_record_index) + + def on_unmount(self) -> None: + """ + 浏览页卸载时取消尚未完成的后台任务。 + + :return: None + """ + self._cancel_background_tasks() + + def action_apply_filter_1(self) -> None: + """ + 应用第一个浏览页筛选条件。 + + :return: None + """ + self._apply_filter_shortcut('1') + + def action_apply_filter_2(self) -> None: + """ + 应用第二个浏览页筛选条件。 + + :return: None + """ + self._apply_filter_shortcut('2') + + def action_apply_filter_3(self) -> None: + """ + 应用第三个浏览页筛选条件。 + + :return: None + """ + self._apply_filter_shortcut('3') + + def action_apply_filter_4(self) -> None: + """ + 应用第四个浏览页筛选条件。 + + :return: None + """ + self._apply_filter_shortcut('4') + + def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None: + """ + 根据侧边栏事件打开对应视图,并避免重复打开当前页面。 + + :param event: 侧边栏高亮或选中事件 + :return: None + """ + item = getattr(event.item, 'item', None) + if not isinstance(item, NavigationItem): + return + if item.view_key == self.active_view: + return + self.app.open_view(item.view_key) + + @on(WorkspaceSidebar.Selected) + def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None: + """ + 响应左侧导航选中事件并切换视图。 + + :param event: 导航选中事件 + :return: None + """ + self._open_sidebar_item(event) + + @on(WorkspaceSidebar.Highlighted) + def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None: + """ + 响应左侧导航高亮变更并立即切换视图。 + + :param event: 导航高亮事件 + :return: None + """ + self._open_sidebar_item(event) + + def compose(self) -> ComposeResult: + """ + 构建浏览页界面结构。 + + :return: Textual 组件结果 + """ + yield WorkspaceHeader(self.env, self.active_view) + with Horizontal(id='workspace-shell'): + yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view) + with ScrollableContainer(id='workspace-main'): + yield WorkspaceHero( + title=self.snapshot.title, + subtitle=self.snapshot.subtitle, + env=self.env, + active_view=self.active_view, + summary=self._build_summary_text(), + refreshed_at=self.refreshed_at, + ) + if self.snapshot.filters: + yield Static(self._build_filter_bar_text(), id='browser-filter-bar', markup=False) + with Horizontal(id='browser-body'): + yield RecordNavigator( + self.snapshot.records, + initial_index=self.selected_record_index, + query=self._current_search_query(), + ) + with Vertical(id='browser-detail-pane'): + yield RecordSummaryView(self._get_record_or_fallback(self.selected_record_index)) + with Horizontal(id='browser-section-body'): + yield SectionNavigator( + self._get_sections_for_record(self.selected_record_index, eager=False), + initial_index=self.selected_section_index, + query=self._current_search_query(), + ) + yield SectionDetailView( + self._get_section_or_fallback( + self.selected_record_index, + self.selected_section_index, + ), + query=self._current_search_query(), + ) + yield Footer() + + @on(RecordNavigator.Changed) + async def on_record_changed(self, event: RecordNavigator.Changed) -> None: + """ + 响应记录导航高亮变更并更新详情内容。 + + :param event: 记录导航高亮事件 + :return: None + """ + await self._update_selected_record(event.index) + + @on(SectionNavigator.Changed) + def on_section_changed(self, event: SectionNavigator.Changed) -> None: + """ + 响应记录详情分区高亮变更并更新最右侧详情内容。 + + :param event: 分区导航高亮事件 + :return: None + """ + self._update_selected_section(event.index) diff --git a/shierfeng-fastapi-backend/cli/tui/screens/confirm.py b/shierfeng-fastapi-backend/cli/tui/screens/confirm.py new file mode 100644 index 0000000..abdc64c --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/confirm.py @@ -0,0 +1,128 @@ +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + +from cli.tui.copy import TUI_COPY + + +class ActionConfirmScreen(ModalScreen[bool]): + """ + 通用动作确认弹窗。 + + :param title: 弹窗标题 + :param lines: 预览文本行 + :param confirm_label: 确认按钮文本 + """ + + CSS = """ + ActionConfirmScreen { + align: center middle; + } + + #action-confirm-dialog { + width: 96; + max-width: 96; + border: heavy #38d8ff; + background: #06111b; + padding: 1 2; + } + + #action-confirm-title { + text-style: bold; + color: #e9fcff; + margin-bottom: 1; + } + + #action-confirm-body { + border: round #18425d; + background: #081827; + color: #d8f7ff; + padding: 1 2; + margin-bottom: 1; + } + + #action-confirm-actions { + height: auto; + align-horizontal: right; + } + + #action-confirm-hint { + color: #8fc6d6; + margin-bottom: 1; + } + + #action-confirm-cancel { + margin-right: 1; + } + """ + + BINDINGS = [ + Binding('escape', 'cancel', TUI_COPY.build_confirm_binding_label('cancel'), show=False), + Binding('enter', 'submit', TUI_COPY.build_confirm_binding_label('submit'), show=False), + ] + + def __init__(self, title: str, lines: list[str], confirm_label: str) -> None: + super().__init__() + self._dialog_title = title + self._dialog_lines = lines + self._confirm_label = confirm_label + + def compose(self) -> ComposeResult: + """ + 构建确认弹窗界面。 + + :return: Textual 组件结果 + """ + with Vertical(id='action-confirm-dialog'): + yield Static(self._dialog_title, id='action-confirm-title', markup=False) + yield Static('\n'.join(self._dialog_lines), id='action-confirm-body', markup=False) + yield Static(TUI_COPY.build_action_confirm_hint(), id='action-confirm-hint', markup=False) + with Horizontal(id='action-confirm-actions'): + yield Button(TUI_COPY.build_action_confirm_cancel_label(), id='action-confirm-cancel') + yield Button(self._confirm_label, id='action-confirm-submit', variant='primary') + + def on_mount(self) -> None: + """ + 弹窗挂载后默认聚焦确认按钮。 + + :return: None + """ + self.call_after_refresh(self.query_one('#action-confirm-submit', Button).focus) + + def action_cancel(self) -> None: + """ + 取消并关闭弹窗。 + + :return: None + """ + self.dismiss(False) + + def action_submit(self) -> None: + """ + 通过键盘确认执行当前动作。 + + :return: None + """ + submit_button = self.query_one('#action-confirm-submit', Button) + submit_button.press() + + @on(Button.Pressed, '#action-confirm-cancel') + def on_cancel_pressed(self) -> None: + """ + 响应取消按钮。 + + :return: None + """ + self.dismiss(False) + + @on(Button.Pressed, '#action-confirm-submit') + def on_submit_pressed(self) -> None: + """ + 响应确认按钮。 + + :return: None + """ + self.dismiss(True) diff --git a/shierfeng-fastapi-backend/cli/tui/screens/dashboard.py b/shierfeng-fastapi-backend/cli/tui/screens/dashboard.py new file mode 100644 index 0000000..ae8b460 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/dashboard.py @@ -0,0 +1,253 @@ +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Grid, Horizontal, ScrollableContainer +from textual.screen import Screen +from textual.widgets import Footer + +from cli.tui.adapters import DashboardSnapshot +from cli.tui.copy import TUI_COPY +from cli.tui.keymaps import TUI_KEYMAP_REGISTRY +from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin +from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER +from cli.tui.widgets import ( + MetricPanel, + NavigationItem, + SignalRail, + StatusPanel, + WorkspaceHeader, + WorkspaceHero, + WorkspaceSidebar, +) + +PRIMARY_METRIC_COUNT = 2 + + +class DashboardScreenSupport: + """ + 首页屏幕支持对象。 + + 该对象负责首页摘要文本、信号带文本和状态轨迹构建, + 使 `DashboardScreen` 本体主要保留 Textual 生命周期与界面编排。 + """ + + @staticmethod + def build_summary_text(snapshot: DashboardSnapshot) -> str: + """ + 构建首页摘要文本。 + + :param snapshot: 首页聚合快照 + :return: 摘要文本 + """ + summary = STATUS_SUMMARY_BUILDER.build(snapshot.panels) + return TUI_COPY.build_dashboard_summary_text( + total_count=summary.total_count, + ok_count=summary.ok_count, + warn_count=summary.warn_count, + fail_count=summary.fail_count, + ) + + @staticmethod + def build_status_track(snapshot: DashboardSnapshot) -> str: + """ + 构建首页面板状态轨迹字符串。 + + :param snapshot: 首页聚合快照 + :return: 状态轨迹 + """ + return ''.join( + 'x' if panel.status == 'fail' else '!' if panel.status == 'warn' else 'o' if panel.status == 'ok' else '-' + for panel in snapshot.panels + ) + + def build_signal_lines(self, snapshot: DashboardSnapshot) -> list[str]: + """ + 构建首页顶部信号带文本。 + + :param snapshot: 首页聚合快照 + :return: 信号文本行 + """ + summary = STATUS_SUMMARY_BUILDER.build(snapshot.panels) + return TUI_COPY.build_dashboard_signal_lines( + status_track=self.build_status_track(snapshot), + fail_count=summary.fail_count, + warn_count=summary.warn_count, + ok_count=summary.ok_count, + total_count=summary.total_count, + navigation_shortcut_hint=TUI_KEYMAP_REGISTRY.navigation_shortcut_hint, + ) + + +class DashboardScreenFocusService(BaseScreenFocusService): + """ + 首页焦点服务。 + + 该对象负责定义首页可聚焦组件顺序,以及按左右键和滚动键解析 + 当前目标区域。 + """ + + def get_focus_order(self, screen: 'DashboardScreen') -> list[WorkspaceSidebar | ScrollableContainer]: + """ + 定义首页可通过左右键切换的焦点顺序。 + + :param screen: 当前首页屏幕 + :return: 焦点顺序列表 + """ + return [ + screen.query_one(WorkspaceSidebar), + screen.query_one('#workspace-main', ScrollableContainer), + ] + + def get_default_scroll_target(self, screen: 'DashboardScreen') -> ScrollableContainer: + """ + 返回首页默认滚动容器。 + + :param screen: 当前首页屏幕 + :return: 主工作区滚动容器 + """ + return screen.query_one('#workspace-main', ScrollableContainer) + + +class DashboardScreen(ScreenFocusActionsMixin, Screen[None]): + """ + TUI 首页巡检屏幕。 + + :param snapshot: 首页聚合快照 + """ + + BINDINGS = [ + Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False), + Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False), + Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False), + Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False), + Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False), + Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False), + Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False), + Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False), + ] + + def __init__( + self, + snapshot: DashboardSnapshot, + env: str, + active_view: str, + navigation_items: list[NavigationItem], + refreshed_at: str, + support: DashboardScreenSupport | None = None, + focus_service: DashboardScreenFocusService | None = None, + ) -> None: + """ + 初始化首页巡检屏幕。 + + :param snapshot: 首页聚合快照 + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :param navigation_items: 导航项列表 + :param refreshed_at: 本次刷新时间 + :param support: 首页屏幕支持对象 + :param focus_service: 首页焦点服务 + :return: None + """ + self.snapshot = snapshot + self.env = env + self.active_view = active_view + self.navigation_items = navigation_items + self.refreshed_at = refreshed_at + self.support = support or DashboardScreenSupport() + self.focus_service = focus_service or DashboardScreenFocusService() + super().__init__() + + def _build_summary_text(self) -> str: + """ + 构建首页摘要文本。 + + :return: 摘要文本 + """ + return self.support.build_summary_text(self.snapshot) + + def _build_signal_lines(self) -> list[str]: + """ + 构建首页顶部信号带文本。 + + :return: 信号文本行 + """ + return self.support.build_signal_lines(self.snapshot) + + def compose(self) -> ComposeResult: + """ + 构建首页界面结构。 + + :return: Textual 组件结果 + """ + yield WorkspaceHeader(self.env, self.active_view) + with Horizontal(id='workspace-shell'): + yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view) + with ScrollableContainer(id='workspace-main'): + yield WorkspaceHero( + title=TUI_COPY.build_dashboard_hero_title(), + subtitle=TUI_COPY.build_dashboard_hero_subtitle(), + env=self.env, + active_view=self.active_view, + summary=self._build_summary_text(), + refreshed_at=self.refreshed_at, + ) + yield SignalRail(self._build_signal_lines()) + with Grid(id='dashboard-metrics'): + for index, metric in enumerate(self.snapshot.metrics): + yield MetricPanel( + title=metric.title, + value=metric.value, + status=metric.status, + hint=metric.hint, + accent=index < PRIMARY_METRIC_COUNT, + ) + with Grid(id='dashboard-grid'): + for panel in self.snapshot.panels: + yield StatusPanel( + title=panel.title, + status=panel.status, + body='\n'.join(panel.lines), + ) + yield Footer() + + def on_mount(self) -> None: + """ + 首页挂载后默认将焦点停留在左侧导航。 + + :return: None + """ + self.call_after_refresh(self.query_one(WorkspaceSidebar).focus) + + def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None: + """ + 根据侧边栏事件打开对应视图,并避免重复打开当前页面。 + + :param event: 侧边栏高亮或选中事件 + :return: None + """ + item = getattr(event.item, 'item', None) + if not isinstance(item, NavigationItem): + return + if item.view_key == self.active_view: + return + self.app.open_view(item.view_key) + + @on(WorkspaceSidebar.Selected) + def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None: + """ + 响应左侧导航选中事件并切换视图。 + + :param event: 导航选中事件 + :return: None + """ + self._open_sidebar_item(event) + + @on(WorkspaceSidebar.Highlighted) + def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None: + """ + 响应左侧导航高亮变更并立即切换视图。 + + :param event: 导航高亮事件 + :return: None + """ + self._open_sidebar_item(event) diff --git a/shierfeng-fastapi-backend/cli/tui/screens/detail.py b/shierfeng-fastapi-backend/cli/tui/screens/detail.py new file mode 100644 index 0000000..97e2c69 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/detail.py @@ -0,0 +1,413 @@ +import asyncio + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, ScrollableContainer +from textual.screen import Screen +from textual.widgets import Footer + +from cli.tui.actions import ( + TUI_ACTION_PRESENTATION_SERVICE, + TUI_ACTION_REGISTRY, + TuiActionResult, + TuiActionSpec, +) +from cli.tui.adapters import DetailPageSnapshot, DetailSectionSnapshot +from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER +from cli.tui.copy import TUI_COPY +from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin +from cli.tui.screens.interactions import TUI_SCREEN_INTERACTION_SERVICE, ScreenInteractionActionsMixin +from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER +from cli.tui.widgets import ( + NavigationItem, + SectionDetailView, + SectionNavigator, + WorkspaceHeader, + WorkspaceHero, + WorkspaceSidebar, +) + + +class DetailScreenSupport: + """ + 详情页屏幕支持对象。 + + 该对象负责详情页摘要、搜索词、动作解析与空态分区构建, + 使 `DetailScreen` 本体主要保留 Textual 事件桥接。 + """ + + @staticmethod + def build_empty_section() -> DetailSectionSnapshot: + """ + 构建详情页空态兜底分区。 + + :return: 分区快照 + """ + return DetailSectionSnapshot( + title=TUI_COPY.build_detail_empty_section_copy('title'), + status='info', + lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines( + empty_label=TUI_COPY.build_detail_empty_section_copy('label'), + empty_value=TUI_COPY.build_detail_empty_section_copy('value'), + detail=TUI_COPY.build_detail_empty_section_copy('detail_page'), + ), + ) + + @staticmethod + def build_summary_text(snapshot: DetailPageSnapshot, active_view: str) -> str: + """ + 构建详情页摘要文本。 + + :param snapshot: 页面快照 + :param active_view: 当前激活视图 + :return: 摘要文本 + """ + summary = STATUS_SUMMARY_BUILDER.build(snapshot.sections) + return ( + TUI_COPY.build_detail_summary_text( + total_count=summary.total_count, + ok_count=summary.ok_count, + warn_count=summary.warn_count, + fail_count=summary.fail_count, + ) + + f' {TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint(active_view)}' + ) + + @staticmethod + def current_search_query(snapshot: DetailPageSnapshot) -> str: + """ + 读取当前详情页搜索词。 + + :param snapshot: 页面快照 + :return: 搜索词 + """ + search_context = snapshot.search + return search_context.query if search_context is not None else '' + + @staticmethod + def resolve_action(*, active_view: str, slot: str, env: str) -> TuiActionSpec | None: + """ + 解析当前详情页指定槽位对应的动作。 + + :param active_view: 当前激活视图 + :param slot: 动作槽位 + :param env: 当前运行环境 + :return: 动作定义 + """ + return TUI_ACTION_REGISTRY.resolve_detail_action( + view_key=active_view, + slot=slot, # type: ignore[arg-type] + env=env, + ) + + +class DetailScreenFocusService(BaseScreenFocusService): + """ + 详情页焦点服务。 + + 该对象负责定义详情页可聚焦组件顺序,以及按左右键和滚动键解析 + 当前目标区域。 + """ + + def get_focus_order(self, screen: 'DetailScreen') -> list[WorkspaceSidebar | SectionNavigator | SectionDetailView]: + """ + 定义详情页可通过左右键切换的焦点顺序。 + + :param screen: 当前详情页屏幕 + :return: 焦点顺序列表 + """ + return [ + screen.query_one(WorkspaceSidebar), + screen.query_one(SectionNavigator), + screen.query_one(SectionDetailView), + ] + + def get_default_scroll_target( + self, + screen: 'DetailScreen', + ) -> ScrollableContainer: + """ + 返回详情页默认滚动容器。 + + :param screen: 当前详情页屏幕 + :return: 主工作区滚动容器 + """ + return screen.query_one('#workspace-main', ScrollableContainer) + + +class DetailScreen(ScreenInteractionActionsMixin, ScreenFocusActionsMixin, Screen[None]): + """ + TUI 通用详情屏幕。 + + :param snapshot: 页面快照 + """ + + BINDINGS = [ + Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False), + Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False), + Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False), + Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False), + Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False), + Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False), + Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False), + Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False), + Binding('x', 'trigger_primary_action', TUI_COPY.build_internal_binding_label('action_primary'), show=False), + Binding('z', 'trigger_secondary_action', TUI_COPY.build_internal_binding_label('action_secondary'), show=False), + Binding('y', 'trigger_global_action', TUI_COPY.build_internal_binding_label('action_global'), show=False), + Binding('w', 'trigger_utility_action', TUI_COPY.build_internal_binding_label('action_utility'), show=False), + Binding('/', 'open_search', TUI_COPY.build_internal_binding_label('search'), show=False), + Binding('backspace', 'clear_search', TUI_COPY.build_internal_binding_label('clear_search'), show=False), + ] + + def __init__( + self, + snapshot: DetailPageSnapshot, + env: str, + active_view: str, + navigation_items: list[NavigationItem], + refreshed_at: str, + support: DetailScreenSupport | None = None, + focus_service: DetailScreenFocusService | None = None, + ) -> None: + """ + 初始化通用详情屏幕。 + + :param snapshot: 页面快照 + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :param navigation_items: 导航项列表 + :param refreshed_at: 本次刷新时间 + :param support: 详情页屏幕支持对象 + :param focus_service: 详情页焦点服务 + :return: None + """ + self.snapshot = snapshot + self.env = env + self.active_view = active_view + self.navigation_items = navigation_items + self.refreshed_at = refreshed_at + self.selected_section_index = 0 + self._last_action_result: TuiActionResult | None = None + self._action_feedback_lines: list[str] = [] + self._action_task: asyncio.Task[None] | None = None + self.support = support or DetailScreenSupport() + self.focus_service = focus_service or DetailScreenFocusService() + super().__init__() + + @staticmethod + def _cancel_task(task: asyncio.Task[None] | None) -> None: + """ + 取消指定后台任务。 + + :param task: 待取消任务 + :return: None + """ + if task is None or task.done(): + return + task.cancel() + + def _get_section_or_fallback(self, index: int) -> DetailSectionSnapshot: + """ + 获取指定索引的分区快照,缺失时返回兜底分区。 + + :param index: 分区索引 + :return: 分区快照 + """ + if 0 <= index < len(self.snapshot.sections): + return self.snapshot.sections[index] + return self.support.build_empty_section() + + def _build_summary_text(self) -> str: + """ + 构建详情页摘要文本。 + + :return: 摘要文本 + """ + return self.support.build_summary_text(self.snapshot, self.active_view) + + def _current_search_query(self) -> str: + return self.support.current_search_query(self.snapshot) + + def _open_search(self) -> None: + """ + 打开当前详情页搜索输入弹窗。 + + :return: None + """ + TUI_SCREEN_INTERACTION_SERVICE.open_search_dialog( + self, + self.snapshot.search, + self._handle_search_submitted, + ) + + def _handle_search_submitted(self, query: str | None) -> None: + """ + 处理详情页搜索输入结果。 + + :param query: 搜索词 + :return: None + """ + TUI_SCREEN_INTERACTION_SERVICE.remember_query_and_refresh(self, self.active_view, query) + + def _clear_search(self) -> None: + """ + 清空当前详情页搜索词。 + + :return: None + """ + TUI_SCREEN_INTERACTION_SERVICE.clear_query_and_refresh( + self, + self.active_view, + self._current_search_query(), + ) + + def _resolve_action(self, slot: str) -> TuiActionSpec | None: + """ + 解析当前详情页指定槽位对应的动作。 + + :param slot: 动作槽位 + :return: 动作定义 + """ + return self.support.resolve_action(active_view=self.active_view, slot=slot, env=self.env) + + def _open_action_confirm(self, slot: str) -> None: + """ + 打开指定槽位动作的确认弹窗。 + + :param slot: 动作槽位 + :return: None + """ + self._action_task = TUI_SCREEN_INTERACTION_SERVICE.confirm_and_schedule_action( + self, + self._resolve_action(slot), + self._action_task, + self._execute_action, + ) + + async def _execute_action(self, action: TuiActionSpec) -> None: + """ + 执行详情页动作。 + + :param action: 动作定义 + :return: None + """ + + def _handle_result(result: TuiActionResult, feedback_lines: list[str]) -> None: + self._last_action_result = result + self._action_feedback_lines = feedback_lines + + await TUI_SCREEN_INTERACTION_SERVICE.execute_action_with_feedback( + self, + action, + self.env, + self.active_view, + _handle_result, + ) + + def _update_selected_section(self, index: int) -> None: + """ + 更新当前选中分区,并同步右侧详情视图。 + + :param index: 待选中的分区索引 + :return: None + """ + if not self.snapshot.sections: + detail_view = self.query_one(SectionDetailView) + detail_view.show_section(self._get_section_or_fallback(index), query=self._current_search_query()) + return + if not 0 <= index < len(self.snapshot.sections): + return + self.selected_section_index = index + detail_view = self.query_one(SectionDetailView) + detail_view.show_section(self.snapshot.sections[index], query=self._current_search_query()) + + def compose(self) -> ComposeResult: + """ + 构建详情页界面结构。 + + :return: Textual 组件结果 + """ + yield WorkspaceHeader(self.env, self.active_view) + with Horizontal(id='workspace-shell'): + yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view) + with ScrollableContainer(id='workspace-main'): + yield WorkspaceHero( + title=self.snapshot.title, + subtitle=self.snapshot.subtitle, + env=self.env, + active_view=self.active_view, + summary=self._build_summary_text(), + refreshed_at=self.refreshed_at, + ) + with Horizontal(id='detail-body'): + yield SectionNavigator( + self.snapshot.sections, + initial_index=self.selected_section_index, + query=self._current_search_query(), + ) + yield SectionDetailView( + self._get_section_or_fallback(self.selected_section_index), + query=self._current_search_query(), + ) + yield Footer() + + def on_mount(self) -> None: + """ + 详情页挂载后默认将焦点停留在左侧导航。 + + :return: None + """ + self.call_after_refresh(self.query_one(WorkspaceSidebar).focus) + + def on_unmount(self) -> None: + """ + 详情页卸载时取消尚未完成的后台任务。 + + :return: None + """ + self._cancel_task(self._action_task) + + def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None: + """ + 根据侧边栏事件打开对应视图,并避免重复打开当前页面。 + + :param event: 侧边栏高亮或选中事件 + :return: None + """ + item = getattr(event.item, 'item', None) + if not isinstance(item, NavigationItem): + return + if item.view_key == self.active_view: + return + self.app.open_view(item.view_key) + + @on(WorkspaceSidebar.Selected) + def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None: + """ + 响应左侧导航选中事件并切换视图。 + + :param event: 导航选中事件 + :return: None + """ + self._open_sidebar_item(event) + + @on(WorkspaceSidebar.Highlighted) + def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None: + """ + 响应左侧导航高亮变更并立即切换视图。 + + :param event: 导航高亮事件 + :return: None + """ + self._open_sidebar_item(event) + + @on(SectionNavigator.Changed) + def on_section_changed(self, event: SectionNavigator.Changed) -> None: + """ + 响应分区导航高亮变更并更新详情内容。 + + :param event: 分区导航高亮事件 + :return: None + """ + self._update_selected_section(event.index) diff --git a/shierfeng-fastapi-backend/cli/tui/screens/focus.py b/shierfeng-fastapi-backend/cli/tui/screens/focus.py new file mode 100644 index 0000000..fa3a00e --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/focus.py @@ -0,0 +1,152 @@ +from abc import ABC, abstractmethod +from typing import Any + +from textual.containers import ScrollableContainer + + +class BaseScreenFocusService(ABC): + """ + TUI 屏幕焦点服务基类。 + + 该基类统一处理焦点顺序中的左右移动和滚动目标回退逻辑,具体页面 + 只需声明自身的焦点顺序和默认滚动容器。 + """ + + @abstractmethod + def get_focus_order(self, screen: Any) -> list[Any]: + """ + 返回当前页面的焦点顺序。 + + :param screen: 当前屏幕对象 + :return: 焦点组件列表 + """ + + @abstractmethod + def get_default_scroll_target(self, screen: Any) -> ScrollableContainer: + """ + 返回当前页面的默认滚动容器。 + + :param screen: 当前屏幕对象 + :return: 默认滚动容器 + """ + + def move_focus(self, screen: Any, step: int) -> None: + """ + 按给定方向移动当前焦点。 + + :param screen: 当前屏幕对象 + :param step: 焦点移动步长 + :return: None + """ + order = self.get_focus_order(screen) + focused = screen.app.focused + if focused not in order: + order[0].focus() + return + target_index = max(0, min(order.index(focused) + step, len(order) - 1)) + order[target_index].focus() + + def get_scroll_target(self, screen: Any) -> Any: + """ + 获取当前应响应滚动动作的焦点区域。 + + :param screen: 当前屏幕对象 + :return: 当前焦点组件或默认滚动容器 + """ + focused = screen.app.focused + order = self.get_focus_order(screen) + if focused in order: + return focused + return self.get_default_scroll_target(screen) + + +class ScreenFocusActionsMixin: + """ + TUI 屏幕焦点动作混入。 + + 该混入负责将焦点服务暴露为屏幕上的左右移动与滚动 action,避免 + 各个 Screen 类重复编写同形转发方法。 + """ + + focus_service: BaseScreenFocusService + + def _move_focus(self, step: int) -> None: + """ + 按给定方向移动当前焦点。 + + :param step: 焦点移动步长 + :return: None + """ + self.focus_service.move_focus(self, step) + + def _get_scroll_target(self) -> Any: + """ + 获取当前应响应滚动动作的焦点区域。 + + :return: 当前焦点组件或主工作区 + """ + return self.focus_service.get_scroll_target(self) + + def action_scroll_focus_down(self) -> None: + """ + 向下滚动当前焦点区域。 + + :return: None + """ + self._get_scroll_target().scroll_down(animate=False) + + def action_scroll_focus_up(self) -> None: + """ + 向上滚动当前焦点区域。 + + :return: None + """ + self._get_scroll_target().scroll_up(animate=False) + + def action_scroll_focus_page_down(self) -> None: + """ + 向下翻动当前焦点区域。 + + :return: None + """ + self._get_scroll_target().scroll_page_down(animate=False) + + def action_scroll_focus_page_up(self) -> None: + """ + 向上翻动当前焦点区域。 + + :return: None + """ + self._get_scroll_target().scroll_page_up(animate=False) + + def action_scroll_focus_home(self) -> None: + """ + 将当前焦点区域滚动到起始位置。 + + :return: None + """ + self._get_scroll_target().scroll_home(animate=False) + + def action_scroll_focus_end(self) -> None: + """ + 将当前焦点区域滚动到末尾位置。 + + :return: None + """ + self._get_scroll_target().scroll_end(animate=False) + + def action_focus_left(self) -> None: + """ + 将焦点向左移动一列。 + + :return: None + """ + self._move_focus(-1) + + def action_focus_right(self) -> None: + """ + 将焦点向右移动一列。 + + :return: None + """ + self._move_focus(1) diff --git a/shierfeng-fastapi-backend/cli/tui/screens/interactions.py b/shierfeng-fastapi-backend/cli/tui/screens/interactions.py new file mode 100644 index 0000000..208469a --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/interactions.py @@ -0,0 +1,331 @@ +import asyncio +import inspect +from collections.abc import Awaitable, Callable +from typing import Any + +from textual.app import SuspendNotSupported + +from cli.tui.actions import ( + TUI_ACTION_EXECUTION_SERVICE, + TUI_ACTION_PRESENTATION_SERVICE, + TuiActionResult, + TuiActionSpec, +) +from cli.tui.copy import TUI_COPY +from cli.tui.screens.confirm import ActionConfirmScreen +from cli.tui.screens.search import SearchInputScreen +from cli.tui.search import PageSearchContext +from cli.tui.widgets import WorkspaceSidebar + + +class TuiScreenInteractionService: + """ + TUI 屏幕交互服务。 + + 该服务负责搜索弹窗、搜索状态写回、动作确认弹窗和动作执行通知等 + 屏幕级交互流程,减少浏览页与详情页之间的重复控制逻辑。 + """ + + @staticmethod + def open_search_dialog( + screen: Any, + search_context: PageSearchContext | None, + callback: Callable[[str | None], None], + ) -> None: + """ + 打开搜索输入弹窗。 + + :param screen: 当前屏幕对象 + :param search_context: 搜索上下文 + :param callback: 提交搜索词后的回调 + :return: None + """ + if search_context is None: + return + screen.app.push_screen( + SearchInputScreen( + '页内搜索', + search_context.placeholder, + search_context.query, + search_context.suggestions, + ), + callback=callback, + ) + + @staticmethod + def remember_query_and_refresh(screen: Any, active_view: str, query: str | None) -> None: + """ + 记录当前页面搜索词并刷新页面。 + + :param screen: 当前屏幕对象 + :param active_view: 当前页面视图标识 + :param query: 搜索词 + :return: None + """ + if query is None: + return + remember_query = getattr(screen.app, 'remember_browser_query', None) + if callable(remember_query): + remember_query(active_view, query) + screen.app.action_refresh_current_view() + + def clear_query_and_refresh(self, screen: Any, active_view: str, current_query: str) -> None: + """ + 清空当前页面搜索词并刷新页面。 + + :param screen: 当前屏幕对象 + :param active_view: 当前页面视图标识 + :param current_query: 当前搜索词 + :return: None + """ + if not current_query: + return + self.remember_query_and_refresh(screen, active_view, '') + + @staticmethod + def open_action_confirm( + screen: Any, + action: TuiActionSpec | None, + callback: Callable[[bool | None, TuiActionSpec], None], + ) -> None: + """ + 打开动作确认弹窗。 + + :param screen: 当前屏幕对象 + :param action: 待执行动作 + :param callback: 确认回调 + :return: None + """ + if action is None: + screen.notify( + TUI_COPY.build_action_unavailable_message(), + title=TUI_COPY.build_action_notification_title(), + severity='warning', + ) + return + screen.app.push_screen( + ActionConfirmScreen(action.preview_title, action.preview_lines, action.label), + callback=lambda confirmed, action=action: callback(confirmed, action), + ) + + @staticmethod + def schedule_action_task( + existing_task: asyncio.Task[None] | None, + action: TuiActionSpec, + execute_callback: Callable[[TuiActionSpec], Awaitable[None]], + ) -> asyncio.Task[None]: + """ + 安排动作执行任务,并在必要时取消旧任务。 + + :param existing_task: 现有动作任务 + :param action: 待执行动作 + :param execute_callback: 动作执行协程工厂 + :return: 新建任务 + """ + if existing_task is not None and not existing_task.done(): + existing_task.cancel() + return asyncio.create_task(execute_callback(action)) + + def confirm_and_schedule_action( + self, + screen: Any, + action: TuiActionSpec | None, + existing_task: asyncio.Task[None] | None, + execute_callback: Callable[[TuiActionSpec], Awaitable[None]], + ) -> asyncio.Task[None] | None: + """ + 统一处理动作确认结果,并在确认后调度后台执行任务。 + + :param screen: 当前屏幕对象 + :param action: 待执行动作 + :param existing_task: 当前已有动作任务 + :param execute_callback: 动作执行协程工厂 + :return: 新建任务或原任务 + """ + if action is None: + self.open_action_confirm(screen, action, lambda confirmed, action_spec: None) + return existing_task + + scheduled_task = existing_task + + def _handle_confirmed(confirmed: bool | None, action_spec: TuiActionSpec) -> None: + nonlocal scheduled_task + if not confirmed: + return + scheduled_task = self.schedule_action_task( + scheduled_task, + action_spec, + execute_callback, + ) + + self.open_action_confirm(screen, action, _handle_confirmed) + return scheduled_task + + async def execute_action( + self, + screen: Any, + action: TuiActionSpec, + env: str, + active_view: str, + *, + on_result: Callable[[TuiActionResult, list[str]], Any] | None = None, + ) -> TuiActionResult: + """ + 执行屏幕动作并统一处理通知、反馈持久化与刷新。 + + :param screen: 当前屏幕对象 + :param action: 动作定义 + :param env: 当前运行环境 + :param active_view: 当前页面视图标识 + :param on_result: 动作结果回调 + :return: 动作结果 + """ + screen.notify( + TUI_COPY.build_action_running_message(action.label), + title=TUI_COPY.build_action_notification_title(), + ) + if action.execution_mode == 'external': + try: + with screen.app.suspend(): + result = TUI_ACTION_EXECUTION_SERVICE.execute_external(action) + except SuspendNotSupported: + result = TuiActionResult( + spec=action, + external_exit_code=1, + external_message='当前终端不支持挂起 TUI,无法打开外部交互向导', + ) + else: + result = await asyncio.to_thread(TUI_ACTION_EXECUTION_SERVICE.execute, action, env) + + feedback_lines = TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result) + remember_feedback = getattr(screen.app, 'remember_action_feedback', None) + if callable(remember_feedback): + remember_feedback(active_view, feedback_lines) + if on_result is not None: + maybe_result = on_result(result, feedback_lines) + if inspect.isawaitable(maybe_result): + await maybe_result + screen.notify( + TUI_ACTION_PRESENTATION_SERVICE.build_action_result_message(result), + title=TUI_COPY.build_action_notification_title(), + severity='information' if result.ok else 'error', + ) + if result.ok and action.refresh_view: + screen.app.action_refresh_current_view() + return result + + async def execute_action_with_feedback( + self, + screen: Any, + action: TuiActionSpec, + env: str, + active_view: str, + feedback_callback: Callable[[TuiActionResult, list[str]], Any], + ) -> TuiActionResult: + """ + 执行动作并将统一反馈回调注入结果收口流程。 + + :param screen: 当前屏幕对象 + :param action: 动作定义 + :param env: 当前运行环境 + :param active_view: 当前页面视图标识 + :param feedback_callback: 反馈写回回调 + :return: 动作结果 + """ + return await self.execute_action( + screen, + action, + env, + active_view, + on_result=feedback_callback, + ) + + +class ScreenInteractionActionsMixin: + """ + 屏幕交互动作混入。 + + 该混入负责把搜索、动作确认和侧栏切屏这类重复桥接 action 收口为 + 通用 Screen 方法,具体页面只需实现对应的私有协作方法。 + """ + + def _open_search(self) -> None: + """ + 打开当前页面搜索输入弹窗。 + + :return: None + """ + + def _clear_search(self) -> None: + """ + 清空当前页面搜索词。 + + :return: None + """ + + def _open_action_confirm(self, slot: str) -> None: + """ + 打开指定槽位动作的确认弹窗。 + + :param slot: 动作槽位 + :return: None + """ + + def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None: + """ + 根据侧栏事件打开对应视图。 + + :param event: 侧栏事件 + :return: None + """ + + def action_open_search(self) -> None: + """ + 打开当前页面搜索输入。 + + :return: None + """ + self._open_search() + + def action_clear_search(self) -> None: + """ + 清空当前页面搜索词。 + + :return: None + """ + self._clear_search() + + def action_trigger_primary_action(self) -> None: + """ + 触发当前页面主动作。 + + :return: None + """ + self._open_action_confirm('primary') + + def action_trigger_secondary_action(self) -> None: + """ + 触发当前页面次动作。 + + :return: None + """ + self._open_action_confirm('secondary') + + def action_trigger_global_action(self) -> None: + """ + 触发当前页面全局动作。 + + :return: None + """ + self._open_action_confirm('global') + + def action_trigger_utility_action(self) -> None: + """ + 触发当前页面工具动作。 + + :return: None + """ + self._open_action_confirm('utility') + + +TUI_SCREEN_INTERACTION_SERVICE = TuiScreenInteractionService() diff --git a/shierfeng-fastapi-backend/cli/tui/screens/search.py b/shierfeng-fastapi-backend/cli/tui/screens/search.py new file mode 100644 index 0000000..fac67be --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/search.py @@ -0,0 +1,80 @@ +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +from cli.tui.copy import TUI_COPY + + +class SearchInputScreen(ModalScreen[str | None]): + """ + 通用搜索输入弹窗。 + + :param title: 弹窗标题 + :param placeholder: 输入框提示 + :param value: 当前搜索词 + :param suggestions: 候选建议 + """ + + CSS = """ + SearchInputScreen { + align: center middle; + } + + #search-dialog { + width: 88; + max-width: 88; + border: heavy #38d8ff; + background: #06111b; + padding: 1 2; + } + + #search-title { + text-style: bold; + color: #e9fcff; + margin-bottom: 1; + } + + #search-input { + margin-bottom: 1; + } + + #search-suggestions { + color: #8fc6d6; + } + """ + + BINDINGS = [ + Binding('escape', 'cancel', TUI_COPY.build_confirm_binding_label('cancel'), show=False), + Binding('enter', 'submit', TUI_COPY.build_confirm_binding_label('submit'), show=False), + ] + + def __init__(self, title: str, placeholder: str, value: str, suggestions: list[str]) -> None: + super().__init__() + self._title = title + self._placeholder = placeholder + self._value = value + self._suggestions = suggestions + + def compose(self) -> ComposeResult: + with Vertical(id='search-dialog'): + yield Static(self._title, id='search-title', markup=False) + yield Input(value=self._value, placeholder=self._placeholder, id='search-input') + suggestion_text = '候选建议 · ' + (' '.join(self._suggestions[:6]) if self._suggestions else '暂无建议') + yield Static(suggestion_text, id='search-suggestions', markup=False) + + def on_mount(self) -> None: + self.call_after_refresh(self.query_one('#search-input', Input).focus) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_submit(self) -> None: + input_widget = self.query_one('#search-input', Input) + self.dismiss(str(input_widget.value or '').strip()) + + @on(Input.Submitted, '#search-input') + def on_input_submitted(self) -> None: + self.action_submit() diff --git a/shierfeng-fastapi-backend/cli/tui/screens/summary.py b/shierfeng-fastapi-backend/cli/tui/screens/summary.py new file mode 100644 index 0000000..f359cc8 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/screens/summary.py @@ -0,0 +1,59 @@ +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Protocol + + +class StatusCarrier(Protocol): + """ + 状态载体协议。 + + 该协议用于描述首页面板、详情分区和浏览记录这类具备 `status` + 属性的摘要统计对象。 + """ + + status: str + + +@dataclass(frozen=True) +class StatusSummary: + """ + 状态摘要统计结果。 + + :param total_count: 总数量 + :param ok_count: 正常数量 + :param warn_count: 警告数量 + :param fail_count: 失败数量 + """ + + total_count: int + ok_count: int + warn_count: int + fail_count: int + + +class StatusSummaryBuilder: + """ + 状态摘要构建器。 + + 该对象负责对带 `status` 属性的集合做统一计数,供 dashboard、 + detail、browser 三类 screen support 复用。 + """ + + @staticmethod + def build(items: Iterable[StatusCarrier]) -> StatusSummary: + """ + 统计状态摘要。 + + :param items: 待统计对象集合 + :return: 状态摘要 + """ + materialized = list(items) + return StatusSummary( + total_count=len(materialized), + ok_count=sum(1 for item in materialized if item.status == 'ok'), + warn_count=sum(1 for item in materialized if item.status == 'warn'), + fail_count=sum(1 for item in materialized if item.status == 'fail'), + ) + + +STATUS_SUMMARY_BUILDER = StatusSummaryBuilder() diff --git a/shierfeng-fastapi-backend/cli/tui/search.py b/shierfeng-fastapi-backend/cli/tui/search.py new file mode 100644 index 0000000..9990549 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/search.py @@ -0,0 +1,375 @@ +from collections.abc import Callable +from dataclasses import dataclass, field + +from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway + +CompletionProviderCallable = Callable[[object, list[str] | None, str], list[str]] + + +@dataclass(frozen=True) +class PageFilterOption: + """ + 浏览页筛选项定义。 + + :param key: 筛选键 + :param label: 展示名称 + :param shortcut: 快捷键 + """ + + key: str + label: str + shortcut: str + + +@dataclass(frozen=True) +class PageSearchContext: + """ + 浏览页搜索上下文定义。 + + :param placeholder: 搜索输入提示 + :param query: 当前搜索词 + :param suggestions: 候选建议列表 + """ + + placeholder: str + query: str = '' + suggestions: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class SearchSuggestionProviderSpec: + """ + 页面搜索建议提供者定义。 + + :param placeholder: 搜索输入提示 + :param suggestion_provider: 候选建议提供函数 + """ + + placeholder: str + suggestion_provider: Callable[[str], list[str]] | None = None + + +JOB_FILTER_OPTIONS: tuple[PageFilterOption, ...] = ( + PageFilterOption('all', '全部', '1'), + PageFilterOption('failed', '失败', '2'), + PageFilterOption('paused', '暂停', '3'), + PageFilterOption('ok', '正常', '4'), +) + +CONFIG_FILTER_OPTIONS: tuple[PageFilterOption, ...] = ( + PageFilterOption('all', '全部', '1'), + PageFilterOption('risky', '高风险', '2'), + PageFilterOption('mismatch', '值不一致', '3'), + PageFilterOption('cache-drift', '缓存异常', '4'), +) + + +class TuiSearchSuggestionProviderRegistry: + """ + TUI 搜索建议提供者注册表。 + + 该对象负责管理页面搜索提示文案及其候选建议提供函数,避免搜索服务 + 继续通过字符串反射隐式定位 completion provider。 + + :param providers: 页面搜索提供者映射 + """ + + def __init__(self, providers: dict[str, SearchSuggestionProviderSpec]) -> None: + """ + 初始化搜索建议提供者注册表。 + + :param providers: 页面搜索提供者映射 + :return: None + """ + self.providers = {self.normalize_view_key(key): value for key, value in providers.items()} + + @staticmethod + def normalize_view_key(view_key: str) -> str: + """ + 规范化页面视图标识。 + + :param view_key: 原始页面视图标识 + :return: 规范化后的页面视图标识 + """ + return str(view_key or '').strip().lower() + + def get_provider(self, view_key: str) -> SearchSuggestionProviderSpec | None: + """ + 获取指定页面的搜索建议提供者。 + + :param view_key: 页面视图标识 + :return: 搜索建议提供者定义 + """ + return self.providers.get(self.normalize_view_key(view_key)) + + +class CompletionSuggestionProviderFactory: + """ + 基于 completion 注册表构建 TUI 搜索建议提供函数。 + + :param completion_provider_gateway: completion 提供器对外网关 + """ + + def __init__(self, completion_provider_gateway: CompletionProviderGateway) -> None: + """ + 初始化 completion 搜索建议工厂。 + + :param completion_provider_gateway: completion 提供器对外网关 + :return: None + """ + self.completion_provider_gateway = completion_provider_gateway + + def build(self, provider: CompletionProviderCallable) -> Callable[[str], list[str]]: + """ + 根据显式 completion provider 构建建议提供函数。 + + :param provider: completion provider 可调用对象 + :return: 搜索建议提供函数 + """ + + def provide(query: str) -> list[str]: + """ + 调用 completion provider 生成候选建议。 + + :param query: 当前搜索词 + :return: 候选建议列表 + """ + candidates = provider(None, [], str(query or '').strip()) + return [str(candidate).strip() for candidate in candidates if str(candidate).strip()] + + return provide + + +def build_default_search_provider_registry( + completion_provider_gateway: CompletionProviderGateway, +) -> TuiSearchSuggestionProviderRegistry: + """ + 构建默认 TUI 搜索建议提供者注册表。 + + :param completion_provider_gateway: completion 提供器对外网关 + :return: 搜索建议提供者注册表 + """ + provider_factory = CompletionSuggestionProviderFactory(completion_provider_gateway) + return TuiSearchSuggestionProviderRegistry( + providers={ + 'jobs': SearchSuggestionProviderSpec( + '按任务名搜索', + provider_factory.build(completion_provider_gateway.complete_job_names), + ), + 'configs': SearchSuggestionProviderSpec( + '按配置键搜索', + provider_factory.build(completion_provider_gateway.complete_config_keys), + ), + 'cache': SearchSuggestionProviderSpec( + '按缓存名搜索', + provider_factory.build(completion_provider_gateway.complete_cache_names), + ), + 'gen': SearchSuggestionProviderSpec( + '按业务表名搜索', + provider_factory.build(completion_provider_gateway.complete_gen_table_names), + ), + 'database': SearchSuggestionProviderSpec( + '按 revision 搜索', + provider_factory.build(completion_provider_gateway.complete_alembic_revisions), + ), + 'app': SearchSuggestionProviderSpec('按分区或内容搜索'), + 'ops': SearchSuggestionProviderSpec('按分区或内容搜索'), + 'crypto': SearchSuggestionProviderSpec('按分区或内容搜索'), + } + ) + + +class TuiSearchHighlighter: + """ + TUI 搜索高亮器。 + + 该对象负责对终端文本做轻量关键字高亮。 + """ + + @staticmethod + def highlight(text: str, query: str, *, left_tag: str = '【', right_tag: str = '】') -> str: + """ + 对给定文本中的搜索词做轻量高亮。 + + :param text: 原始文本 + :param query: 搜索词 + :param left_tag: 左高亮标记 + :param right_tag: 右高亮标记 + :return: 高亮后的文本 + """ + raw_text = str(text or '') + normalized_query = str(query or '').strip() + if not raw_text or not normalized_query: + return raw_text + lower_text = raw_text.lower() + lower_query = normalized_query.lower() + result: list[str] = [] + cursor = 0 + query_length = len(normalized_query) + while True: + index = lower_text.find(lower_query, cursor) + if index < 0: + result.append(raw_text[cursor:]) + break + result.append(raw_text[cursor:index]) + result.append(f'{left_tag}{raw_text[index : index + query_length]}{right_tag}') + cursor = index + query_length + return ''.join(result) + + +class TuiSearchService: + """ + TUI 搜索服务。 + + 该对象负责筛选项解析、筛选条文案构建、搜索上下文装配和详情分区过滤。 + """ + + def __init__(self, provider_registry: TuiSearchSuggestionProviderRegistry) -> None: + """ + 初始化 TUI 搜索服务。 + + :param provider_registry: 页面搜索建议提供者注册表 + :return: None + """ + self.provider_registry = provider_registry + + @staticmethod + def resolve_filter_option( + options: list[PageFilterOption] | tuple[PageFilterOption, ...], + filter_key: str | None, + ) -> PageFilterOption | None: + """ + 从筛选项列表中解析指定键对应的筛选定义。 + + :param options: 筛选项列表 + :param filter_key: 筛选键 + :return: 匹配到的筛选项 + """ + normalized_key = str(filter_key or '').strip().lower() + if not normalized_key: + return None + for option in options: + if option.key == normalized_key: + return option + return None + + def build_filter_bar_text( + self, + options: list[PageFilterOption] | tuple[PageFilterOption, ...], + active_filter_key: str | None, + *, + search_query: str | None = None, + search_placeholder: str | None = None, + search_suggestions: list[str] | None = None, + ) -> str: + """ + 构建浏览页顶部筛选条文本。 + + :param options: 筛选项列表 + :param active_filter_key: 当前激活筛选键 + :param search_query: 当前搜索词 + :param search_placeholder: 搜索提示文本 + :param search_suggestions: 搜索候选建议 + :return: 筛选条文本 + """ + if not options and not search_placeholder: + return '' + lines: list[str] = [] + normalized_active = str(active_filter_key or '').strip().lower() + if options: + rendered_items = [ + f'[{option.shortcut}] {option.label}{" *" if option.key == normalized_active else ""}' + for option in options + ] + active_option = self.resolve_filter_option(options, normalized_active) + active_label = active_option.label if active_option is not None else '未指定' + lines.extend( + [ + f'筛选器 · {" ".join(rendered_items)}', + f'当前筛选 · {active_label}', + ] + ) + if search_placeholder: + normalized_query = str(search_query or '').strip() + lines.append(f'搜索器 · [/] {search_placeholder}') + lines.append(f'当前搜索 · {normalized_query or "未指定"} | [Backspace] 清空') + if search_suggestions: + lines.append(f'候选建议 · {" ".join(search_suggestions[:4])}') + return '\n'.join(lines) + + def resolve_search_context( + self, + view_key: str, + query: str = '', + *, + default_suggestions: list[str] | None = None, + ) -> PageSearchContext | None: + """ + 根据页面视图解析搜索上下文,并复用 completion provider 生成候选建议。 + + :param view_key: 当前页面视图标识 + :param query: 当前搜索词 + :param default_suggestions: 缺省候选建议 + :return: 搜索上下文 + """ + normalized_view = str(view_key).strip().lower() + provider_spec = self.provider_registry.get_provider(normalized_view) + if provider_spec is None: + return None + suggestions = self.resolve_search_suggestions(provider_spec, query) + if not suggestions and default_suggestions: + normalized_query = str(query or '').strip().lower() + suggestions = [ + suggestion + for suggestion in default_suggestions + if suggestion and (not normalized_query or normalized_query in suggestion.lower()) + ] + return PageSearchContext( + placeholder=provider_spec.placeholder, + query=str(query or '').strip(), + suggestions=suggestions[:6], + ) + + @staticmethod + def resolve_search_suggestions(provider_spec: SearchSuggestionProviderSpec, query: str) -> list[str]: + """ + 调用搜索建议提供函数生成当前搜索词的候选建议。 + + :param provider_spec: 搜索建议提供者定义 + :param query: 当前搜索词 + :return: 候选建议列表 + """ + normalized_query = str(query or '').strip() + if not normalized_query or not callable(provider_spec.suggestion_provider): + return [] + try: + candidates = provider_spec.suggestion_provider(normalized_query) + except Exception: + return [] + return [str(candidate).strip() for candidate in candidates if str(candidate).strip()] + + @staticmethod + def filter_detail_sections(sections: list[object], query: str) -> list[object]: + """ + 按搜索词过滤详情页分区列表。 + + :param sections: 原始分区列表 + :param query: 当前搜索词 + :return: 过滤后的分区列表 + """ + normalized_query = str(query or '').strip().lower() + if not normalized_query: + return sections + filtered_sections: list[object] = [] + for section in sections: + title = str(getattr(section, 'title', '') or '').strip().lower() + lines = getattr(section, 'lines', []) + joined_lines = ' '.join(str(line).strip().lower() for line in lines if str(line).strip()) + if normalized_query in title or normalized_query in joined_lines: + filtered_sections.append(section) + return filtered_sections + + +TUI_SEARCH_HIGHLIGHTER = TuiSearchHighlighter() +TUI_SEARCH_PROVIDER_REGISTRY = build_default_search_provider_registry(COMPLETION_PROVIDER_GATEWAY) +TUI_SEARCH_SERVICE = TuiSearchService(TUI_SEARCH_PROVIDER_REGISTRY) diff --git a/shierfeng-fastapi-backend/cli/tui/widgets/__init__.py b/shierfeng-fastapi-backend/cli/tui/widgets/__init__.py new file mode 100644 index 0000000..d51fc91 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/widgets/__init__.py @@ -0,0 +1,27 @@ +from cli.tui.widgets.status_panel import MetricPanel, SignalRail, StatusPanel +from cli.tui.widgets.workspace import ( + NavigationItem, + RecordDetailView, + RecordNavigator, + RecordSummaryView, + SectionDetailView, + SectionNavigator, + WorkspaceHeader, + WorkspaceHero, + WorkspaceSidebar, +) + +__all__ = [ + 'MetricPanel', + 'NavigationItem', + 'RecordDetailView', + 'RecordNavigator', + 'RecordSummaryView', + 'SectionDetailView', + 'SectionNavigator', + 'SignalRail', + 'StatusPanel', + 'WorkspaceHeader', + 'WorkspaceHero', + 'WorkspaceSidebar', +] diff --git a/shierfeng-fastapi-backend/cli/tui/widgets/status_panel.py b/shierfeng-fastapi-backend/cli/tui/widgets/status_panel.py new file mode 100644 index 0000000..6e31ea9 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/widgets/status_panel.py @@ -0,0 +1,323 @@ +from textual.widgets import Static + +from cli.tui.copy import TUI_COPY + + +class StatusPanelRenderingSupport: + """ + 状态面板渲染支持对象。 + + 该对象负责状态类名解析、结构化正文渲染和信号带脉冲文本构建, + 供状态面板、指标卡与信号带组件复用。 + """ + + PULSE_FRAMES = ['·', '•', '◉', '•'] + + @staticmethod + def resolve_status_class(status: str) -> str: + """ + 根据状态值解析样式类。 + + :param status: 面板状态 + :return: 状态样式类名 + """ + normalized_status = str(status).strip().lower() + if normalized_status in {'ok', 'success', 'healthy'}: + return 'is-ok' + if normalized_status in {'fail', 'error', 'down'}: + return 'is-fail' + if normalized_status in {'warn', 'warning', 'degraded'}: + return 'is-warn' + return 'is-info' + + def render_structured_body(self, body: str) -> str: + """ + 将结构化正文文本渲染为更有层次的终端块内容。 + + 支持以下约定: + - `## ` 开头:分组标题 + - `> ` 开头:说明或子项 + - 空字符串:保留空行 + + :param body: 原始正文文本 + :return: 渲染后的正文文本 + """ + rendered_lines: list[str] = [] + for raw_line in body.splitlines(): + stripped = raw_line.strip() + if not stripped: + if rendered_lines and rendered_lines[-1] != '': + rendered_lines.append('') + continue + if stripped.startswith('## '): + title = stripped[3:].strip() + if rendered_lines and rendered_lines[-1] != '': + rendered_lines.append('') + rendered_lines.append(f'【{title}】') + rendered_lines.append('─' * max(10, min(len(title) * 2, 28))) + continue + if stripped.startswith('> '): + rendered_lines.append(f'│ {stripped[2:].strip()}') + continue + rendered_lines.append(f'• {stripped}') + + while rendered_lines and rendered_lines[0] == '': + rendered_lines.pop(0) + while rendered_lines and rendered_lines[-1] == '': + rendered_lines.pop() + return '\n'.join(rendered_lines) if rendered_lines else f'- {TUI_COPY.build_status_panel_empty_text()}' + + def build_signal_rail_text(self, lines: list[str], pulse_index: int) -> str: + """ + 构建信号带渲染文本。 + + :param lines: 信号文本行 + :param pulse_index: 当前脉冲帧索引 + :return: 渲染文本 + """ + pulse = self.PULSE_FRAMES[pulse_index % len(self.PULSE_FRAMES)] + body = '\n'.join(line for line in lines if line.strip()) + return TUI_COPY.build_signal_rail_text(pulse, body or TUI_COPY.build_signal_rail_empty_text()) + + +STATUS_PANEL_RENDERING = StatusPanelRenderingSupport() + + +class StatusPanel(Static): + """ + TUI 状态面板组件。 + + :param title: 面板标题 + :param status: 面板状态 + :param body: 面板正文 + """ + + DEFAULT_CSS = """ + StatusPanel { + border: double #245874; + padding: 1 2; + margin: 0; + height: 17; + min-height: 17; + background: #08131f; + color: #def8ff; + } + + StatusPanel.is-ok { + border: double #22c983; + background: #081b17; + } + + StatusPanel.is-fail { + border: double #ff6b7a; + background: #241118; + } + + StatusPanel.is-warn { + border: double #f2b84b; + background: #261d0f; + } + + StatusPanel.is-info { + border: double #38d8ff; + background: #081825; + } + + StatusPanel:focus { + border: heavy #6df7ff; + background: #10364b; + } + """ + + def __init__( + self, + title: str, + status: str, + body: str, + rendering: StatusPanelRenderingSupport | None = None, + ) -> None: + """ + 初始化状态面板。 + + :param title: 面板标题 + :param status: 面板状态 + :param body: 面板正文 + :param rendering: 状态面板渲染支持对象 + :return: None + """ + self.title = title + self.status = status + self.body = body + self.rendering = rendering or STATUS_PANEL_RENDERING + super().__init__(self._build_render_text(), markup=False) + self.can_focus = True + self.add_class(self.rendering.resolve_status_class(self.status)) + + def _build_render_text(self) -> str: + """ + 构建面板渲染文本。 + + :return: 渲染文本 + """ + rendered_body = self.rendering.render_structured_body(self.body) + normalized_status = TUI_COPY.render_status_label(self.status) + return TUI_COPY.build_status_panel_text( + self.title, + TUI_COPY.render_status_code(self.status), + normalized_status, + rendered_body, + ) + + +class MetricPanel(Static): + """ + TUI 首页驾驶舱指标卡组件。 + + :param title: 指标标题 + :param value: 指标主值 + :param status: 指标状态 + :param hint: 指标补充说明 + """ + + DEFAULT_CSS = """ + MetricPanel { + border: double #245874; + padding: 1 2; + height: 12; + min-height: 12; + background: #08131f; + color: #effcff; + } + + MetricPanel.is-primary { + border: heavy #6df7ff; + height: 12; + min-height: 12; + background: #0a1725; + text-style: bold; + } + + MetricPanel.is-ok { + border: double #22c983; + background: #081b17; + } + + MetricPanel.is-fail { + border: double #ff6b7a; + background: #241118; + } + + MetricPanel.is-warn { + border: double #f2b84b; + background: #261d0f; + } + + MetricPanel.is-info { + border: double #38d8ff; + background: #081825; + } + """ + + def __init__( + self, + title: str, + value: str, + status: str, + hint: str, + *, + accent: bool = False, + rendering: StatusPanelRenderingSupport | None = None, + ) -> None: + """ + 初始化指标卡。 + + :param title: 指标标题 + :param value: 指标主值 + :param status: 指标状态 + :param hint: 指标补充说明 + :param accent: 是否作为主指标高亮展示 + :param rendering: 状态面板渲染支持对象 + :return: None + """ + self.title = title + self.value = value + self.status = status + self.hint = hint + self.rendering = rendering or STATUS_PANEL_RENDERING + super().__init__(self._build_render_text(), markup=False) + self.add_class(self.rendering.resolve_status_class(self.status)) + if accent: + self.add_class('is-primary') + + def _build_render_text(self) -> str: + """ + 构建指标卡渲染文本。 + + :return: 渲染文本 + """ + normalized_status = TUI_COPY.render_status_label(self.status) + return TUI_COPY.build_metric_panel_text( + self.title, + self.value, + TUI_COPY.render_status_code(self.status), + normalized_status, + self.hint, + ) + + +class SignalRail(Static): + """ + 首页信号带组件,用于展示简洁的系统态势条目。 + + :param lines: 信号文本行 + """ + + DEFAULT_CSS = """ + SignalRail { + border: double #1db9d1; + padding: 1 2; + margin-top: 1; + margin-bottom: 1; + min-height: 6; + background: #06111b; + color: #e9fdff; + } + """ + + def __init__(self, lines: list[str], rendering: StatusPanelRenderingSupport | None = None) -> None: + """ + 初始化首页信号带。 + + :param lines: 信号文本行 + :param rendering: 状态面板渲染支持对象 + :return: None + """ + self.lines = lines + self._pulse_index = 0 + self.rendering = rendering or STATUS_PANEL_RENDERING + super().__init__(self._build_render_text(), markup=False) + + def on_mount(self) -> None: + """ + 挂载后启动轻量脉冲刷新。 + + :return: None + """ + self.set_interval(0.8, self._advance_pulse, name='signal rail pulse') + + def _advance_pulse(self) -> None: + """ + 推进信号带脉冲状态。 + + :return: None + """ + self._pulse_index = (self._pulse_index + 1) % 4 + self.update(self._build_render_text()) + + def _build_render_text(self) -> str: + """ + 构建信号带渲染文本。 + + :return: 渲染文本 + """ + return self.rendering.build_signal_rail_text(self.lines, self._pulse_index) diff --git a/shierfeng-fastapi-backend/cli/tui/widgets/workspace.py b/shierfeng-fastapi-backend/cli/tui/widgets/workspace.py new file mode 100644 index 0000000..52ac4af --- /dev/null +++ b/shierfeng-fastapi-backend/cli/tui/widgets/workspace.py @@ -0,0 +1,1262 @@ +from dataclasses import dataclass +from datetime import datetime +from math import sin +from time import monotonic + +from rich.text import Text +from textual.message import Message +from textual.widgets import Label, ListItem, ListView, Static + +from cli.tui.adapters.models import BrowserRecordSnapshot, DetailSectionSnapshot +from cli.tui.copy import TUI_COPY +from cli.tui.keymaps import TUI_KEYMAP_REGISTRY +from cli.tui.search import TUI_SEARCH_HIGHLIGHTER + + +class WorkspaceRenderingSupport: + """ + 工作台组件渲染支持对象。 + + 该对象负责状态徽标、状态类名、结构化文本渲染和预览提取, + 供工作台中的导航项、详情视图和摘要视图复用。 + """ + + @staticmethod + def highlight_search_text(text: str, query: str) -> str: + """ + 对工作台文本执行搜索高亮。 + + :param text: 原始文本 + :param query: 搜索词 + :return: 高亮后的文本 + """ + return TUI_SEARCH_HIGHLIGHTER.highlight(text, query) + + @staticmethod + def render_status_badge(status: str) -> str: + """ + 将状态值转换为紧凑徽标文本。 + + :param status: 原始状态值 + :return: 徽标文本 + """ + return f'[{TUI_COPY.render_status_code(status)}]' + + @staticmethod + def resolve_status_class(status: str) -> str: + """ + 根据状态值解析统一样式类。 + + :param status: 原始状态值 + :return: 状态样式类名 + """ + normalized_status = str(status).strip().lower() + if normalized_status in {'ok', 'success', 'healthy'}: + return 'is-ok' + if normalized_status in {'fail', 'error'}: + return 'is-fail' + if normalized_status in {'warn', 'warning'}: + return 'is-warn' + return 'is-info' + + @staticmethod + def strip_line_markup(line: str) -> str: + """ + 去除内部展示标记,便于提取预览文本。 + + :param line: 原始文本行 + :return: 纯文本内容 + """ + stripped = line.strip() + if stripped.startswith('## '): + return stripped[3:].strip() + if stripped.startswith('> '): + return stripped[2:].strip() + return stripped + + def build_preview_line(self, lines: list[str], fallback: str) -> str: + """ + 从结构化文本中提取首条适合作为预览的内容。 + + :param lines: 原始文本行列表 + :param fallback: 兜底文本 + :return: 预览文本 + """ + for line in lines: + preview = self.strip_line_markup(line) + if preview: + return preview + return fallback + + def render_structured_lines(self, lines: list[str], empty_text: str) -> str: + """ + 将结构化文本行渲染为适合终端展示的块文本。 + + 支持以下轻量约定: + - `## ` 开头:区块标题 + - `> ` 开头:说明或子项 + - 空字符串:保留空行 + + :param lines: 原始文本行列表 + :param empty_text: 空态提示文本 + :return: 渲染后的文本块 + """ + rendered_lines: list[str] = [] + for raw_line in lines: + stripped = raw_line.strip() + if not stripped: + if rendered_lines and rendered_lines[-1] != '': + rendered_lines.append('') + continue + if stripped.startswith('## '): + title = stripped[3:].strip() + if rendered_lines and rendered_lines[-1] != '': + rendered_lines.append('') + rendered_lines.append(f'【{title}】') + rendered_lines.append('─' * max(10, min(len(title) * 2, 30))) + continue + if stripped.startswith('> '): + rendered_lines.append(f'│ {stripped[2:].strip()}') + continue + rendered_lines.append(f'• {stripped}') + + while rendered_lines and rendered_lines[0] == '': + rendered_lines.pop(0) + while rendered_lines and rendered_lines[-1] == '': + rendered_lines.pop() + + if not rendered_lines: + return f'- {empty_text}' + return '\n'.join(rendered_lines) + + +WORKSPACE_RENDERING = WorkspaceRenderingSupport() + + +@dataclass(frozen=True) +class NavigationItem: + """ + 工作台导航项定义。 + + :param view_key: 视图唯一标识 + :param label: 导航显示名称 + :param shortcut: 快捷键 + :param description: 导航说明 + """ + + view_key: str + label: str + shortcut: str + description: str + + +class NavigationListItem(ListItem): + """ + 工作台导航列表项。 + + :param item: 导航项定义 + """ + + DEFAULT_CSS = """ + NavigationListItem { + padding: 0 1; + margin-bottom: 1; + border: round #18425d; + background: #07131f; + color: #89b9c9; + height: auto; + } + + NavigationListItem Label { + width: 1fr; + } + + NavigationListItem.is-active { + border: round #38d8ff; + background: #0a1e2d; + color: #e4fbff; + } + + NavigationListItem.is-active Label { + text-style: bold; + } + + NavigationListItem.-highlight { + border: heavy #6df7ff; + background: #10364b; + color: #ffffff; + text-style: bold; + padding-left: 2; + } + """ + + def __init__(self, item: NavigationItem, index: int, *, active: bool = False) -> None: + """ + 初始化导航列表项。 + + :param item: 导航项定义 + :return: None + """ + self.item = item + super().__init__( + Label( + '\n'.join(TUI_COPY.build_navigation_item_lines(index, item.label, item.shortcut, item.description)), + markup=False, + ) + ) + if active: + self.add_class('is-active') + + +class WorkspaceSidebar(ListView): + """ + 工作台左侧导航组件。 + + :param env: 当前运行环境 + :param items: 导航项列表 + :param active_view: 当前激活视图 + """ + + DEFAULT_CSS = """ + WorkspaceSidebar { + width: 34; + min-width: 34; + padding: 1; + margin: 1 0 1 1; + border: double #18425d; + background: #06111b; + } + + WorkspaceSidebar:focus { + border: heavy #6df7ff; + } + """ + + def __init__(self, env: str, items: list[NavigationItem], active_view: str) -> None: + """ + 初始化工作台导航组件。 + + :param env: 当前运行环境 + :param items: 导航项列表 + :param active_view: 当前激活视图 + :return: None + """ + self.env = env + self.items = items + self.active_view = active_view + initial_index = self._resolve_initial_index() + children = [ + NavigationListItem(item, index, active=item.view_key == self.active_view) + for index, item in enumerate(self.items) + ] + super().__init__(*children, initial_index=initial_index, id='workspace-sidebar') + + def _resolve_initial_index(self) -> int: + """ + 解析当前激活视图对应的导航索引。 + + :return: 初始高亮索引 + """ + for index, item in enumerate(self.items): + if item.view_key == self.active_view: + return index + return 0 + + +class WorkspaceHero(Static): + """ + 工作台顶部摘要组件。 + + :param title: 页面标题 + :param subtitle: 页面副标题 + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :param summary: 状态摘要文本 + :param refreshed_at: 本次刷新时间 + """ + + DEFAULT_CSS = """ + WorkspaceHero { + border: double #38d8ff; + padding: 1 2; + margin-bottom: 1; + min-height: 11; + background: #081827; + color: #e9fcff; + } + """ + _BORDER_PHASES = ('#2fb8df', '#38d8ff', '#62ecff', '#38d8ff') + _TITLE_GLOW_PALETTE = ('#7be8ff', '#b4f6ff', '#e9fdff', '#b4f6ff') + + def __init__( + self, + title: str, + subtitle: str, + env: str, + active_view: str, + summary: str, + refreshed_at: str, + ) -> None: + """ + 初始化工作台顶部摘要组件。 + + :param title: 页面标题 + :param subtitle: 页面副标题 + :param active_view: 当前激活视图 + :param summary: 状态摘要文本 + :param refreshed_at: 本次刷新时间 + :return: None + """ + self.title = title + self.subtitle = subtitle + self.env = env + self.active_view = active_view + self.summary = summary + self.refreshed_at = refreshed_at + super().__init__(self._build_render_text(), id='workspace-hero', markup=False) + + def on_mount(self) -> None: + """ + 在组件挂载后启动低频边框呼吸动效。 + + :return: None + """ + self.set_interval(0.9, self._pulse_border, name='pulse workspace hero border') + + def _pulse_border(self) -> None: + """ + 以低频节奏切换 Hero 边框颜色。 + + :return: None + """ + phase_index = int(monotonic() * 1.1) % len(self._BORDER_PHASES) + self.styles.border = ('double', self._BORDER_PHASES[phase_index]) + + @classmethod + def _build_title_text(cls, title: str) -> Text: + """ + 构建 Hero 标题渐变文本。 + + :param title: 标题文本 + :return: 富文本标题 + """ + rendered = Text() + palette_size = len(cls._TITLE_GLOW_PALETTE) + for index, char in enumerate(title): + rendered.append(char, style=f'bold {cls._TITLE_GLOW_PALETTE[index % palette_size]}') + return rendered + + def _build_render_text(self) -> Text: + """ + 构建顶部摘要文本。 + + :return: 渲染富文本 + """ + lines = TUI_COPY.build_workspace_hero_lines( + view_label=TUI_COPY.render_view_label(self.active_view), + title=self.title, + subtitle=self.subtitle, + env=self.env, + summary=self.summary, + refreshed_at=self.refreshed_at, + shortcut_hint=TUI_KEYMAP_REGISTRY.hero_shortcut_hint, + ) + rendered = Text() + for index, line in enumerate(lines): + if index: + rendered.append('\n') + if line.startswith('标题 · '): + rendered.append('标题 · ', style='bold #8fd9e8') + rendered.append_text(self._build_title_text(line.removeprefix('标题 · '))) + continue + rendered.append(line, style='bold #e9fcff' if index == 0 else '#d7f7ff') + return rendered + + +class WorkspaceHeader(Static): + """ + 工作台顶部状态栏。 + + :param env: 当前运行环境 + :param active_view: 当前激活视图 + """ + + DEFAULT_CSS = """ + WorkspaceHeader { + height: 2; + padding: 0 2; + background: #060d16; + color: #c7f6ff; + border-bottom: heavy #163c57; + content-align: left middle; + text-style: bold; + } + """ + _HEADER_LINE_COUNT = 2 + _SCANLINE_WIDTH = 16 + _HEADER_HORIZONTAL_PADDING = 4 + _HEADER_FALLBACK_WIDTH = 108 + _SCANLINE_BASE_STYLE = '#1c5368' + _SCANLINE_GLOW_STYLES = { + '·': '#8ef7ff', + '░': '#62ecff', + '▒': '#4ee4ff', + '▓': '#2cc9ff', + '█': '#dffcff', + } + + @classmethod + def build_scanline_text(cls, phase: float, width: int) -> Text: + """ + 构建页眉扫描线文本。 + + 通过单个高亮扫描头和两侧衰减尾迹,营造简洁的霓虹扫线效果。 + + :param phase: 当前动画相位 + :param width: 扫描线宽度 + :return: 带颜色层次的扫描线文本 + """ + cells = ['─'] * width + head_position = int(((sin(phase * 0.82) + 1) / 2) * (width - 1)) + for offset, symbol in ((-2, '·'), (-1, '░'), (0, '█'), (1, '▓'), (2, '▒')): + position = head_position + offset + if 0 <= position < width: + cells[position] = symbol + rendered = Text() + for symbol in cells: + rendered.append(symbol, style=cls._SCANLINE_GLOW_STYLES.get(symbol, cls._SCANLINE_BASE_STYLE)) + return rendered + + def __init__(self, env: str, active_view: str) -> None: + """ + 初始化工作台顶部状态栏。 + + :param env: 当前运行环境 + :param active_view: 当前激活视图 + :return: None + """ + self.env = env + self.active_view = active_view + super().__init__(id='workspace-header', markup=False) + + def on_mount(self) -> None: + """ + 在组件挂载后启动时钟刷新。 + + :return: None + """ + self.set_interval(0.18, self.refresh, name='update workspace header') + + def build_scanline_text_label(self) -> Text: + """ + 构建扫描线文本。 + + :return: 扫描线文本 + """ + phase = monotonic() * 2.1 + return self.build_scanline_text(phase, self._SCANLINE_WIDTH) + + def build_symmetric_effects(self) -> tuple[Text, Text]: + """ + 构建标题两侧的对称动效文本。 + + 左右两侧都使用固定宽度,确保标题中心点稳定,不随动效变化漂移。 + + :return: 左右动效文本 + """ + left_effect = self.build_scanline_text_label() + right_effect = Text() + for span_symbol in reversed(left_effect.plain): + right_effect.append( + span_symbol, + style=self._SCANLINE_GLOW_STYLES.get(span_symbol, self._SCANLINE_BASE_STYLE), + ) + return left_effect, right_effect + + def build_centered_title_line(self, title_text: str) -> Text: + """ + 构建居中的标题装饰行。 + + 标题左右分别放置镜像扫描线动效,中间保留标题主体。 + + :param title_text: 标题主体文本 + :return: 居中后的富文本标题行 + """ + left_effect, right_effect = self.build_symmetric_effects() + decorated_title = Text() + decorated_title.append_text(left_effect) + decorated_title.append(' ') + decorated_title.append(title_text, style='bold #e8fdff') + decorated_title.append(' ') + decorated_title.append_text(right_effect) + available_width = self.size.width - self._HEADER_HORIZONTAL_PADDING + if available_width <= 0: + available_width = self._HEADER_FALLBACK_WIDTH + centered = Text( + ' ' * max((max(available_width, len(decorated_title.plain)) - len(decorated_title.plain)) // 2, 0) + ) + centered.append_text(decorated_title) + return centered + + def render(self) -> Text: + """ + 渲染顶部状态栏文本。 + + :return: 渲染富文本 + """ + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + lines = TUI_COPY.build_workspace_header_lines( + env=self.env, + view_label=TUI_COPY.render_view_label(self.active_view), + timestamp=timestamp, + shortcut_hint=TUI_KEYMAP_REGISTRY.global_shortcut_hint, + ) + rendered = Text() + if len(lines) >= self._HEADER_LINE_COUNT: + rendered.append_text(self.build_centered_title_line(lines[0])) + rendered.append('\n') + rendered.append(lines[1], style='bold #c7f6ff') + return rendered + rendered.append('\n'.join(lines), style='bold #c7f6ff') + return rendered + + +class SectionListItem(ListItem): + """ + 详情页分区导航列表项。 + + :param section: 分区快照 + :param index: 分区索引 + """ + + DEFAULT_CSS = """ + SectionListItem { + padding: 0 1; + margin-bottom: 1; + border: round #123148; + background: #07131f; + color: #a3c8d2; + height: auto; + } + + SectionListItem Label { + width: 1fr; + } + + SectionListItem.is-ok { + border: round #22c983; + background: #081b17; + } + + SectionListItem.is-fail { + border: round #ff6b7a; + background: #241118; + } + + SectionListItem.is-warn { + border: round #f2b84b; + background: #261d0f; + } + + SectionListItem.is-info { + border: round #38d8ff; + background: #081825; + } + + SectionListItem.-highlight { + border: heavy #6df7ff; + background: #10364b; + color: #ffffff; + text-style: bold; + padding-left: 2; + } + """ + + def __init__( + self, + section: DetailSectionSnapshot, + index: int, + query: str = '', + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化详情页分区导航项。 + + :param section: 分区快照 + :param index: 分区索引 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.section = section + self.index = index + self.search_query = query + self.rendering = rendering or WORKSPACE_RENDERING + preview_line = self.rendering.build_preview_line(section.lines, TUI_COPY.build_workspace_empty_text('detail')) + super().__init__( + Label( + '\n'.join( + TUI_COPY.build_section_item_lines( + index, + self.rendering.highlight_search_text(section.title, query), + self.rendering.render_status_badge(section.status), + self.rendering.highlight_search_text(preview_line, query), + ) + ), + markup=False, + ) + ) + self.add_class(self.rendering.resolve_status_class(section.status)) + + +class SectionNavigator(ListView): + """ + 详情页分区导航组件。 + + :param sections: 分区快照列表 + :param initial_index: 初始高亮索引 + """ + + DEFAULT_CSS = """ + SectionNavigator { + width: 30; + min-width: 30; + border: double #18425d; + padding: 1; + margin-right: 1; + background: #06111b; + } + + SectionNavigator:focus { + border: heavy #6df7ff; + } + """ + + class Changed(Message): + """ + 分区导航高亮变更消息。 + """ + + def __init__(self, navigator: 'SectionNavigator', index: int, item: SectionListItem) -> None: + super().__init__() + self.navigator = navigator + self.index = index + self.item = item + + @property + def control(self) -> 'SectionNavigator': + return self.navigator + + def __init__( + self, + sections: list[DetailSectionSnapshot], + initial_index: int = 0, + query: str = '', + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化详情页分区导航。 + + :param sections: 分区快照列表 + :param initial_index: 初始高亮索引 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.sections = sections + self.search_query = query + self.rendering = rendering or WORKSPACE_RENDERING + children = [ + SectionListItem(section, index, query=query, rendering=self.rendering) + for index, section in enumerate(sections) + ] + super().__init__(*children, initial_index=initial_index) + + def watch_index(self, old_index: int | None, new_index: int | None) -> None: + """ + 在高亮变化后向父级发送明确的分区切换消息。 + + :param old_index: 旧索引 + :param new_index: 新索引 + :return: None + """ + super().watch_index(old_index, new_index) + if new_index is None or not 0 <= new_index < len(self._nodes): + return + item = self._nodes[new_index] + if isinstance(item, SectionListItem): + self.post_message(self.Changed(self, new_index, item)) + + async def show_sections( + self, sections: list[DetailSectionSnapshot], initial_index: int = 0, query: str = '' + ) -> None: + """ + 刷新分区导航内容并恢复高亮位置。 + + :param sections: 最新分区快照列表 + :param initial_index: 目标高亮索引 + :return: None + """ + self.sections = sections + self.search_query = query + await self.clear() + children = [ + SectionListItem(section, index, query=query, rendering=self.rendering) + for index, section in enumerate(sections) + ] + if not children: + self.index = None + return + await self.extend(children) + self.index = max(0, min(initial_index, len(children) - 1)) + + +class SectionDetailView(Static): + """ + 详情页分区内容展示组件。 + + :param section: 当前展示的分区快照 + """ + + DEFAULT_CSS = """ + SectionDetailView { + border: double #245874; + padding: 1 2; + background: #08131f; + color: #ddf8ff; + width: 1fr; + min-height: 18; + overflow-x: auto; + overflow-y: auto; + } + + SectionDetailView.is-ok { + border: double #22c983; + background: #081b17; + } + + SectionDetailView.is-fail { + border: double #ff6b7a; + background: #241118; + } + + SectionDetailView.is-warn { + border: double #f2b84b; + background: #261d0f; + } + + SectionDetailView.is-info { + border: double #38d8ff; + background: #081825; + } + + SectionDetailView:focus { + border: heavy #6df7ff; + background: #10364b; + } + """ + + def __init__( + self, + section: DetailSectionSnapshot, + query: str = '', + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化详情页分区内容展示组件。 + + :param section: 当前展示的分区快照 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.section = section + self.search_query = query + self.rendering = rendering or WORKSPACE_RENDERING + super().__init__(markup=False) + self.can_focus = True + self._render_section() + + def show_section(self, section: DetailSectionSnapshot, query: str = '') -> None: + """ + 切换并渲染当前展示的分区内容。 + + :param section: 待展示的分区快照 + :return: None + """ + self.section = section + self.search_query = query + self._render_section() + + def _render_section(self) -> None: + """ + 渲染当前分区内容。 + + :return: None + """ + self.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info') + self.add_class(self.rendering.resolve_status_class(self.section.status)) + highlighted_lines = [ + self.rendering.highlight_search_text(line, self.search_query) for line in self.section.lines + ] + body = self.rendering.render_structured_lines( + highlighted_lines, + TUI_COPY.build_workspace_empty_text('detail'), + ) + self.update( + '\n'.join( + TUI_COPY.build_section_detail_lines( + self.rendering.highlight_search_text(self.section.title, self.search_query), + TUI_COPY.render_status_label(self.section.status), + self.rendering.render_status_badge(self.section.status), + body, + ) + ) + ) + + +class RecordListItem(ListItem): + """ + 浏览页记录导航列表项。 + + :param record: 记录快照 + :param index: 记录索引 + """ + + DEFAULT_CSS = """ + RecordListItem { + padding: 0 1; + margin-bottom: 1; + border: round #123148; + background: #07131f; + color: #a3c8d2; + height: auto; + } + + RecordListItem Label { + width: 1fr; + } + + RecordListItem.is-ok { + border: round #22c983; + background: #081b17; + } + + RecordListItem.is-fail { + border: round #ff6b7a; + background: #241118; + } + + RecordListItem.is-warn { + border: round #f2b84b; + background: #261d0f; + } + + RecordListItem.is-info { + border: round #38d8ff; + background: #081825; + } + + RecordListItem.-highlight { + border: heavy #6df7ff; + background: #10364b; + color: #ffffff; + text-style: bold; + padding-left: 2; + } + """ + + def __init__( + self, + record: BrowserRecordSnapshot, + index: int, + query: str = '', + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化浏览页记录导航项。 + + :param record: 记录快照 + :param index: 记录索引 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.record = record + self.index = index + self.search_query = query + self.rendering = rendering or WORKSPACE_RENDERING + super().__init__( + Label( + '\n'.join( + TUI_COPY.build_record_item_lines( + index, + self.rendering.highlight_search_text(record.title, query), + self.rendering.render_status_badge(record.status), + self.rendering.highlight_search_text(record.summary, query), + ) + ), + markup=False, + ) + ) + self.add_class(self.rendering.resolve_status_class(record.status)) + + +class RecordNavigator(ListView): + """ + 浏览页记录导航组件。 + + :param records: 记录快照列表 + :param initial_index: 初始高亮索引 + """ + + DEFAULT_CSS = """ + RecordNavigator { + width: 34; + min-width: 34; + border: double #18425d; + padding: 1; + margin-right: 1; + background: #06111b; + } + + RecordNavigator:focus { + border: heavy #6df7ff; + } + """ + + class Changed(Message): + """ + 记录导航高亮变更消息。 + """ + + def __init__(self, navigator: 'RecordNavigator', index: int, item: RecordListItem) -> None: + super().__init__() + self.navigator = navigator + self.index = index + self.item = item + + @property + def control(self) -> 'RecordNavigator': + return self.navigator + + def __init__( + self, + records: list[BrowserRecordSnapshot], + initial_index: int = 0, + query: str = '', + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化浏览页记录导航。 + + :param records: 记录快照列表 + :param initial_index: 初始高亮索引 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.records = records + self.search_query = query + self.rendering = rendering or WORKSPACE_RENDERING + children = [ + RecordListItem(record, index, query=query, rendering=self.rendering) for index, record in enumerate(records) + ] + super().__init__(*children, initial_index=initial_index, id='record-navigator') + + def watch_index(self, old_index: int | None, new_index: int | None) -> None: + """ + 在高亮变化后向父级发送明确的记录切换消息。 + + :param old_index: 旧索引 + :param new_index: 新索引 + :return: None + """ + super().watch_index(old_index, new_index) + if new_index is None or not 0 <= new_index < len(self._nodes): + return + item = self._nodes[new_index] + if isinstance(item, RecordListItem): + self.post_message(self.Changed(self, new_index, item)) + + +class RecordDetailView(Static): + """ + 浏览页记录详情展示组件。 + + :param record: 当前展示的记录快照 + :param shared_sections: 页面共享分区列表 + """ + + DEFAULT_CSS = """ + RecordDetailView { + border: double #245874; + padding: 1 2; + background: #08131f; + color: #ddf8ff; + width: 1fr; + min-height: 18; + overflow-x: auto; + overflow-y: auto; + } + + RecordDetailView.is-ok { + border: double #22c983; + } + + RecordDetailView.is-fail { + border: double #ff6b7a; + } + + RecordDetailView.is-warn { + border: double #f2b84b; + } + + RecordDetailView.is-info { + border: double #38d8ff; + } + + RecordDetailView:focus { + border: heavy #6df7ff; + background: #10364b; + } + """ + + def __init__( + self, + record: BrowserRecordSnapshot, + shared_sections: list[DetailSectionSnapshot], + query: str = '', + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化浏览页记录详情展示组件。 + + :param record: 当前展示的记录快照 + :param shared_sections: 页面共享分区列表 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.record = record + self.shared_sections = shared_sections + self.search_query = query + self.rendering = rendering or WORKSPACE_RENDERING + super().__init__(id='record-detail-view', markup=False) + self.can_focus = True + self._render_record() + + def show_record( + self, + record: BrowserRecordSnapshot, + shared_sections: list[DetailSectionSnapshot], + query: str = '', + ) -> None: + """ + 切换并渲染当前展示的记录内容。 + + :param record: 待展示的记录快照 + :param shared_sections: 页面共享分区列表 + :return: None + """ + self.record = record + self.shared_sections = shared_sections + self.search_query = query + self._render_record() + + def _render_record(self) -> None: + """ + 渲染当前记录内容。 + + :return: None + """ + self.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info') + self.add_class(self.rendering.resolve_status_class(self.record.status)) + lines = [ + ( + f'{self.rendering.render_status_badge(self.record.status)} ' + f'{self.rendering.highlight_search_text(self.record.title, self.search_query)}' + ), + f'{TUI_COPY.build_workspace_label("overview")} · {self.rendering.highlight_search_text(self.record.summary, self.search_query)}', + '', + ] + if self.record.metadata_lines: + lines.append(TUI_COPY.build_workspace_title('key_info')) + lines.append('────────────────') + lines.append( + self.rendering.render_structured_lines( + [ + self.rendering.highlight_search_text(line, self.search_query) + for line in self.record.metadata_lines + ], + TUI_COPY.build_workspace_empty_text('key_info'), + ) + ) + lines.append('') + + combined_sections = [*self.record.detail_sections, *self.shared_sections] + if not combined_sections: + lines.append(TUI_COPY.build_workspace_title('detail_content')) + lines.append('========') + lines.append(f'- {TUI_COPY.build_workspace_empty_text("detail")}') + else: + for section in combined_sections: + lines.append('-' * 44) + lines.append( + f'{self.rendering.render_status_badge(section.status)} ' + f'{self.rendering.highlight_search_text(section.title, self.search_query)}' + ) + lines.append( + self.rendering.render_structured_lines( + [self.rendering.highlight_search_text(line, self.search_query) for line in section.lines], + TUI_COPY.build_workspace_empty_text('detail'), + ) + ) + lines.append('') + self.update('\n'.join(lines).strip()) + + +class RecordSummaryView(Static): + """ + 浏览页记录摘要展示组件。 + + :param record: 当前展示的记录快照 + """ + + DEFAULT_CSS = """ + RecordSummaryView { + border: double #38d8ff; + padding: 1 2; + margin-bottom: 1; + background: #081827; + color: #e9fcff; + overflow-x: auto; + overflow-y: auto; + } + + RecordSummaryView.is-ok { + border: double #22c983; + background: #081b17; + } + + RecordSummaryView.is-fail { + border: double #ff6b7a; + background: #241118; + } + + RecordSummaryView.is-warn { + border: double #f2b84b; + background: #261d0f; + } + + RecordSummaryView.is-info { + border: double #38d8ff; + background: #081825; + } + + RecordSummaryView.section-ok { + border-left: heavy #22c983; + } + + RecordSummaryView.section-fail { + border-left: heavy #ff6b7a; + } + + RecordSummaryView.section-warn { + border-left: heavy #f2b84b; + } + + RecordSummaryView.section-info { + border-left: heavy #38d8ff; + } + + RecordSummaryView:focus { + border: heavy #6df7ff; + background: #10364b; + } + """ + + def __init__( + self, + record: BrowserRecordSnapshot, + rendering: WorkspaceRenderingSupport | None = None, + ) -> None: + """ + 初始化浏览页记录摘要展示组件。 + + :param record: 当前展示的记录快照 + :param rendering: 工作台组件渲染支持对象 + :return: None + """ + self.record = record + self.section: DetailSectionSnapshot | None = None + self.action_lines: list[str] = [] + self.search_query = '' + self.rendering = rendering or WORKSPACE_RENDERING + super().__init__(id='record-summary-view', markup=False) + self.can_focus = True + self._render_record() + + def show_record( + self, + record: BrowserRecordSnapshot, + selected_section: DetailSectionSnapshot | None = None, + action_lines: list[str] | None = None, + query: str = '', + ) -> None: + """ + 更新当前展示的记录摘要。 + + :param record: 待展示的记录快照 + :param selected_section: 当前联动分区 + :param action_lines: 动作摘要文本 + :return: None + """ + self.record = record + self.section = selected_section + self.action_lines = list(action_lines or []) + self.search_query = query + self._render_record() + + def _render_record(self) -> None: + """ + 渲染当前记录摘要。 + + :return: None + """ + self.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info') + self.remove_class('section-ok', 'section-fail', 'section-warn', 'section-info') + self.add_class(self.rendering.resolve_status_class(self.record.status)) + if self.section is not None: + self.add_class( + f'section-{self.section.status if self.section.status in {"ok", "fail", "warn"} else "info"}' + ) + lines = [ + ( + f'{self.rendering.render_status_badge(self.record.status)} ' + f'{self.rendering.highlight_search_text(self.record.title, self.search_query)}' + ), + f'{TUI_COPY.build_workspace_label("overview")} · {self.rendering.highlight_search_text(self.record.summary, self.search_query)}', + ] + if self.section is not None: + lines.append( + f'{TUI_COPY.build_workspace_label("current_section")} · {self.rendering.highlight_search_text(self.section.title, self.search_query)} / {TUI_COPY.render_status_label(self.section.status)}' + ) + if self.record.metadata_lines: + lines.append('') + lines.append(TUI_COPY.build_workspace_title('key_fields')) + lines.append('────────────────') + lines.append( + self.rendering.render_structured_lines( + [ + self.rendering.highlight_search_text(line, self.search_query) + for line in self.record.metadata_lines + ], + TUI_COPY.build_workspace_empty_text('key_info'), + ) + ) + if self.action_lines: + lines.append('') + lines.append(TUI_COPY.build_workspace_title('action_feedback')) + lines.append('────────────────') + lines.append( + self.rendering.render_structured_lines( + [self.rendering.highlight_search_text(line, self.search_query) for line in self.action_lines], + TUI_COPY.build_workspace_empty_text('actions'), + ) + ) + self.update('\n'.join(lines)) diff --git a/shierfeng-fastapi-backend/cli/utils.py b/shierfeng-fastapi-backend/cli/utils.py new file mode 100644 index 0000000..28e4221 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/utils.py @@ -0,0 +1,366 @@ +import json +import os +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from cli.exit_codes import RUNTIME_ERROR +from cli.runtime.base import RUNTIME_ENVIRONMENT + +_SNAKE_CASE_BOUNDARY_PATTERN = re.compile(r'(? str: + """ + 将命令参数列表格式化为 shell 可读文本。 + + :param arguments: 命令参数列表 + :return: shell 命令文本 + """ + return shlex.join(arguments) + + @staticmethod + def truncate_text(value: object, max_length: int) -> str: + """ + 将文本截断到指定长度。 + + :param value: 原始文本值 + :param max_length: 最大长度 + :return: 截断后的文本 + """ + text = '' if value is None else str(value).strip() + if len(text) <= max_length: + return text + return f'{text[: max_length - 3]}...' + + @staticmethod + def to_snake_case(value: object) -> str: + """ + 将字段名标准化为 `snake_case` 形式。 + + :param value: 原始字段名 + :return: `snake_case` 字段名 + """ + text = '' if value is None else str(value).strip() + if not text: + return '' + normalized_text = text.replace('-', '_').replace(' ', '_') + return _SNAKE_CASE_BOUNDARY_PATTERN.sub('_', normalized_text).lower() + + +@dataclass(frozen=True) +class NestedCliProjectLocator: + """ + 内部 CLI 项目目录定位器。 + + 该对象负责识别后端根目录,并为内部 CLI 调用返回稳定的项目目录。 + """ + + @staticmethod + def is_backend_project_dir(project_dir: Path) -> bool: + """ + 判断给定目录是否为后端项目根目录。 + + :param project_dir: 待检查目录 + :return: 是否为后端项目根目录 + """ + return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir) + + def resolve_project_dir(self) -> Path: + """ + 解析内部 CLI 子进程应使用的后端项目目录。 + + 优先使用当前工作目录;若当前目录不是后端目录,则回退到当前 + `cli/utils.py` 所在代码树的上一层目录。 + + :return: 后端项目根目录 + """ + current_dir = Path.cwd().resolve() + if self.is_backend_project_dir(current_dir): + return current_dir + return Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True) +class NestedCliEnvironmentBuilder: + """ + 内部 CLI 环境构建器。 + + 该对象负责构建子进程环境变量以及内部 CLI 调用命令。 + """ + + @staticmethod + def build_process_env(project_dir: Path) -> dict[str, str]: + """ + 构建内部 CLI 子进程环境变量。 + + :param project_dir: 后端项目根目录 + :return: 子进程环境变量字典 + """ + process_env = dict(os.environ) + project_dir_str = str(project_dir) + python_path = process_env.get('PYTHONPATH', '').strip() + if python_path: + process_env['PYTHONPATH'] = os.pathsep.join([project_dir_str, python_path]) + else: + process_env['PYTHONPATH'] = project_dir_str + return process_env + + @staticmethod + def build_nested_cli_command(*arguments: str) -> list[str]: + """ + 构建内部 CLI 调用命令。 + + :param arguments: CLI 参数列表 + :return: 完整子进程命令参数列表 + """ + return [sys.executable, '-m', 'cli.main', '--color=never', '--icon=none', *arguments] + + +@dataclass(frozen=True) +class NestedCliPayloadParser: + """ + 内部 CLI 负载解析器。 + + 该对象负责从标准输出/错误中提取 JSON,并为非 JSON 结果构建兜底负载。 + """ + + @staticmethod + def extract_json_payload(output_text: str) -> dict[str, Any] | list[Any] | None: + """ + 从原始输出文本中提取 JSON 负载。 + + 该逻辑优先尝试直接解析;若输出前后混入提示文本、ANSI 控制序列 + 或其他噪声,则尝试截取最外层 JSON 对象或数组再解析。 + + :param output_text: 原始输出文本 + :return: 解析成功的 JSON 负载,失败时返回 None + """ + normalized_output = _ANSI_ESCAPE_PATTERN.sub('', output_text).strip() + if not normalized_output: + return None + + decoder = json.JSONDecoder() + try: + return decoder.decode(normalized_output) + except Exception: + pass + + for index, char in enumerate(normalized_output): + if char not in '{[': + continue + try: + payload, _ = decoder.raw_decode(normalized_output[index:]) + return payload + except Exception: + continue + return None + + @staticmethod + def build_non_json_payload_fallback( + *, + stdout: str, + stderr: str, + returncode: int, + ) -> dict[str, Any]: + """ + 为未返回 JSON 的内部 CLI 输出构建兜底负载。 + + :param stdout: 标准输出文本 + :param stderr: 标准错误文本 + :param returncode: 进程退出码 + :return: 兜底结果负载 + """ + stdout_text = stdout.strip() + stderr_text = stderr.strip() + summary = stdout_text or stderr_text or '内部 CLI 未返回可解析的 JSON 输出' + return { + 'ok': returncode == 0, + 'message': summary, + 'stdout': stdout_text, + 'stderr': stderr_text, + 'exit_code': returncode or RUNTIME_ERROR, + 'fallback': 'non_json_output', + } + + +@dataclass +class NestedCliSupport: + """ + 内部 CLI 子进程支持服务。 + + 该对象统一负责编排项目目录解析、环境注入、标准输出解析以及 + 实时执行/进程替换执行路径。 + + :param project_locator: 项目目录定位器 + :param environment_builder: 子进程环境构建器 + :param payload_parser: 负载解析器 + """ + + project_locator: NestedCliProjectLocator = field(default_factory=NestedCliProjectLocator) + environment_builder: NestedCliEnvironmentBuilder = field(default_factory=NestedCliEnvironmentBuilder) + payload_parser: NestedCliPayloadParser = field(default_factory=NestedCliPayloadParser) + + def resolve_project_dir(self) -> Path: + """ + 解析内部 CLI 子进程应使用的后端项目目录。 + + :return: 后端项目根目录 + """ + return self.project_locator.resolve_project_dir() + + def build_process_env(self, project_dir: Path) -> dict[str, str]: + """ + 构建内部 CLI 子进程环境变量。 + + :param project_dir: 后端项目根目录 + :return: 子进程环境变量字典 + """ + return self.environment_builder.build_process_env(project_dir) + + def build_nested_cli_command(self, *arguments: str) -> list[str]: + """ + 构建内部 CLI 调用命令。 + + :param arguments: CLI 参数列表 + :return: 完整子进程命令参数列表 + """ + return self.environment_builder.build_nested_cli_command(*arguments) + + def extract_json_payload(self, output_text: str) -> dict[str, Any] | list[Any] | None: + """ + 从原始输出文本中提取 JSON 负载。 + + :param output_text: 原始输出文本 + :return: 解析成功的 JSON 负载,失败时返回 None + """ + return self.payload_parser.extract_json_payload(output_text) + + def build_non_json_payload_fallback( + self, + *, + stdout: str, + stderr: str, + returncode: int, + ) -> dict[str, Any]: + """ + 为未返回 JSON 的内部 CLI 输出构建兜底负载。 + + :param stdout: 标准输出文本 + :param stderr: 标准错误文本 + :param returncode: 进程退出码 + :return: 兜底结果负载 + """ + return self.payload_parser.build_non_json_payload_fallback( + stdout=stdout, + stderr=stderr, + returncode=returncode, + ) + + def run(self, *arguments: str, parse_json: bool = False) -> NestedCliResult: + """ + 在当前后端目录中执行内部 CLI 命令。 + + :param arguments: CLI 参数列表 + :param parse_json: 是否尝试解析标准输出中的 JSON 负载 + :return: 内部 CLI 调用结果 + """ + project_dir = self.resolve_project_dir() + command = self.build_nested_cli_command(*arguments) + completed = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + cwd=str(project_dir), + env=self.build_process_env(project_dir), + ) + payload: dict[str, Any] | list[Any] | None = None + if parse_json: + payload = self.extract_json_payload(completed.stdout) + if payload is None: + payload = self.extract_json_payload(completed.stderr) + if payload is None: + payload = self.build_non_json_payload_fallback( + stdout=completed.stdout, + stderr=completed.stderr, + returncode=completed.returncode, + ) + return NestedCliResult( + command=command, + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + payload=payload, + ) + + def run_live(self, *arguments: str) -> subprocess.CompletedProcess[str]: + """ + 在当前终端中直接执行内部 CLI 命令。 + + 该模式不会捕获标准输出/错误,适用于需要占用当前终端交互的 wizard + 或其他外部命令入口;调用方应自行决定是否先挂起 TUI。 + + :param arguments: CLI 参数列表 + :return: 子进程执行结果 + """ + project_dir = self.resolve_project_dir() + command = self.build_nested_cli_command(*arguments) + return subprocess.run( + command, + text=True, + check=False, + cwd=str(project_dir), + env=self.build_process_env(project_dir), + ) + + def exec(self, *arguments: str) -> None: + """ + 以进程替换方式执行内部 CLI 命令。 + + :param arguments: CLI 参数列表 + :return: None + """ + project_dir = self.resolve_project_dir() + command = self.build_nested_cli_command(*arguments) + executable = command[0] + subprocess_arguments = [executable, *command[1:]] + os.chdir(project_dir) + os.execvp(executable, subprocess_arguments) + + +SHELL_TEXT_FORMATTER = ShellTextFormatter() +NESTED_CLI_SUPPORT = NestedCliSupport() diff --git a/shierfeng-fastapi-backend/cli/wizard/__init__.py b/shierfeng-fastapi-backend/cli/wizard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-backend/cli/wizard/aggregators.py b/shierfeng-fastapi-backend/cli/wizard/aggregators.py new file mode 100644 index 0000000..ade4639 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/aggregators.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ProdCheckPayloadSupport: + """ + `wizard prod-check` 结果提取支持对象。 + + 该对象负责从多路只读结果中提取 runtime、doctor、config + 片段,避免聚合器本体继续堆叠细碎字段判定逻辑。 + """ + + @staticmethod + def extract_runtime_payload(runtime_payload: dict[str, Any] | None) -> dict[str, Any] | None: + """ + 提取运行环境结果中的 runtime 片段。 + + :param runtime_payload: 运行环境结果 + :return: runtime 片段 + """ + return runtime_payload.get('runtime') if isinstance(runtime_payload, dict) else None + + @staticmethod + def extract_doctor_payload(doctor_payload: dict[str, Any] | None) -> dict[str, Any] | None: + """ + 提取应用检查结果片段。 + + :param doctor_payload: 应用检查结果 + :return: doctor 片段 + """ + return doctor_payload if isinstance(doctor_payload, dict) else None + + @staticmethod + def extract_config_payload(config_payload: dict[str, Any] | None) -> dict[str, Any] | None: + """ + 提取配置快照结果中的 config 片段。 + + :param config_payload: 配置快照结果 + :return: config 片段 + """ + return config_payload.get('config') if isinstance(config_payload, dict) else None + + +@dataclass(frozen=True) +class ProdCheckStatusEvaluator: + """ + `wizard prod-check` 状态评估器。 + + 该对象负责统一计算聚合结果的成功状态和标准消息。 + """ + + @staticmethod + def is_ok( + runtime_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + ) -> bool: + """ + 计算生产巡检聚合结果是否成功。 + + :param runtime_payload: 运行环境结果 + :param doctor_payload: 应用检查结果 + :param config_payload: 配置快照结果 + :return: 是否成功 + """ + return ( + bool(runtime_payload and runtime_payload.get('ok', False)) + and bool(doctor_payload and doctor_payload.get('ok', False)) + and (config_payload is None or bool(config_payload.get('ok', False))) + ) + + @staticmethod + def build_message() -> str: + """ + 构建生产巡检统一结果消息。 + + :return: 结果消息 + """ + return '生产巡检完成' + + +@dataclass(frozen=True) +class ProdCheckAggregator: + """ + `wizard prod-check` 聚合器。 + + 该对象负责聚合运行环境、应用检查和配置快照结果, + 让 flow 本体只保留交互编排职责。 + + :param payload_support: 结果提取支持对象 + :param status_evaluator: 状态评估器 + """ + + payload_support: ProdCheckPayloadSupport = ProdCheckPayloadSupport() + status_evaluator: ProdCheckStatusEvaluator = ProdCheckStatusEvaluator() + + def build_payload( + self, + *, + env: str, + runtime_payload: dict[str, Any] | None, + doctor_payload: dict[str, Any] | None, + config_payload: dict[str, Any] | None, + ) -> dict[str, Any]: + """ + 构建生产巡检聚合结果。 + + :param env: 当前运行环境 + :param runtime_payload: 运行环境结果 + :param doctor_payload: 应用检查结果 + :param config_payload: 配置快照结果 + :return: 聚合结果负载 + """ + return { + 'ok': self.status_evaluator.is_ok(runtime_payload, doctor_payload, config_payload), + 'env': env, + 'message': self.status_evaluator.build_message(), + 'runtime': self.payload_support.extract_runtime_payload(runtime_payload), + 'doctor': self.payload_support.extract_doctor_payload(doctor_payload), + 'config': self.payload_support.extract_config_payload(config_payload), + } diff --git a/shierfeng-fastapi-backend/cli/wizard/base.py b/shierfeng-fastapi-backend/cli/wizard/base.py new file mode 100644 index 0000000..b699087 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/base.py @@ -0,0 +1,909 @@ +import inspect +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Any, ClassVar, Generic, TypeVar + +import typer + +from cli.context import CliContext, OutputOption +from cli.core import DEFAULT_CORE_SERVICES, CliContextFactory, CliExecutionService +from cli.output import CommandResult +from cli.utils import NESTED_CLI_SUPPORT +from cli.wizard.preview import WIZARD_PREVIEW_RENDERER, WizardPreviewRenderer +from cli.wizard.prompts import WIZARD_PROMPT_SERVICE, WizardPromptService + +SelectionT = TypeVar('SelectionT') + + +class WizardInteractionSupport: + """ + 向导交互与上下文构建支持对象。 + + :param context_factory: CLI 上下文工厂 + :param prompt_service: 向导提示服务 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory, + prompt_service: WizardPromptService, + ) -> None: + """ + 初始化交互支持对象。 + + :param context_factory: CLI 上下文工厂 + :param prompt_service: 向导提示服务 + :return: None + """ + self.context_factory = context_factory + self.prompt_service = prompt_service + + def build_regular_context( + self, + env: str, + output: OutputOption, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> CliContext: + """ + 构建普通可写命令上下文。 + + :param env: 运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境执行 + :param yes: 是否跳过确认 + :param dry_run: 是否执行预演 + :return: CLI 上下文 + """ + return self.context_factory.build_regular(env, output, allow_prod, yes, dry_run) + + def build_readonly_context(self, env: str, output: OutputOption) -> CliContext: + """ + 构建只读命令上下文。 + + :param env: 运行环境 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.context_factory.build_readonly(env, output) + + def prompt_confirm(self, prompt_text: str, *, default_value: bool) -> bool: + """ + 执行标准确认交互。 + + :param prompt_text: 提示文本 + :param default_value: 默认值 + :return: 是否确认 + """ + return self.prompt_service.prompt_confirm(prompt_text, default_value=default_value) + + +class WizardResultSupport: + """ + 向导结果收口支持对象。 + + :param execution_service: CLI 执行服务 + :param preview_renderer: 向导预览渲染器 + """ + + def __init__( + self, + *, + execution_service: CliExecutionService, + preview_renderer: WizardPreviewRenderer, + ) -> None: + """ + 初始化结果收口支持对象。 + + :param execution_service: CLI 执行服务 + :param preview_renderer: 向导预览渲染器 + :return: None + """ + self.execution_service = execution_service + self.preview_renderer = preview_renderer + + def complete_result(self, ctx: CliContext, result: CommandResult) -> None: + """ + 输出命令结果对象。 + + :param ctx: CLI 上下文 + :param result: 命令结果对象 + :return: None + """ + self.execution_service.complete_result(ctx, result) + + def complete_payload( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + default_exit_code: int = 0, + ) -> None: + """ + 输出标准结果负载。 + + :param ctx: CLI 上下文 + :param payload: 标准结果负载 + :param default_exit_code: 默认退出码 + :return: None + """ + self.execution_service.complete_payload(ctx, payload, default_exit_code=default_exit_code) + + def complete_payload_result( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + text_builder: Callable[[dict[str, Any]], Any] | None = None, + default_exit_code: int = 0, + text_condition: Callable[[dict[str, Any]], bool] | None = None, + ) -> None: + """ + 统一按输出格式收口标准结果负载。 + + :param ctx: CLI 上下文 + :param payload: 标准结果负载 + :param text_builder: 文本结果构建函数 + :param default_exit_code: 默认退出码 + :param text_condition: 文本模式结果构建判定函数 + :return: None + """ + self.execution_service.complete_payload_result( + ctx, + payload, + text_builder=text_builder, + default_exit_code=default_exit_code, + text_condition=text_condition, + ) + + def build_cancel_result(self, wizard_name: str) -> CommandResult: + """ + 构建统一的向导取消结果。 + + :param wizard_name: 向导名称 + :return: 命令结果对象 + """ + return self.preview_renderer.build_cancel_result(wizard_name) + + def get_preview_renderer(self) -> WizardPreviewRenderer: + """ + 返回当前项目默认的向导预览渲染器。 + + :return: 预览渲染器 + """ + return self.preview_renderer + + +class WizardNestedCommandSupport: + """ + 向导内部 CLI 调用支持对象。 + + :param command_runner: 内部 CLI 子进程调用函数 + :param live_command_runner: 内部 CLI 实时执行函数 + """ + + def __init__( + self, + *, + command_runner: Callable[..., Any], + live_command_runner: Callable[..., Any], + ) -> None: + """ + 初始化内部 CLI 调用支持对象。 + + :param command_runner: 内部 CLI 子进程调用函数 + :param live_command_runner: 内部 CLI 实时执行函数 + :return: None + """ + self.command_runner = command_runner + self.live_command_runner = live_command_runner + + @staticmethod + def resolve_callable_override(owner: type['BaseCliWizardFlow[Any]'], attribute_name: str) -> Callable[..., Any]: + """ + 解析类级可调用注入点,避免普通函数在实例访问时被绑定为方法。 + + :param owner: 向导类 + :param attribute_name: 类属性名称 + :return: 可直接调用的函数对象 + """ + attribute = inspect.getattr_static(owner, attribute_name) + if isinstance(attribute, staticmethod): + return attribute.__func__ + return attribute + + def run_nested_command(self, *arguments: str, parse_json: bool = False) -> Any: + """ + 调用内部 CLI 并按需解析 JSON。 + + :param arguments: CLI 参数列表 + :param parse_json: 是否解析 JSON + :return: 内部 CLI 调用结果 + """ + return self.command_runner(*arguments, parse_json=parse_json) + + def exec_nested_command(self, *arguments: str) -> Any: + """ + 直接切换到内部 CLI 的实时执行路径。 + + :param arguments: CLI 参数列表 + :return: 内部 CLI 调用结果 + """ + return self.live_command_runner(*arguments) + + +class WizardExecutionResultSupport: + """ + 向导执行结果翻译支持对象。 + + 该对象负责提取内部 CLI 结果的 payload、退出码与错误文本,并构建 + 默认失败负载,让模板基类不再直接持有结果翻译细节。 + + :param failure_message: 默认失败提示 + """ + + def __init__(self, *, failure_message: str) -> None: + """ + 初始化执行结果翻译支持对象。 + + :param failure_message: 默认失败提示 + :return: None + """ + self.failure_message = failure_message + + @staticmethod + def extract_payload(nested_result: Any) -> dict[str, Any] | None: + """ + 提取内部 CLI JSON 负载。 + + :param nested_result: 内部 CLI 调用结果 + :return: JSON 负载 + """ + payload = getattr(nested_result, 'payload', None) + return payload if isinstance(payload, dict) else None + + @staticmethod + def extract_returncode(nested_result: Any) -> int: + """ + 提取内部 CLI 退出码。 + + :param nested_result: 内部 CLI 调用结果 + :return: 退出码 + """ + return int(getattr(nested_result, 'returncode', 0) or 0) + + @staticmethod + def extract_stdout(nested_result: Any) -> str: + """ + 提取内部 CLI 标准输出。 + + :param nested_result: 内部 CLI 调用结果 + :return: 标准输出文本 + """ + return str(getattr(nested_result, 'stdout', '') or '').strip() + + @staticmethod + def extract_stderr(nested_result: Any) -> str: + """ + 提取内部 CLI 标准错误。 + + :param nested_result: 内部 CLI 调用结果 + :return: 标准错误文本 + """ + return str(getattr(nested_result, 'stderr', '') or '').strip() + + def build_failure_payload(self, nested_result: Any) -> dict[str, Any]: + """ + 为内部 CLI 非 JSON 结果构建失败负载。 + + :param nested_result: 内部 CLI 调用结果 + :return: 失败负载 + """ + error_text = self.extract_stderr(nested_result) or self.extract_stdout(nested_result) + return { + 'ok': False, + 'message': self.failure_message, + 'error': error_text, + 'exit_code': self.extract_returncode(nested_result), + } + + +class WizardSupportCache: + """ + 向导协作者缓存。 + + 该对象负责延迟构建并复用当前 flow 需要的 interaction/result/nested + support collaborator,让 `BaseCliWizardFlow` 回到模板骨架与桥接层角色。 + + :param owner: 当前向导 flow + """ + + def __init__(self, owner: 'BaseCliWizardFlow[Any]') -> None: + """ + 初始化向导协作者缓存。 + + :param owner: 当前向导 flow + :return: None + """ + self.owner = owner + self._interaction_support: WizardInteractionSupport | None = None + self._result_support: WizardResultSupport | None = None + self._nested_command_support: WizardNestedCommandSupport | None = None + self._execution_result_support: WizardExecutionResultSupport | None = None + + def get_interaction_support(self) -> WizardInteractionSupport: + """ + 返回当前向导使用的交互支持对象。 + + :return: 交互支持对象 + """ + if self._interaction_support is None: + self._interaction_support = WizardInteractionSupport( + context_factory=self.owner.context_factory, + prompt_service=self.owner.prompt_service, + ) + return self._interaction_support + + def get_result_support(self) -> WizardResultSupport: + """ + 返回当前向导使用的结果收口支持对象。 + + :return: 结果收口支持对象 + """ + if self._result_support is None: + self._result_support = WizardResultSupport( + execution_service=self.owner.execution_service, + preview_renderer=self.owner.preview_renderer, + ) + return self._result_support + + def get_nested_command_support(self) -> WizardNestedCommandSupport: + """ + 返回当前向导使用的内部 CLI 调用支持对象。 + + :return: 内部 CLI 调用支持对象 + """ + if self._nested_command_support is None: + self._nested_command_support = WizardNestedCommandSupport( + command_runner=WizardNestedCommandSupport.resolve_callable_override( + type(self.owner), 'nested_command_runner' + ), + live_command_runner=WizardNestedCommandSupport.resolve_callable_override( + type(self.owner), + 'nested_live_command_runner', + ), + ) + return self._nested_command_support + + def get_execution_result_support(self) -> WizardExecutionResultSupport: + """ + 返回当前向导使用的执行结果翻译支持对象。 + + :return: 执行结果翻译支持对象 + """ + if self._execution_result_support is None: + self._execution_result_support = WizardExecutionResultSupport( + failure_message=self.owner.failure_message, + ) + return self._execution_result_support + + +class BaseNestedCommandWizardFlow(ABC, Generic[SelectionT]): + """ + 统一封装通过内部 CLI 执行的向导流程。 + + 子类需要提供参数采集、上下文构建、预览摘要、执行参数和 + 结果收口策略,从而复用向导的通用交互主链路。 + """ + + wizard_name: str + preview_title: str + failure_message: str + + def run(self, output: OutputOption = 'text') -> None: + """ + 执行向导主流程。 + + :param output: 输出格式 + :return: None + """ + selection = self.collect_selection() + self.prepare_execution_state(selection, output) + ctx = self.prepare_context(selection, output) + typer.echo(self.render_preview(selection)) + if not self.prompt_confirm(self.confirm_prompt(selection), default_value=self.confirm_default_value(selection)): + self.complete_result(ctx, self.build_cancel_result()) + return + + nested_result = self.execute(selection, output) + if self.handle_execution_result(ctx, nested_result, output): + return + payload = self.extract_payload(nested_result) + if payload is not None: + self.complete_payload(ctx, payload, default_exit_code=self.extract_returncode(nested_result)) + return + self.complete_payload(ctx, self.build_failure_payload(nested_result)) + + def render_preview(self, selection: SelectionT) -> str: + """ + 生成向导预览文本。 + + :param selection: 向导采集结果 + :return: 预览文本 + """ + return self.get_preview_renderer().render_preview( + self.preview_title, + summary=self.build_preview_summary(selection), + command=self.build_preview_command(selection), + notes=self.build_preview_notes(selection), + ) + + def confirm_default_value(self, selection: SelectionT) -> bool: + """ + 返回最终确认默认值。 + + :param selection: 向导采集结果 + :return: 默认确认值 + """ + del selection + return False + + def build_preview_notes(self, selection: SelectionT) -> list[str] | None: + """ + 构建预览附加说明。 + + :param selection: 向导采集结果 + :return: 预览附加说明 + """ + del selection + return None + + def prepare_execution_state(self, selection: SelectionT, output: OutputOption) -> None: + """ + 在渲染预览前准备 flow 级执行状态。 + + 该钩子用于少量需要在预览、确认默认值或执行前共享状态的 flow, + 例如预先探测只读诊断结果,但不允许子类因此重写整个模板主链路。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: None + """ + del selection, output + + def handle_execution_result(self, ctx: CliContext, nested_result: Any, output: OutputOption) -> bool: + """ + 允许子类在默认 payload 收口前接管执行结果。 + + 该钩子适用于需要切换到 live exec、实时终端占用或其他非标准 payload + 收口路径的少数 flow。返回 `True` 表示结果已处理完成。 + + :param ctx: CLI 上下文 + :param nested_result: 执行结果 + :param output: 输出格式 + :return: 是否已完成结果处理 + """ + del ctx, nested_result, output + return False + + def build_failure_payload(self, nested_result: Any) -> dict[str, Any]: + """ + 为内部 CLI 非 JSON 结果构建失败负载。 + + :param nested_result: 内部 CLI 调用结果 + :return: 失败负载 + """ + return self.get_execution_result_support().build_failure_payload(nested_result) + + @staticmethod + def extract_payload(nested_result: Any) -> dict[str, Any] | None: + """ + 提取内部 CLI JSON 负载。 + + :param nested_result: 内部 CLI 调用结果 + :return: JSON 负载 + """ + return WizardExecutionResultSupport.extract_payload(nested_result) + + @staticmethod + def extract_returncode(nested_result: Any) -> int: + """ + 提取内部 CLI 退出码。 + + :param nested_result: 内部 CLI 调用结果 + :return: 退出码 + """ + return WizardExecutionResultSupport.extract_returncode(nested_result) + + @staticmethod + def extract_stdout(nested_result: Any) -> str: + """ + 提取内部 CLI 标准输出。 + + :param nested_result: 内部 CLI 调用结果 + :return: 标准输出文本 + """ + return WizardExecutionResultSupport.extract_stdout(nested_result) + + @staticmethod + def extract_stderr(nested_result: Any) -> str: + """ + 提取内部 CLI 标准错误。 + + :param nested_result: 内部 CLI 调用结果 + :return: 标准错误文本 + """ + return WizardExecutionResultSupport.extract_stderr(nested_result) + + @abstractmethod + def collect_selection(self) -> SelectionT: + """ + 采集向导参数。 + + :return: 向导采集结果 + """ + + @abstractmethod + def prepare_context(self, selection: SelectionT, output: OutputOption) -> CliContext: + """ + 构建命令上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + + @abstractmethod + def build_preview_summary(self, selection: SelectionT) -> dict[str, Any]: + """ + 构建预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + + @abstractmethod + def build_preview_command(self, selection: SelectionT) -> list[str]: + """ + 构建用户视角命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + + @abstractmethod + def confirm_prompt(self, selection: SelectionT) -> str: + """ + 返回最终确认提示文本。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + + @abstractmethod + def build_execute_arguments(self, selection: SelectionT, output: OutputOption) -> list[str]: + """ + 构建内部 CLI 执行参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + + @abstractmethod + def prompt_confirm(self, prompt_text: str, *, default_value: bool) -> bool: + """ + 执行确认交互。 + + :param prompt_text: 提示文本 + :param default_value: 默认值 + :return: 是否确认 + """ + + @abstractmethod + def complete_result(self, ctx: CliContext, result: CommandResult) -> None: + """ + 输出命令结果对象。 + + :param ctx: CLI 上下文 + :param result: 命令结果 + :return: None + """ + + @abstractmethod + def build_cancel_result(self) -> CommandResult: + """ + 构建向导取消结果。 + + :return: 命令结果对象 + """ + + @abstractmethod + def get_preview_renderer(self) -> WizardPreviewRenderer: + """ + 获取当前向导使用的预览渲染器。 + + :return: 预览渲染器 + """ + + @abstractmethod + def complete_payload( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + default_exit_code: int = 0, + ) -> None: + """ + 输出标准负载。 + + :param ctx: CLI 上下文 + :param payload: 结果负载 + :param default_exit_code: 默认退出码 + :return: None + """ + + def execute(self, selection: SelectionT, output: OutputOption) -> Any: + """ + 执行内部 CLI 命令。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 调用结果 + """ + return self.run_nested_command(*self.build_execute_arguments(selection, output), parse_json=True) + + @abstractmethod + def run_nested_command(self, *arguments: str, parse_json: bool = False) -> Any: + """ + 调用内部 CLI。 + + :param arguments: 参数列表 + :param parse_json: 是否解析 JSON + :return: 内部 CLI 调用结果 + """ + + +class BaseCliWizardFlow(BaseNestedCommandWizardFlow[SelectionT], ABC): + """ + 当前项目使用的 CLI 向导基类。 + + 该基类在通用向导主链路之上继续下沉当前项目稳定复用的桥接逻辑, + 包括确认交互、上下文构建、命令结果收口与内部 CLI 调用封装。 + """ + + context_factory: ClassVar[CliContextFactory] = DEFAULT_CORE_SERVICES.context_factory + execution_service: ClassVar[CliExecutionService] = DEFAULT_CORE_SERVICES.execution_service + prompt_service: ClassVar[WizardPromptService] = WIZARD_PROMPT_SERVICE + preview_renderer: ClassVar[WizardPreviewRenderer] = WIZARD_PREVIEW_RENDERER + nested_command_runner: ClassVar[Callable[..., Any]] = staticmethod(NESTED_CLI_SUPPORT.run) + nested_live_command_runner: ClassVar[Callable[..., Any]] = staticmethod(NESTED_CLI_SUPPORT.exec) + + def __init__(self) -> None: + """ + 初始化当前项目向导基类。 + + 该基类通过协作者缓存稳定持有交互、结果收口和内部 CLI 调用 support, + 避免模板方法执行过程中重复构建同类对象。 + + :return: None + """ + self._support_cache: WizardSupportCache | None = None + + def get_support_cache(self) -> WizardSupportCache: + """ + 返回当前向导使用的协作者缓存。 + + 保持惰性创建,以兼容未显式调用基类 `__init__` 的现有 flow。 + + :return: 协作者缓存 + """ + support_cache = getattr(self, '_support_cache', None) + if support_cache is None: + self._support_cache = WizardSupportCache(self) + return self._support_cache + + def get_interaction_support(self) -> WizardInteractionSupport: + """ + 返回当前向导使用的交互支持对象。 + + :return: 交互支持对象 + """ + return self.get_support_cache().get_interaction_support() + + def get_result_support(self) -> WizardResultSupport: + """ + 返回当前向导使用的结果收口支持对象。 + + :return: 结果收口支持对象 + """ + return self.get_support_cache().get_result_support() + + def get_nested_command_support(self) -> WizardNestedCommandSupport: + """ + 返回当前向导使用的内部 CLI 调用支持对象。 + + :return: 内部 CLI 调用支持对象 + """ + return self.get_support_cache().get_nested_command_support() + + def get_execution_result_support(self) -> WizardExecutionResultSupport: + """ + 返回当前向导使用的执行结果翻译支持对象。 + + :return: 执行结果翻译支持对象 + """ + return self.get_support_cache().get_execution_result_support() + + def build_regular_context( + self, + env: str, + output: OutputOption, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> CliContext: + """ + 构建普通可写命令上下文。 + + :param env: 运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境执行 + :param yes: 是否跳过确认 + :param dry_run: 是否执行预演 + :return: CLI 上下文 + """ + return self.get_interaction_support().build_regular_context( + env, + output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + def build_readonly_context(self, env: str, output: OutputOption) -> CliContext: + """ + 构建只读命令上下文。 + + :param env: 运行环境 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.get_interaction_support().build_readonly_context(env, output) + + def prompt_confirm(self, prompt_text: str, *, default_value: bool) -> bool: + """ + 执行标准确认交互。 + + :param prompt_text: 提示文本 + :param default_value: 默认值 + :return: 是否确认 + """ + return self.get_interaction_support().prompt_confirm(prompt_text, default_value=default_value) + + def complete_result(self, ctx: CliContext, result: CommandResult) -> None: + """ + 输出命令结果对象。 + + :param ctx: CLI 上下文 + :param result: 命令结果对象 + :return: None + """ + self.get_result_support().complete_result(ctx, result) + + def complete_payload( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + default_exit_code: int = 0, + ) -> None: + """ + 输出标准结果负载。 + + :param ctx: CLI 上下文 + :param payload: 标准结果负载 + :param default_exit_code: 默认退出码 + :return: None + """ + self.get_result_support().complete_payload(ctx, payload, default_exit_code=default_exit_code) + + def complete_payload_result( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + text_builder: Callable[[dict[str, Any]], Any] | None = None, + default_exit_code: int = 0, + text_condition: Callable[[dict[str, Any]], bool] | None = None, + ) -> None: + """ + 统一按输出格式收口标准结果负载。 + + :param ctx: CLI 上下文 + :param payload: 标准结果负载 + :param text_builder: 文本结果构建函数 + :param default_exit_code: 默认退出码 + :param text_condition: 文本模式结果构建判定函数 + :return: None + """ + self.get_result_support().complete_payload_result( + ctx, + payload, + text_builder=text_builder, + default_exit_code=default_exit_code, + text_condition=text_condition, + ) + + def build_cancel_result(self) -> CommandResult: + """ + 构建统一的向导取消结果。 + + :return: 命令结果对象 + """ + return self.get_result_support().build_cancel_result(self.wizard_name) + + def get_preview_renderer(self) -> WizardPreviewRenderer: + """ + 返回当前项目默认的向导预览渲染器。 + + :return: 预览渲染器 + """ + return self.get_result_support().get_preview_renderer() + + def run_nested_command(self, *arguments: str, parse_json: bool = False) -> Any: + """ + 调用内部 CLI 并按需解析 JSON。 + + :param arguments: CLI 参数列表 + :param parse_json: 是否解析 JSON + :return: 内部 CLI 调用结果 + """ + return self.get_nested_command_support().run_nested_command(*arguments, parse_json=parse_json) + + def exec_nested_command(self, *arguments: str) -> Any: + """ + 直接切换到内部 CLI 的实时执行路径。 + + :param arguments: CLI 参数列表 + :return: 内部 CLI 调用结果 + """ + return self.get_nested_command_support().exec_nested_command(*arguments) + + +class BaseLiveExecCliWizardFlow(BaseCliWizardFlow[SelectionT], ABC): + """ + 统一封装“确认后切换到实时终端执行路径”的 CLI 向导基类。 + + 该特化基类用于少量不走 JSON payload 收口,而是在确认后直接 + 切换到现有命令实时执行链路的向导场景,例如 `wizard app-run`。 + 它在保留预览与确认模板骨架的前提下,显式表达“执行阶段即完成收口”, + 避免子类再通过覆写 `complete_payload()` 或 `handle_execution_result()` + 实现规避式短路。 + """ + + def execute(self, selection: SelectionT, output: OutputOption) -> None: + """ + 切换到内部 CLI 的实时执行路径。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: None + """ + self.exec_nested_command(*self.build_execute_arguments(selection, output)) + + def handle_execution_result(self, ctx: CliContext, nested_result: Any, output: OutputOption) -> bool: + """ + 标记实时执行路径已完成结果收口。 + + :param ctx: CLI 上下文 + :param nested_result: 执行结果 + :param output: 输出格式 + :return: 是否已完成结果处理 + """ + del ctx, nested_result, output + return True diff --git a/shierfeng-fastapi-backend/cli/wizard/commands.py b/shierfeng-fastapi-backend/cli/wizard/commands.py new file mode 100644 index 0000000..18067ac --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/commands.py @@ -0,0 +1,316 @@ +from collections.abc import Callable +from dataclasses import dataclass, field +from importlib import import_module + +import typer + +from cli.context import OutputOption + +WizardCommandRegistrar = Callable[[typer.Typer, 'WizardCommandRegistration'], None] + + +@dataclass(frozen=True) +class WizardCommandRegistration: + """ + wizard 子命令注册元数据。 + + :param name: 子命令名称 + :param help_text: 子命令帮助文案 + :param registrar: 对应的注册器函数 + """ + + name: str + help_text: str + + +class WizardFlowLoader: + """ + wizard flow 惰性加载器。 + + 该对象负责在命令真正执行时导入对应 flow, + 避免 `wizard` 入口在模块导入阶段提前拉起全部向导依赖。 + """ + + def load_runner(self, module_name: str, function_name: str) -> Callable[..., None]: + """ + 按模块名和函数名加载向导执行入口。 + + :param module_name: flow 模块名 + :param function_name: flow 执行函数名 + :return: 可调用的向导执行函数 + """ + flow_module = import_module(module_name) + return getattr(flow_module, function_name) + + +@dataclass +class WizardCommandBuilder: + """ + wizard 子命令构建器。 + + :param flow_loader: wizard flow 惰性加载器 + """ + + flow_loader: WizardFlowLoader = field(default_factory=WizardFlowLoader) + + def build(self) -> typer.Typer: + """ + 构建 wizard 命令组。 + + :return: wizard 子应用 + """ + app = typer.Typer( + help='交互式向导命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, + ) + self.register_commands(app) + return app + + def register_commands(self, app: typer.Typer) -> None: + """ + 向 Typer 子应用注册全部 wizard 子命令。 + + :param app: wizard 子应用 + :return: None + """ + for registration, registrar in self.iter_command_registrars(): + registrar(app, registration) + + def iter_command_registrars(self) -> tuple[tuple[WizardCommandRegistration, WizardCommandRegistrar], ...]: + """ + 返回向导命令注册元数据与显式注册器映射。 + + 该方法将命令声明与注册函数绑定关系显式化,避免继续依赖 + `registrar_name -> getattr(self, ...)` 的字符串分发。 + + :return: 注册元数据与注册器映射列表 + """ + return ( + ( + WizardCommandRegistration('app-run', '通过交互方式启动应用'), + self._register_app_run_command, + ), + ( + WizardCommandRegistration('db-upgrade', '通过交互方式执行数据库升级'), + self._register_db_upgrade_command, + ), + ( + WizardCommandRegistration('cache-clear', '通过交互方式执行缓存清理'), + self._register_cache_clear_command, + ), + ( + WizardCommandRegistration('gen-export', '通过交互方式执行代码导出'), + self._register_gen_export_command, + ), + ( + WizardCommandRegistration('gen-import', '通过交互方式执行物理表导入'), + self._register_gen_import_command, + ), + ( + WizardCommandRegistration('prod-check', '通过交互方式执行生产巡检'), + self._register_prod_check_command, + ), + ) + + def _register_app_run_command(self, app: typer.Typer, registration: WizardCommandRegistration) -> None: + """ + 注册 `app-run` 子命令。 + + :param app: wizard 子应用 + :param registration: 子命令注册元数据 + :return: None + """ + + @app.command(registration.name, help=registration.help_text) + def app_run() -> None: + """ + 通过交互方式启动应用。 + + :return: None + """ + self.flow_loader.load_runner('cli.wizard.flows.app_run', 'run_app_run_wizard')() + + def _register_db_upgrade_command(self, app: typer.Typer, registration: WizardCommandRegistration) -> None: + """ + 注册 `db-upgrade` 子命令。 + + :param app: wizard 子应用 + :param registration: 子命令注册元数据 + :return: None + """ + + @app.command(registration.name, help=registration.help_text) + def db_upgrade( + output: OutputOption = 'text', + default_env: str = typer.Option('dev', '--default-env', help='向导默认环境'), + default_revision: str = typer.Option('head', '--default-revision', help='向导默认目标 revision'), + default_dry_run: bool = typer.Option( + True, '--default-dry-run/--no-default-dry-run', help='向导默认 dry-run 选项' + ), + ) -> None: + """ + 通过交互方式执行数据库升级。 + + :param output: 输出格式 + :param default_env: 向导默认环境 + :param default_revision: 向导默认目标 revision + :param default_dry_run: 向导默认 dry-run 选项 + :return: None + """ + self.flow_loader.load_runner('cli.wizard.flows.db_upgrade', 'run_db_upgrade_wizard')( + output, + default_env=default_env, + default_revision=default_revision, + default_dry_run=default_dry_run, + ) + + def _register_cache_clear_command(self, app: typer.Typer, registration: WizardCommandRegistration) -> None: + """ + 注册 `cache-clear` 子命令。 + + :param app: wizard 子应用 + :param registration: 子命令注册元数据 + :return: None + """ + + @app.command(registration.name, help=registration.help_text) + def cache_clear( + output: OutputOption = 'text', + default_env: str = typer.Option('dev', '--default-env', help='向导默认环境'), + default_mode: str = typer.Option('cache-name', '--default-mode', help='向导默认清理模式'), + default_cache_name: str = typer.Option('', '--default-cache-name', help='向导默认缓存名称前缀'), + default_cache_key: str = typer.Option('', '--default-cache-key', help='向导默认缓存键关键字'), + default_dry_run: bool = typer.Option( + True, '--default-dry-run/--no-default-dry-run', help='向导默认 dry-run 选项' + ), + ) -> None: + """ + 通过交互方式执行缓存清理。 + + :param output: 输出格式 + :param default_env: 向导默认环境 + :param default_mode: 向导默认清理模式 + :param default_cache_name: 向导默认缓存名称前缀 + :param default_cache_key: 向导默认缓存键关键字 + :param default_dry_run: 向导默认 dry-run 选项 + :return: None + """ + self.flow_loader.load_runner('cli.wizard.flows.cache_clear', 'run_cache_clear_wizard')( + output, + default_env=default_env, + default_mode=default_mode, + default_cache_name=default_cache_name, + default_cache_key=default_cache_key, + default_dry_run=default_dry_run, + ) + + def _register_gen_export_command(self, app: typer.Typer, registration: WizardCommandRegistration) -> None: + """ + 注册 `gen-export` 子命令。 + + :param app: wizard 子应用 + :param registration: 子命令注册元数据 + :return: None + """ + + @app.command(registration.name, help=registration.help_text) + def gen_export( + output: OutputOption = 'text', + default_env: str = typer.Option('dev', '--default-env', help='向导默认环境'), + default_table_names: str = typer.Option('', '--default-table-names', help='向导默认业务表名称列表'), + default_mode: str = typer.Option('zip', '--default-mode', help='向导默认导出模式'), + default_output_file: str = typer.Option('', '--default-output-file', help='向导默认导出目标文件路径'), + default_dry_run: bool = typer.Option( + True, '--default-dry-run/--no-default-dry-run', help='向导默认 dry-run 选项' + ), + ) -> None: + """ + 通过交互方式执行代码导出。 + + :param output: 输出格式 + :param default_env: 向导默认环境 + :param default_table_names: 向导默认业务表名称列表 + :param default_mode: 向导默认导出模式 + :param default_output_file: 向导默认导出目标文件路径 + :param default_dry_run: 向导默认 dry-run 选项 + :return: None + """ + self.flow_loader.load_runner('cli.wizard.flows.gen_export', 'run_gen_export_wizard')( + output, + default_env=default_env, + default_table_names=default_table_names, + default_mode=default_mode, + default_output_file=default_output_file, + default_dry_run=default_dry_run, + ) + + def _register_gen_import_command(self, app: typer.Typer, registration: WizardCommandRegistration) -> None: + """ + 注册 `gen-import` 子命令。 + + :param app: wizard 子应用 + :param registration: 子命令注册元数据 + :return: None + """ + + @app.command(registration.name, help=registration.help_text) + def gen_import( + output: OutputOption = 'text', + default_env: str = typer.Option('dev', '--default-env', help='向导默认环境'), + default_table_names: str = typer.Option('', '--default-table-names', help='向导默认物理表名称列表'), + default_dry_run: bool = typer.Option( + True, '--default-dry-run/--no-default-dry-run', help='向导默认 dry-run 选项' + ), + ) -> None: + """ + 通过交互方式执行物理表导入。 + + :param output: 输出格式 + :param default_env: 向导默认环境 + :param default_table_names: 向导默认物理表名称列表 + :param default_dry_run: 向导默认 dry-run 选项 + :return: None + """ + self.flow_loader.load_runner('cli.wizard.flows.gen_import', 'run_gen_import_wizard')( + output, + default_env=default_env, + default_table_names=default_table_names, + default_dry_run=default_dry_run, + ) + + def _register_prod_check_command(self, app: typer.Typer, registration: WizardCommandRegistration) -> None: + """ + 注册 `prod-check` 子命令。 + + :param app: wizard 子应用 + :param registration: 子命令注册元数据 + :return: None + """ + + @app.command(registration.name, help=registration.help_text) + def prod_check( + output: OutputOption = 'text', + default_env: str = typer.Option('prod', '--default-env', help='向导默认环境'), + default_include_config: bool = typer.Option( + True, + '--default-include-config/--no-default-include-config', + help='向导默认是否附带配置快照', + ), + ) -> None: + """ + 通过交互方式执行生产巡检。 + + :param output: 输出格式 + :param default_env: 向导默认环境 + :param default_include_config: 向导默认是否附带配置快照 + :return: None + """ + self.flow_loader.load_runner('cli.wizard.flows.prod_check', 'run_prod_check_wizard')( + output, + default_env=default_env, + default_include_config=default_include_config, + ) + + +WIZARD_COMMAND_BUILDER = WizardCommandBuilder() diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/__init__.py b/shierfeng-fastapi-backend/cli/wizard/flows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/app_run.py b/shierfeng-fastapi-backend/cli/wizard/flows/app_run.py new file mode 100644 index 0000000..f04c910 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/flows/app_run.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass +from typing import Any + +from cli.context import CliContext +from cli.wizard.base import BaseLiveExecCliWizardFlow + + +@dataclass(frozen=True) +class AppRunWizardSelection: + """ + `wizard app-run` 向导采集结果。 + + :param env: 运行环境 + :param run_doctor: 启动前是否先执行应用检查 + """ + + env: str + run_doctor: bool + + +class AppRunWizardFlow(BaseLiveExecCliWizardFlow[AppRunWizardSelection]): + """ + `wizard app-run` 向导流程。 + + :param doctor_payload: 启动前检查结果缓存 + """ + + wizard_name = 'wizard app-run' + preview_title = 'wizard app-run preview' + failure_message = '应用启动向导执行失败' + + def collect_selection(self) -> AppRunWizardSelection: + """ + 采集应用启动向导参数。 + + :return: 向导采集结果 + """ + env = self.prompt_service.prompt_env('dev') + run_doctor = self.prompt_service.prompt_confirm('启动前是否先执行应用检查', default_value=True) + return AppRunWizardSelection(env=env, run_doctor=run_doctor) + + def prepare_context(self, selection: AppRunWizardSelection, output: str) -> CliContext: + """ + 构建应用启动向导上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + del output + return self.build_readonly_context(selection.env, 'text') + + def build_preview_summary(self, selection: AppRunWizardSelection) -> dict[str, Any]: + """ + 构建应用启动预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + return { + 'env': selection.env, + 'run_doctor': str(selection.run_doctor).lower(), + 'doctor_ok': '-' if self.doctor_payload is None else str(self.doctor_payload.get('ok', False)).lower(), + } + + def build_preview_command(self, selection: AppRunWizardSelection) -> list[str]: + """ + 构建应用启动预览命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + return ['shierfeng', 'app', 'run', f'--env={selection.env}'] + + def build_preview_notes(self, selection: AppRunWizardSelection) -> list[str] | None: + """ + 构建应用启动预览附加说明。 + + :param selection: 向导采集结果 + :return: 预览附加说明 + """ + del selection + notes: list[str] = [] + if isinstance(self.doctor_payload, dict) and not self.doctor_payload.get('ok', False): + notes.append(self.doctor_payload.get('message', '启动前检查未通过')) + return notes or None + + def confirm_prompt(self, selection: AppRunWizardSelection) -> str: + """ + 返回应用启动最终确认提示。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + del selection + return '确认启动应用吗' + + def confirm_default_value(self, selection: AppRunWizardSelection) -> bool: + """ + 返回应用启动确认默认值。 + + :param selection: 向导采集结果 + :return: 默认确认值 + """ + del selection + return bool(self.doctor_payload is None or self.doctor_payload.get('ok', False)) + + def build_execute_arguments(self, selection: AppRunWizardSelection, output: str) -> list[str]: + """ + 构建应用启动内部 CLI 参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + del output + return ['app', 'run', f'--env={selection.env}'] + + def __init__(self) -> None: + """ + 初始化应用启动向导流程。 + + :return: None + """ + super().__init__() + self.doctor_payload: dict[str, Any] | None = None + + def prepare_execution_state(self, selection: AppRunWizardSelection, output: str) -> None: + """ + 在预览与确认前准备启动前检查结果。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: None + """ + del output + self.doctor_payload = None + if selection.run_doctor: + doctor_result = self.run_nested_command( + 'app', 'doctor', f'--env={selection.env}', '--output=json', parse_json=True + ) + payload = getattr(doctor_result, 'payload', None) + self.doctor_payload = payload if isinstance(payload, dict) else None + + +def run_app_run_wizard() -> None: + """ + 执行 `wizard app-run` 向导。 + + :return: None + """ + AppRunWizardFlow().run('text') diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/cache_clear.py b/shierfeng-fastapi-backend/cli/wizard/flows/cache_clear.py new file mode 100644 index 0000000..3d5e806 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/flows/cache_clear.py @@ -0,0 +1,213 @@ +from dataclasses import dataclass + +from cli.context import CliContext, OutputOption +from cli.wizard.base import BaseCliWizardFlow + + +@dataclass(frozen=True) +class CacheClearWizardSelection: + """ + `wizard cache-clear` 向导采集结果。 + + :param env: 运行环境 + :param mode: 清理模式 + :param cache_name: 缓存名称前缀 + :param cache_key: 缓存键名关键字 + :param dry_run: 是否演练执行 + :param allow_prod: 是否允许生产环境执行 + """ + + env: str + mode: str + cache_name: str + cache_key: str + dry_run: bool + allow_prod: bool + + +class CacheClearWizardFlow(BaseCliWizardFlow[CacheClearWizardSelection]): + """ + `wizard cache-clear` 向导流程。 + + :param default_env: 默认环境 + :param default_mode: 默认清理模式 + :param default_cache_name: 默认缓存名称前缀 + :param default_cache_key: 默认缓存键名关键字 + :param default_dry_run: 默认是否先执行 dry-run + """ + + wizard_name = 'wizard cache-clear' + preview_title = 'wizard cache-clear preview' + failure_message = '缓存清理向导执行失败' + + def __init__( + self, + *, + default_env: str = 'dev', + default_mode: str = 'cache-name', + default_cache_name: str = '', + default_cache_key: str = '', + default_dry_run: bool = True, + ) -> None: + """ + 初始化缓存清理向导流程。 + + :param default_env: 默认环境 + :param default_mode: 默认清理模式 + :param default_cache_name: 默认缓存名称前缀 + :param default_cache_key: 默认缓存键名关键字 + :param default_dry_run: 默认是否先执行 dry-run + :return: None + """ + self.default_env = default_env + self.default_mode = default_mode + self.default_cache_name = default_cache_name + self.default_cache_key = default_cache_key + self.default_dry_run = default_dry_run + + def collect_selection(self) -> CacheClearWizardSelection: + """ + 采集缓存清理向导参数。 + + :return: 向导采集结果 + """ + env = self.prompt_service.prompt_env(self.default_env) + mode = self.prompt_service.prompt_choice( + '清理模式', + ['cache-name', 'cache-key', 'all'], + self.default_mode, + ) + cache_name = '' + cache_key = '' + if mode == 'cache-name': + cache_name = self.prompt_service.prompt_required_text('缓存名称前缀', self.default_cache_name) + elif mode == 'cache-key': + cache_key = self.prompt_service.prompt_required_text('缓存键名关键字', self.default_cache_key) + else: + self.prompt_service.prompt_optional_text('当前将清理全部缓存,按回车继续', '') + dry_run = self.prompt_service.prompt_confirm('是否先执行 dry-run 预演', default_value=self.default_dry_run) + allow_prod = ( + self.prompt_service.prompt_confirm('当前为 prod 环境,是否允许继续执行', default_value=False) + if env == 'prod' + else False + ) + return CacheClearWizardSelection( + env=env, + mode=mode, + cache_name=cache_name, + cache_key=cache_key, + dry_run=dry_run, + allow_prod=allow_prod, + ) + + def prepare_context(self, selection: CacheClearWizardSelection, output: OutputOption) -> CliContext: + """ + 构建缓存清理向导上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.build_regular_context( + selection.env, + output, + allow_prod=selection.allow_prod, + yes=True, + dry_run=selection.dry_run, + ) + + def build_preview_summary(self, selection: CacheClearWizardSelection) -> dict[str, str]: + """ + 构建缓存清理预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + return { + 'env': selection.env, + 'mode': selection.mode, + 'cache_name': selection.cache_name or '-', + 'cache_key': selection.cache_key or '-', + 'dry_run': str(selection.dry_run).lower(), + 'allow_prod': str(selection.allow_prod).lower(), + } + + def build_preview_command(self, selection: CacheClearWizardSelection) -> list[str]: + """ + 构建缓存清理预览命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + command = ['shierfeng', 'cache', 'clear', f'--env={selection.env}'] + if selection.mode == 'cache-name': + command.append(f'--cache-name={selection.cache_name}') + elif selection.mode == 'cache-key': + command.append(f'--cache-key={selection.cache_key}') + else: + command.append('--all') + if selection.dry_run: + command.append('--dry-run') + if selection.allow_prod: + command.append('--allow-prod') + return command + + def confirm_prompt(self, selection: CacheClearWizardSelection) -> str: + """ + 返回缓存清理最终确认提示。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + del selection + return '确认执行缓存清理向导吗' + + def build_execute_arguments(self, selection: CacheClearWizardSelection, output: OutputOption) -> list[str]: + """ + 构建缓存清理内部 CLI 参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + arguments = ['cache', 'clear', f'--env={selection.env}', f'--output={output}', '--yes'] + if selection.mode == 'cache-name': + arguments.append(f'--cache-name={selection.cache_name}') + elif selection.mode == 'cache-key': + arguments.append(f'--cache-key={selection.cache_key}') + else: + arguments.append('--all') + if selection.dry_run: + arguments.append('--dry-run') + if selection.allow_prod: + arguments.append('--allow-prod') + return arguments + + +def run_cache_clear_wizard( + output: OutputOption = 'text', + *, + default_env: str = 'dev', + default_mode: str = 'cache-name', + default_cache_name: str = '', + default_cache_key: str = '', + default_dry_run: bool = True, +) -> None: + """ + 执行 `wizard cache-clear` 向导。 + + :param output: 输出格式 + :param default_env: 默认环境 + :param default_mode: 默认清理模式 + :param default_cache_name: 默认缓存名称前缀 + :param default_cache_key: 默认缓存键关键字 + :param default_dry_run: 默认是否执行 dry-run + :return: None + """ + CacheClearWizardFlow( + default_env=default_env, + default_mode=default_mode, + default_cache_name=default_cache_name, + default_cache_key=default_cache_key, + default_dry_run=default_dry_run, + ).run(output) diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/db_upgrade.py b/shierfeng-fastapi-backend/cli/wizard/flows/db_upgrade.py new file mode 100644 index 0000000..1f9c3b2 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/flows/db_upgrade.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass + +from cli.context import CliContext, OutputOption +from cli.wizard.base import BaseCliWizardFlow + + +@dataclass(frozen=True) +class DbUpgradeWizardSelection: + """ + `wizard db-upgrade` 向导采集结果。 + + :param env: 运行环境 + :param revision: 目标迁移版本 + :param dry_run: 是否演练执行 + :param allow_prod: 是否允许生产环境执行 + """ + + env: str + revision: str + dry_run: bool + allow_prod: bool + + +class DbUpgradeWizardFlow(BaseCliWizardFlow[DbUpgradeWizardSelection]): + """ + `wizard db-upgrade` 向导流程。 + + :param default_env: 默认环境 + :param default_revision: 默认目标 revision + :param default_dry_run: 默认是否先执行 dry-run + """ + + wizard_name = 'wizard db-upgrade' + preview_title = 'wizard db-upgrade preview' + failure_message = '数据库升级向导执行失败' + + def __init__( + self, + *, + default_env: str = 'dev', + default_revision: str = 'head', + default_dry_run: bool = True, + ) -> None: + """ + 初始化数据库升级向导流程。 + + :param default_env: 默认环境 + :param default_revision: 默认目标 revision + :param default_dry_run: 默认是否先执行 dry-run + :return: None + """ + self.default_env = default_env + self.default_revision = default_revision + self.default_dry_run = default_dry_run + + def collect_selection(self) -> DbUpgradeWizardSelection: + """ + 采集数据库升级向导参数。 + + :return: 向导采集结果 + """ + env = self.prompt_service.prompt_env(self.default_env) + revision = self.prompt_service.prompt_required_text('目标迁移版本', self.default_revision) + dry_run = self.prompt_service.prompt_confirm('是否先执行 dry-run 预演', default_value=self.default_dry_run) + allow_prod = ( + self.prompt_service.prompt_confirm('当前为 prod 环境,是否允许继续执行', default_value=False) + if env == 'prod' + else False + ) + return DbUpgradeWizardSelection(env=env, revision=revision, dry_run=dry_run, allow_prod=allow_prod) + + def prepare_context(self, selection: DbUpgradeWizardSelection, output: OutputOption) -> CliContext: + """ + 构建数据库升级向导上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.build_regular_context( + selection.env, + output, + allow_prod=selection.allow_prod, + yes=True, + dry_run=selection.dry_run, + ) + + def build_preview_summary(self, selection: DbUpgradeWizardSelection) -> dict[str, str]: + """ + 构建数据库升级预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + return { + 'env': selection.env, + 'revision': selection.revision, + 'dry_run': str(selection.dry_run).lower(), + 'allow_prod': str(selection.allow_prod).lower(), + } + + def build_preview_command(self, selection: DbUpgradeWizardSelection) -> list[str]: + """ + 构建数据库升级预览命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + command = ['shierfeng', 'db', 'upgrade', f'--env={selection.env}', f'--revision={selection.revision}'] + if selection.dry_run: + command.append('--dry-run') + if selection.allow_prod: + command.append('--allow-prod') + return command + + def confirm_prompt(self, selection: DbUpgradeWizardSelection) -> str: + """ + 返回数据库升级最终确认提示。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + del selection + return '确认执行数据库升级向导吗' + + def build_execute_arguments(self, selection: DbUpgradeWizardSelection, output: OutputOption) -> list[str]: + """ + 构建数据库升级内部 CLI 参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + arguments = [ + 'db', + 'upgrade', + f'--env={selection.env}', + f'--output={output}', + f'--revision={selection.revision}', + '--yes', + ] + if selection.dry_run: + arguments.append('--dry-run') + if selection.allow_prod: + arguments.append('--allow-prod') + return arguments + + +def run_db_upgrade_wizard( + output: OutputOption = 'text', + *, + default_env: str = 'dev', + default_revision: str = 'head', + default_dry_run: bool = True, +) -> None: + """ + 执行 `wizard db-upgrade` 向导。 + + :param output: 输出格式 + :param default_env: 默认环境 + :param default_revision: 默认目标 revision + :param default_dry_run: 默认是否先 dry-run + :return: None + """ + DbUpgradeWizardFlow( + default_env=default_env, + default_revision=default_revision, + default_dry_run=default_dry_run, + ).run(output) diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/gen_export.py b/shierfeng-fastapi-backend/cli/wizard/flows/gen_export.py new file mode 100644 index 0000000..1a2bf0b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/flows/gen_export.py @@ -0,0 +1,239 @@ +from dataclasses import dataclass + +import typer + +from cli.context import CliContext, OutputOption +from cli.wizard.base import BaseCliWizardFlow + + +@dataclass(frozen=True) +class GenExportWizardSelection: + """ + `wizard gen-export` 向导采集结果。 + + :param env: 运行环境 + :param table_names: 业务表名称列表 + :param mode: 导出模式 + :param output_file: zip 导出目标文件路径 + :param dry_run: 是否演练执行 + :param allow_prod: 是否允许生产环境执行 + """ + + env: str + table_names: list[str] + mode: str + output_file: str + dry_run: bool + allow_prod: bool + + +class GenExportWizardFlow(BaseCliWizardFlow[GenExportWizardSelection]): + """ + `wizard gen-export` 向导流程。 + + :param default_env: 默认环境 + :param default_table_names: 默认业务表名称列表文本 + :param default_mode: 默认导出模式 + :param default_output_file: 默认 zip 导出目标文件路径 + :param default_dry_run: 默认是否先执行 dry-run + """ + + wizard_name = 'wizard gen-export' + preview_title = 'wizard gen-export preview' + failure_message = '代码导出向导执行失败' + + def __init__( + self, + *, + default_env: str = 'dev', + default_table_names: str = '', + default_mode: str = 'zip', + default_output_file: str = '', + default_dry_run: bool = True, + ) -> None: + """ + 初始化代码导出向导流程。 + + :param default_env: 默认环境 + :param default_table_names: 默认业务表名称列表文本 + :param default_mode: 默认导出模式 + :param default_output_file: 默认 zip 导出目标文件路径 + :param default_dry_run: 默认是否先执行 dry-run + :return: None + """ + self.default_env = default_env + self.default_table_names = default_table_names + self.default_mode = default_mode + self.default_output_file = default_output_file + self.default_dry_run = default_dry_run + + def collect_selection(self) -> GenExportWizardSelection: + """ + 采集代码导出向导参数。 + + :return: 向导采集结果 + """ + env = self.prompt_service.prompt_env(self.default_env) + raw_table_names = self.prompt_service.prompt_required_text( + '业务表名称列表(多个表请使用逗号分隔)', + self.default_table_names, + ) + table_names = self.parse_table_names(raw_table_names) + while not table_names: + typer.echo('至少需要输入一个业务表名称,请重新输入。') + raw_table_names = self.prompt_service.prompt_required_text( + '业务表名称列表(多个表请使用逗号分隔)', + self.default_table_names, + ) + table_names = self.parse_table_names(raw_table_names) + + mode = self.prompt_service.prompt_choice('导出模式', ['zip', 'local'], self.default_mode) + output_file = '' + if mode == 'zip': + output_file = self.prompt_service.prompt_optional_text( + 'zip 导出目标文件路径(留空则使用默认文件名)', + self.default_output_file, + ) + dry_run = self.prompt_service.prompt_confirm('是否先执行 dry-run 预演', default_value=self.default_dry_run) + allow_prod = ( + self.prompt_service.prompt_confirm('当前为 prod 环境,是否允许继续执行', default_value=False) + if env == 'prod' + else False + ) + return GenExportWizardSelection( + env=env, + table_names=table_names, + mode=mode, + output_file=output_file, + dry_run=dry_run, + allow_prod=allow_prod, + ) + + def prepare_context(self, selection: GenExportWizardSelection, output: OutputOption) -> CliContext: + """ + 构建代码导出向导上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.build_regular_context( + selection.env, + output, + allow_prod=selection.allow_prod, + yes=True, + dry_run=selection.dry_run, + ) + + def build_preview_summary(self, selection: GenExportWizardSelection) -> dict[str, str]: + """ + 构建代码导出预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + return { + 'env': selection.env, + 'table_names': ','.join(selection.table_names), + 'mode': selection.mode, + 'output_file': selection.output_file or '-', + 'dry_run': str(selection.dry_run).lower(), + 'allow_prod': str(selection.allow_prod).lower(), + } + + def build_preview_command(self, selection: GenExportWizardSelection) -> list[str]: + """ + 构建代码导出预览命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + command = [ + 'shierfeng', + 'gen', + 'export', + *selection.table_names, + f'--env={selection.env}', + f'--mode={selection.mode}', + ] + if selection.output_file: + command.append(f'--output-file={selection.output_file}') + if selection.dry_run: + command.append('--dry-run') + if selection.allow_prod: + command.append('--allow-prod') + return command + + def confirm_prompt(self, selection: GenExportWizardSelection) -> str: + """ + 返回代码导出最终确认提示。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + del selection + return '确认执行代码导出向导吗' + + def build_execute_arguments(self, selection: GenExportWizardSelection, output: OutputOption) -> list[str]: + """ + 构建代码导出内部 CLI 参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + arguments = [ + 'gen', + 'export', + *selection.table_names, + f'--env={selection.env}', + f'--output={output}', + f'--mode={selection.mode}', + '--yes', + ] + if selection.output_file: + arguments.append(f'--output-file={selection.output_file}') + if selection.dry_run: + arguments.append('--dry-run') + if selection.allow_prod: + arguments.append('--allow-prod') + return arguments + + @staticmethod + def parse_table_names(raw_value: str) -> list[str]: + """ + 将逗号分隔的业务表名称文本解析为列表。 + + :param raw_value: 原始输入文本 + :return: 去空白后的业务表名称列表 + """ + return [table_name.strip() for table_name in raw_value.split(',') if table_name.strip()] + + +def run_gen_export_wizard( + output: OutputOption = 'text', + *, + default_env: str = 'dev', + default_table_names: str = '', + default_mode: str = 'zip', + default_output_file: str = '', + default_dry_run: bool = True, +) -> None: + """ + 执行 `wizard gen-export` 向导。 + + :param output: 输出格式 + :param default_env: 默认环境 + :param default_table_names: 默认业务表名称列表文本 + :param default_mode: 默认导出模式 + :param default_output_file: 默认 zip 导出目标文件路径 + :param default_dry_run: 默认是否先 dry-run + :return: None + """ + GenExportWizardFlow( + default_env=default_env, + default_table_names=default_table_names, + default_mode=default_mode, + default_output_file=default_output_file, + default_dry_run=default_dry_run, + ).run(output) diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/gen_import.py b/shierfeng-fastapi-backend/cli/wizard/flows/gen_import.py new file mode 100644 index 0000000..d57db10 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/flows/gen_import.py @@ -0,0 +1,208 @@ +from dataclasses import dataclass + +import typer + +from cli.context import CliContext, OutputOption +from cli.wizard.base import BaseCliWizardFlow + + +@dataclass(frozen=True) +class GenImportWizardSelection: + """ + `wizard gen-import` 向导采集结果。 + + :param env: 运行环境 + :param table_names: 待导入物理表名称列表 + :param dry_run: 是否演练执行 + :param allow_prod: 是否允许生产环境执行 + """ + + env: str + table_names: list[str] + dry_run: bool + allow_prod: bool + + +class GenImportWizardFlow(BaseCliWizardFlow[GenImportWizardSelection]): + """ + `wizard gen-import` 向导流程。 + + :param default_env: 默认环境 + :param default_table_names: 默认物理表名称列表文本 + :param default_dry_run: 默认是否先执行 dry-run + """ + + wizard_name = 'wizard gen-import' + preview_title = 'wizard gen-import preview' + failure_message = '代码生成导入向导执行失败' + + def __init__( + self, + *, + default_env: str = 'dev', + default_table_names: str = '', + default_dry_run: bool = True, + ) -> None: + """ + 初始化代码生成导入向导流程。 + + :param default_env: 默认环境 + :param default_table_names: 默认物理表名称列表文本 + :param default_dry_run: 默认是否先执行 dry-run + :return: None + """ + self.default_env = default_env + self.default_table_names = default_table_names + self.default_dry_run = default_dry_run + + def collect_selection(self) -> GenImportWizardSelection: + """ + 采集代码生成导入向导参数。 + + :return: 向导采集结果 + """ + env = self.prompt_service.prompt_env(self.default_env) + raw_table_names = self.prompt_service.prompt_required_text( + '物理表名称列表(多个表请使用逗号分隔)', + self.default_table_names, + ) + table_names = self.parse_table_names(raw_table_names) + while not table_names: + typer.echo('至少需要输入一个物理表名称,请重新输入。') + raw_table_names = self.prompt_service.prompt_required_text( + '物理表名称列表(多个表请使用逗号分隔)', + self.default_table_names, + ) + table_names = self.parse_table_names(raw_table_names) + + dry_run = self.prompt_service.prompt_confirm('是否先执行 dry-run 预演', default_value=self.default_dry_run) + allow_prod = ( + self.prompt_service.prompt_confirm('当前为 prod 环境,是否允许继续执行', default_value=False) + if env == 'prod' + else False + ) + return GenImportWizardSelection( + env=env, + table_names=table_names, + dry_run=dry_run, + allow_prod=allow_prod, + ) + + def prepare_context(self, selection: GenImportWizardSelection, output: OutputOption) -> CliContext: + """ + 构建代码生成导入向导上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.build_regular_context( + selection.env, + output, + allow_prod=selection.allow_prod, + yes=True, + dry_run=selection.dry_run, + ) + + def build_preview_summary(self, selection: GenImportWizardSelection) -> dict[str, str]: + """ + 构建代码生成导入预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + return { + 'env': selection.env, + 'table_names': ','.join(selection.table_names), + 'dry_run': str(selection.dry_run).lower(), + 'allow_prod': str(selection.allow_prod).lower(), + } + + def build_preview_command(self, selection: GenImportWizardSelection) -> list[str]: + """ + 构建代码生成导入预览命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + command = ['shierfeng', 'gen', 'import-table', *selection.table_names, f'--env={selection.env}'] + if selection.dry_run: + command.append('--dry-run') + if selection.allow_prod: + command.append('--allow-prod') + return command + + def build_preview_notes(self, selection: GenImportWizardSelection) -> list[str] | None: + """ + 构建代码生成导入预览附加说明。 + + :param selection: 向导采集结果 + :return: 预览附加说明 + """ + del selection + return ['建议先确认物理表注释和字段规模,再决定是否执行真实导入。'] + + def confirm_prompt(self, selection: GenImportWizardSelection) -> str: + """ + 返回代码生成导入最终确认提示。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + del selection + return '确认执行代码生成导入向导吗' + + def build_execute_arguments(self, selection: GenImportWizardSelection, output: OutputOption) -> list[str]: + """ + 构建代码生成导入内部 CLI 参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + arguments = [ + 'gen', + 'import-table', + *selection.table_names, + f'--env={selection.env}', + f'--output={output}', + '--yes', + ] + if selection.dry_run: + arguments.append('--dry-run') + if selection.allow_prod: + arguments.append('--allow-prod') + return arguments + + @staticmethod + def parse_table_names(raw_value: str) -> list[str]: + """ + 将逗号分隔的物理表名称文本解析为列表。 + + :param raw_value: 原始输入文本 + :return: 去空白后的物理表名称列表 + """ + return [table_name.strip() for table_name in raw_value.split(',') if table_name.strip()] + + +def run_gen_import_wizard( + output: OutputOption = 'text', + *, + default_env: str = 'dev', + default_table_names: str = '', + default_dry_run: bool = True, +) -> None: + """ + 执行 `wizard gen-import` 向导。 + + :param output: 输出格式 + :param default_env: 默认环境 + :param default_table_names: 默认物理表名称列表文本 + :param default_dry_run: 默认是否先 dry-run + :return: None + """ + GenImportWizardFlow( + default_env=default_env, + default_table_names=default_table_names, + default_dry_run=default_dry_run, + ).run(output) diff --git a/shierfeng-fastapi-backend/cli/wizard/flows/prod_check.py b/shierfeng-fastapi-backend/cli/wizard/flows/prod_check.py new file mode 100644 index 0000000..8071296 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/flows/prod_check.py @@ -0,0 +1,224 @@ +from dataclasses import dataclass +from typing import Any + +from cli.context import CliContext, OutputOption +from cli.wizard.aggregators import ProdCheckAggregator +from cli.wizard.base import BaseCliWizardFlow +from cli.wizard.presenters import ProdCheckPresenter + + +@dataclass(frozen=True) +class ProdCheckWizardSelection: + """ + `wizard prod-check` 向导采集结果。 + + :param env: 运行环境 + :param include_config: 是否附带配置快照 + """ + + env: str + include_config: bool + + +class ProdCheckWizardFlow(BaseCliWizardFlow[ProdCheckWizardSelection]): + """ + `wizard prod-check` 向导流程。 + + :param default_env: 默认环境 + :param default_include_config: 默认是否附带配置快照 + """ + + wizard_name = 'wizard prod-check' + preview_title = 'wizard prod-check preview' + failure_message = '生产巡检向导执行失败' + + def __init__( + self, + *, + default_env: str = 'prod', + default_include_config: bool = True, + ) -> None: + """ + 初始化生产巡检向导流程。 + + :param default_env: 默认环境 + :param default_include_config: 默认是否附带配置快照 + :return: None + """ + super().__init__() + self.default_env = default_env + self.default_include_config = default_include_config + self.aggregator = ProdCheckAggregator() + self.presenter = ProdCheckPresenter() + + def collect_selection(self) -> ProdCheckWizardSelection: + """ + 采集生产巡检向导参数。 + + :return: 向导采集结果 + """ + env = self.prompt_service.prompt_env(self.default_env) + include_config = self.prompt_service.prompt_confirm( + '是否附带应用配置快照', + default_value=self.default_include_config, + ) + return ProdCheckWizardSelection(env=env, include_config=include_config) + + def prepare_context(self, selection: ProdCheckWizardSelection, output: OutputOption) -> CliContext: + """ + 构建生产巡检向导上下文。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: CLI 上下文 + """ + return self.build_readonly_context(selection.env, output) + + def build_preview_summary(self, selection: ProdCheckWizardSelection) -> dict[str, str]: + """ + 构建生产巡检预览摘要。 + + :param selection: 向导采集结果 + :return: 预览摘要 + """ + return { + 'env': selection.env, + 'include_config': str(selection.include_config).lower(), + } + + def build_preview_command(self, selection: ProdCheckWizardSelection) -> list[str]: + """ + 构建生产巡检预览命令。 + + :param selection: 向导采集结果 + :return: 用户视角命令参数 + """ + return ['shierfeng', 'app', 'doctor', f'--env={selection.env}'] + + def confirm_prompt(self, selection: ProdCheckWizardSelection) -> str: + """ + 返回生产巡检最终确认提示。 + + :param selection: 向导采集结果 + :return: 确认提示 + """ + del selection + return '确认执行生产巡检向导吗' + + def confirm_default_value(self, selection: ProdCheckWizardSelection) -> bool: + """ + 返回生产巡检确认默认值。 + + :param selection: 向导采集结果 + :return: 默认确认值 + """ + del selection + return True + + def build_execute_arguments(self, selection: ProdCheckWizardSelection, output: OutputOption) -> list[str]: + """ + 返回生产巡检占位执行参数。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 内部 CLI 参数 + """ + del selection, output + return [] + + def execute(self, selection: ProdCheckWizardSelection, output: OutputOption) -> dict[str, Any]: + """ + 执行生产巡检聚合逻辑。 + + :param selection: 向导采集结果 + :param output: 输出格式 + :return: 聚合后的巡检结果 + """ + del output + runtime_result = self.run_nested_command( + 'app', 'env', f'--env={selection.env}', '--output=json', parse_json=True + ) + doctor_result = self.run_nested_command( + 'app', + 'doctor', + f'--env={selection.env}', + '--output=json', + parse_json=True, + ) + config_payload = None + if selection.include_config: + config_result = self.run_nested_command( + 'app', + 'config', + f'--env={selection.env}', + '--output=json', + parse_json=True, + ) + config_payload = config_result.payload + return self.aggregator.build_payload( + env=selection.env, + runtime_payload=runtime_result.payload if isinstance(runtime_result.payload, dict) else None, + doctor_payload=doctor_result.payload if isinstance(doctor_result.payload, dict) else None, + config_payload=config_payload, + ) + + def complete_payload( + self, + ctx: CliContext, + payload: dict[str, Any], + *, + default_exit_code: int = 0, + ) -> None: + """ + 以聚合结果展示器收口生产巡检结果。 + + :param ctx: CLI 上下文 + :param payload: 聚合结果负载 + :param default_exit_code: 默认退出码 + :return: None + """ + self.complete_payload_result( + ctx, + payload, + text_builder=self.presenter.build_text, + default_exit_code=default_exit_code, + ) + + def extract_payload(self, nested_result: Any) -> dict[str, Any] | None: + """ + 提取生产巡检聚合结果负载。 + + :param nested_result: 聚合执行结果 + :return: 结构化结果负载 + """ + return nested_result if isinstance(nested_result, dict) else None + + def extract_returncode(self, nested_result: Any) -> int: + """ + 提取生产巡检聚合结果退出码。 + + :param nested_result: 聚合执行结果 + :return: 退出码 + """ + del nested_result + return 0 + + +def run_prod_check_wizard( + output: OutputOption = 'text', + *, + default_env: str = 'prod', + default_include_config: bool = True, +) -> None: + """ + 执行 `wizard prod-check` 向导。 + + :param output: 输出格式 + :param default_env: 默认环境 + :param default_include_config: 默认是否附带配置快照 + :return: None + """ + ProdCheckWizardFlow( + default_env=default_env, + default_include_config=default_include_config, + ).run(output) diff --git a/shierfeng-fastapi-backend/cli/wizard/presenters.py b/shierfeng-fastapi-backend/cli/wizard/presenters.py new file mode 100644 index 0000000..a0eea0b --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/presenters.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ProdCheckRenderingSupport: + """ + `wizard prod-check` 文本渲染支持对象。 + + 该对象负责构建 runtime、doctor、config 分区文本行, + 避免 presenter 本体继续维护全部分区细节。 + """ + + @staticmethod + def build_runtime_lines(runtime: dict[str, Any]) -> list[str]: + """ + 构建 runtime 分区文本行。 + + :param runtime: runtime 结果片段 + :return: 文本行列表 + """ + return [ + 'runtime:', + f' cli_env: {runtime.get("cliEnv", "-")}', + f' config_env: {runtime.get("configEnv", "-")}', + f' env_file: {runtime.get("envFile", "-")}', + f' env_file_exists: {str(runtime.get("envFileExists", False)).lower()}', + ] + + @staticmethod + def build_doctor_lines(doctor: dict[str, Any]) -> list[str]: + """ + 构建 doctor 分区文本行。 + + :param doctor: doctor 结果片段 + :return: 文本行列表 + """ + return [ + 'doctor:', + f' ok: {str(doctor.get("ok", False)).lower()}', + f' message: {doctor.get("message", "-")}', + ] + + @staticmethod + def build_config_lines(config: dict[str, Any]) -> list[str]: + """ + 构建 config 分区文本行。 + + :param config: config 结果片段 + :return: 文本行列表 + """ + return [ + 'config:', + f' name: {config.get("name", "-")}', + f' host: {config.get("host", "-")}:{config.get("port", "-")}', + f' db_type: {config.get("dbType", "-")}', + f' redis_host: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}', + ] + + +@dataclass(frozen=True) +class ProdCheckPresenter: + """ + `wizard prod-check` 结果展示器。 + + 该对象负责将聚合结果转换为文本摘要, + 避免在 flow 内继续维护文本渲染细节。 + + :param rendering_support: 文本渲染支持对象 + """ + + rendering_support: ProdCheckRenderingSupport = ProdCheckRenderingSupport() + + def build_text(self, payload: dict[str, Any]) -> str: + """ + 将生产巡检聚合结果渲染为文本摘要。 + + :param payload: 聚合结果字典 + :return: 文本摘要 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'env: {payload.get("env", "-")}', + f'message: {payload.get("message", "-")}', + ] + runtime = payload.get('runtime') + if isinstance(runtime, dict): + lines.extend(self.rendering_support.build_runtime_lines(runtime)) + doctor = payload.get('doctor') + if isinstance(doctor, dict): + lines.extend(self.rendering_support.build_doctor_lines(doctor)) + config = payload.get('config') + if isinstance(config, dict): + lines.extend(self.rendering_support.build_config_lines(config)) + return '\n'.join(lines) diff --git a/shierfeng-fastapi-backend/cli/wizard/preview.py b/shierfeng-fastapi-backend/cli/wizard/preview.py new file mode 100644 index 0000000..96b31f7 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/preview.py @@ -0,0 +1,65 @@ +from typing import Any + +from cli.exit_codes import GUARD_REJECTED +from cli.output import CommandResult +from cli.utils import SHELL_TEXT_FORMATTER + + +class WizardPreviewRenderer: + """ + 向导预览渲染器。 + + 该对象负责统一构建向导预览文本和取消结果, + 为各个 flow 提供稳定的类式渲染入口。 + """ + + @staticmethod + def render_preview( + title: str, + *, + summary: dict[str, Any], + command: list[str], + notes: list[str] | None = None, + ) -> str: + """ + 渲染向导执行预览文本。 + + :param title: 预览标题 + :param summary: 关键参数摘要 + :param command: 用户视角命令参数列表 + :param notes: 额外提示列表 + :return: 可直接输出的预览文本 + """ + lines = [title, 'summary:'] + for key, value in summary.items(): + lines.append(f' {key}: {value}') + lines.extend( + [ + 'command:', + f' {SHELL_TEXT_FORMATTER.format_shell_command(command)}', + ] + ) + if notes: + lines.append('notes:') + lines.extend(f' - {note}' for note in notes if note) + return '\n'.join(lines) + + @staticmethod + def build_cancel_result(wizard_name: str) -> CommandResult: + """ + 构建向导取消结果。 + + :param wizard_name: 向导名称 + :return: 命令结果对象 + """ + return CommandResult( + data={ + 'ok': False, + 'message': f'已取消向导执行:{wizard_name}', + 'hint': '可重新运行向导并调整参数后再次执行', + }, + exit_code=GUARD_REJECTED, + ) + + +WIZARD_PREVIEW_RENDERER = WizardPreviewRenderer() diff --git a/shierfeng-fastapi-backend/cli/wizard/prompts.py b/shierfeng-fastapi-backend/cli/wizard/prompts.py new file mode 100644 index 0000000..004ae76 --- /dev/null +++ b/shierfeng-fastapi-backend/cli/wizard/prompts.py @@ -0,0 +1,86 @@ +import typer + +from cli.metadata import ENVIRONMENT_OPTION_SERVICE + + +class WizardPromptService: + """ + 向导交互输入服务。 + + 该对象集中封装向导中稳定复用的环境选择、枚举选择、 + 文本采集和确认交互逻辑,避免继续扩散模块级散函数入口。 + """ + + def prompt_env(self, default_env: str = 'dev') -> str: + """ + 交互采集运行环境名称。 + + :param default_env: 默认环境名称 + :return: 用户选择的环境名称 + """ + env_names = ENVIRONMENT_OPTION_SERVICE.discover_env_names() + prompt_message = f'运行环境(可选值: {", ".join(env_names)})' + while True: + env_value = typer.prompt(prompt_message, default=default_env).strip() + if env_value in env_names: + return env_value + typer.echo(f'不支持的环境名称:{env_value},请重新输入。') + + @staticmethod + def prompt_choice(prompt_text: str, choices: list[str], default_value: str) -> str: + """ + 交互采集枚举型文本输入。 + + :param prompt_text: 提示文案 + :param choices: 可选值列表 + :param default_value: 默认值 + :return: 用户选择的枚举值 + """ + while True: + value = typer.prompt( + f'{prompt_text}(可选值: {", ".join(choices)})', + default=default_value, + ).strip() + if value in choices: + return value + typer.echo(f'不支持的选项:{value},请重新输入。') + + @staticmethod + def prompt_required_text(prompt_text: str, default_value: str = '') -> str: + """ + 交互采集必填文本输入。 + + :param prompt_text: 提示文案 + :param default_value: 默认值 + :return: 用户输入文本 + """ + while True: + value = typer.prompt(prompt_text, default=default_value).strip() + if value: + return value + typer.echo('该字段不能为空,请重新输入。') + + @staticmethod + def prompt_optional_text(prompt_text: str, default_value: str = '') -> str: + """ + 交互采集可选文本输入。 + + :param prompt_text: 提示文案 + :param default_value: 默认值 + :return: 用户输入文本 + """ + return typer.prompt(prompt_text, default=default_value).strip() + + @staticmethod + def prompt_confirm(prompt_text: str, default_value: bool = False) -> bool: + """ + 交互采集确认型输入。 + + :param prompt_text: 提示文案 + :param default_value: 默认值 + :return: 用户确认结果 + """ + return typer.confirm(prompt_text, default=default_value) + + +WIZARD_PROMPT_SERVICE = WizardPromptService() diff --git a/shierfeng-fastapi-backend/common/annotation/cache_annotation.py b/shierfeng-fastapi-backend/common/annotation/cache_annotation.py new file mode 100644 index 0000000..aabeefa --- /dev/null +++ b/shierfeng-fastapi-backend/common/annotation/cache_annotation.py @@ -0,0 +1,502 @@ +import hashlib +import json +from collections.abc import Awaitable, Callable, Sequence +from datetime import datetime +from functools import wraps +from typing import Any, TypeVar + +from fastapi import Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse, ORJSONResponse, Response, StreamingResponse, UJSONResponse +from redis import asyncio as aioredis +from typing_extensions import ParamSpec + +from common.constant import HttpStatusConstant +from common.context import RequestContext +from common.enums import HttpMethod, RedisInitKeyConfig +from exceptions.exception import LoginException +from utils.api_annotation_util import ApiAnnotationUtil +from utils.api_response_header_util import ApiResponseHeaderUtil +from utils.log_util import logger + +P = ParamSpec('P') +R = TypeVar('R') + + +class ApiCacheManager: + """ + 接口缓存键与命名空间管理工具类 + """ + + @classmethod + async def clear_namespace(cls, redis: aioredis.Redis, namespace: str) -> int: + """ + 清理指定命名空间下的接口缓存 + + :param redis: Redis连接对象 + :param namespace: 缓存命名空间 + :return: 删除的缓存数量 + """ + return await cls._clear_by_pattern(redis, cls.build_namespace_pattern(namespace)) + + @classmethod + async def clear_all(cls, redis: aioredis.Redis) -> int: + """ + 清理所有接口缓存 + + :param redis: Redis连接对象 + :return: 删除的缓存数量 + """ + return await cls._clear_by_pattern(redis, cls.build_namespace_pattern('*')) + + @classmethod + async def clear_namespaces(cls, redis: aioredis.Redis, namespaces: list[str] | tuple[str, ...] | set[str]) -> int: + """ + 批量清理多个命名空间下的接口缓存 + + :param redis: Redis连接对象 + :param namespaces: 需要清理的缓存命名空间列表 + :return: 删除的缓存数量 + """ + deleted_count = 0 + for namespace in set(namespaces): + deleted_count += await cls.clear_namespace(redis, namespace) + + return deleted_count + + @classmethod + async def clear_namespace_prefix(cls, redis: aioredis.Redis, namespace_prefix: str) -> int: + """ + 按命名空间前缀清理接口缓存 + + :param redis: Redis连接对象 + :param namespace_prefix: 缓存命名空间前缀 + :return: 删除的缓存数量 + """ + return await cls._clear_by_pattern(redis, cls.build_namespace_prefix_pattern(namespace_prefix)) + + @classmethod + async def clear_namespace_prefixes( + cls, + redis: aioredis.Redis, + namespace_prefixes: list[str] | tuple[str, ...] | set[str], + ) -> int: + """ + 批量按命名空间前缀清理接口缓存 + + :param redis: Redis连接对象 + :param namespace_prefixes: 需要清理的缓存命名空间前缀列表 + :return: 删除的缓存数量 + """ + deleted_count = 0 + for namespace_prefix in set(namespace_prefixes): + deleted_count += await cls.clear_namespace_prefix(redis, namespace_prefix) + + return deleted_count + + @classmethod + def build_namespace_pattern(cls, namespace: str) -> str: + """ + 生成命名空间扫描表达式 + + :param namespace: 缓存命名空间 + :return: 缓存键扫描匹配模式 + """ + return f'{RedisInitKeyConfig.API_CACHE.key}:{namespace}:*' + + @classmethod + def build_namespace_prefix_pattern(cls, namespace_prefix: str) -> str: + """ + 生成命名空间前缀扫描表达式 + + :param namespace_prefix: 缓存命名空间前缀 + :return: 缓存键扫描匹配模式 + """ + return f'{RedisInitKeyConfig.API_CACHE.key}:{namespace_prefix}*' + + @classmethod + async def _clear_by_pattern(cls, redis: aioredis.Redis, pattern: str) -> int: + """ + 根据扫描表达式清理匹配的接口缓存 + + :param redis: Redis连接对象 + :param pattern: 缓存键扫描匹配模式 + :return: 删除的缓存数量 + """ + cache_keys = [key async for key in redis.scan_iter(match=pattern)] + if not cache_keys: + return 0 + + return await redis.delete(*cache_keys) + + +class _ApiCacheSupport: + """ + 接口缓存装饰器共用工具基类 + """ + + def _load_json_content(self, response_body: str) -> Any | None: + """ + 解析JSON响应体内容 + + :param response_body: JSON字符串响应体 + :return: 解析后的Python对象,解析失败时返回None + """ + try: + return json.loads(response_body) + except json.JSONDecodeError: + return None + + def _extract_json_response_content(self, result: Any) -> Any | None: + """ + 提取响应中的JSON内容 + + :param result: 原始接口返回结果 + :return: 解析后的JSON内容,无法提取时返回None + """ + if result is None or isinstance(result, StreamingResponse): + return None + + if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)): + return self._load_json_content(result.body.decode('utf-8')) + + if isinstance(result, Response): + return None + + return jsonable_encoder(result) + + def _match_response_codes(self, response_content: Any, response_codes: set[int]) -> bool: + """ + 判断响应内容中的业务响应码是否匹配 + + :param response_content: JSON响应内容 + :param response_codes: 允许的业务响应码集合 + :return: 是否匹配 + """ + if not isinstance(response_content, dict): + return True + + response_code = response_content.get('code') + if response_code is None: + return True + + return response_code in response_codes + + def _get_matched_response_content(self, result: Any, response_codes: set[int]) -> Any | None: + """ + 提取并校验满足业务响应码要求的JSON响应内容 + + :param result: 原始接口返回结果 + :param response_codes: 允许的业务响应码集合 + :return: 匹配成功的JSON响应内容,不匹配时返回None + """ + response_content = self._extract_json_response_content(result) + if response_content is None or not self._match_response_codes(response_content, response_codes): + return None + + return response_content + + +class ApiCache(_ApiCacheSupport): + """ + 接口缓存装饰器,仅用于幂等且返回JSON的接口 + """ + + def __init__( + self, + namespace: str, + expire_seconds: int = 10, + vary_by_user: bool = True, + methods: Sequence[HttpMethod] | None = None, + cache_response_codes: set[int] | None = None, + ) -> None: + """ + 初始化接口缓存装饰器 + + :param namespace: 缓存命名空间,用于区分不同接口类型 + :param expire_seconds: 缓存过期时间,单位秒 + :param vary_by_user: 是否按当前登录用户隔离缓存 + :param methods: 允许启用缓存的HttpMethod枚举列表,为None时默认仅缓存GET请求 + :param cache_response_codes: 允许缓存的业务响应码,为None时默认仅缓存成功响应 + """ + self.namespace = namespace + self.expire_seconds = expire_seconds + self.vary_by_user = vary_by_user + self.methods = ApiAnnotationUtil.normalize_http_methods(methods, default_methods=(HttpMethod.GET,)) + self.cache_response_codes = ( + cache_response_codes if cache_response_codes is not None else {HttpStatusConstant.SUCCESS} + ) + + def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + """ + 为目标异步接口函数增加接口缓存能力 + + :param func: 需要被缓存的异步接口函数 + :return: 包装后的异步接口函数 + """ + + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + request, redis = ApiAnnotationUtil.resolve_request_redis( + func, '当前应用未初始化Redis连接,跳过接口缓存', *args, **kwargs + ) + if request is None or redis is None: + return await func(*args, **kwargs) + if not self._is_request_method_allowed(request): + return await func(*args, **kwargs) + + cache_key = await self._build_cache_key(request) + cached_response = await redis.get(cache_key) + if cached_response: + return self._build_cached_response(cached_response) # type: ignore[return-value] + + result = await func(*args, **kwargs) + await self._cache_response(redis, cache_key, result) + ApiResponseHeaderUtil.merge_headers(request, {'X-Api-Cache': 'MISS'}) + + return result + + return wrapper + + def _is_request_method_allowed(self, request: Request) -> bool: + """ + 判断当前请求方法是否允许启用接口缓存 + + :param request: 当前请求对象 + :return: 是否允许缓存 + """ + return request.method.upper() in self.methods + + async def _build_cache_key(self, request: Request) -> str: + """ + 根据当前请求生成稳定缓存键 + + :param request: 当前请求对象 + :return: 接口缓存键 + """ + request_body = await request.body() + user_scope = self._get_user_scope(request) if self.vary_by_user else '' + key_material = { + 'method': request.method, + 'path': request.url.path, + 'path_params': dict(sorted(request.path_params.items())), + 'query_params': sorted(request.query_params.multi_items()), + 'body_digest': hashlib.sha256(request_body).hexdigest() if request_body else '', + 'user_scope': user_scope, + } + key_digest = hashlib.sha256( + json.dumps( + key_material, + ensure_ascii=False, + sort_keys=True, + separators=(',', ':'), + default=str, + ).encode('utf-8') + ).hexdigest() + + return f'{RedisInitKeyConfig.API_CACHE.key}:{self.namespace}:{key_digest}' + + def _get_user_scope(self, request: Request) -> str: + """ + 获取用户隔离维度 + + 优先使用当前登录用户ID,未登录时回退到Authorization摘要。 + + :param request: 当前请求对象 + :return: 用户隔离字符串 + """ + try: + current_user = RequestContext.get_current_user() + return str(current_user.user.user_id) + except LoginException: + authorization = request.headers.get('Authorization', '') + return hashlib.sha256(authorization.encode('utf-8')).hexdigest() if authorization else '' + + def _serialize_response(self, result: Any) -> str | None: + """ + 将响应对象序列化为可存入缓存的字符串 + + :param result: 原始接口返回结果 + :return: 序列化后的缓存内容,不可缓存时返回None + """ + response_payload = self._extract_response_payload(result) + if response_payload is None: + return None + + return json.dumps(response_payload, ensure_ascii=False) + + def _extract_response_payload(self, result: Any) -> dict[str, Any] | None: + """ + 提取可缓存的响应载荷 + + :param result: 原始接口返回结果 + :return: 可缓存的响应载荷,不可缓存时返回None + """ + if isinstance(result, StreamingResponse): + return None + + if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)): + # 命中缓存时无法安全复放后台任务,因此带有后台任务的响应不参与缓存 + if getattr(result, 'background', None) is not None: + return None + + content = self._get_matched_response_content(result, self.cache_response_codes) + if content is None: + return None + + return { + 'content': content, + 'status_code': result.status_code, + 'media_type': result.media_type, + 'headers': self._filter_response_headers(result.headers), + } + + if isinstance(result, Response): + return None + + json_content = self._get_matched_response_content(result, self.cache_response_codes) + if json_content is None: + return None + + return { + 'content': json_content, + 'status_code': HttpStatusConstant.SUCCESS, + 'media_type': 'application/json', + 'headers': {}, + } + + def _build_cached_response(self, cached_response: str) -> JSONResponse: + """ + 根据缓存内容重建JSON响应对象 + + :param cached_response: 缓存中读取到的响应字符串 + :return: 重建后的JSON响应对象 + """ + cached_payload = json.loads(cached_response) + cached_content = self._refresh_response_time(cached_payload['content']) + response = JSONResponse( + status_code=cached_payload['status_code'], + content=jsonable_encoder(cached_content), + headers=cached_payload.get('headers'), + media_type=cached_payload.get('media_type'), + ) + response.headers['X-Api-Cache'] = 'HIT' + + return response + + async def _cache_response(self, redis: aioredis.Redis, cache_key: str, result: Any) -> None: + """ + 将接口响应写入接口缓存 + + :param redis: Redis连接对象 + :param cache_key: 接口缓存键 + :param result: 原始接口返回结果 + :return: None + """ + serialized_response = self._serialize_response(result) + if serialized_response is None: + return + + await redis.set(cache_key, serialized_response, ex=self.expire_seconds) + logger.debug(f'接口缓存写入成功: {cache_key}') + + def _filter_response_headers(self, headers: dict[str, str]) -> dict[str, str]: + """ + 过滤不适合直接回放的响应头 + + :param headers: 原始响应头 + :return: 过滤后的响应头 + """ + excluded_headers = {'content-length', 'content-type', 'set-cookie'} + return {key: value for key, value in headers.items() if key.lower() not in excluded_headers} + + def _refresh_response_time(self, response_content: Any) -> Any: + """ + 刷新项目统一响应体中的time字段 + + :param response_content: JSON响应内容 + :return: 刷新time后的响应内容 + """ + if not isinstance(response_content, dict): + return response_content + + if {'code', 'msg', 'success', 'time'}.issubset(response_content): + refreshed_content = response_content.copy() + refreshed_content['time'] = datetime.now() + return refreshed_content + + return response_content + + +class ApiCacheEvict(_ApiCacheSupport): + """ + 接口缓存失效装饰器,用于在写操作成功后统一清理相关缓存 + """ + + def __init__( + self, + namespaces: Sequence[str] | None = None, + namespace_prefixes: Sequence[str] | None = None, + evict_response_codes: set[int] | None = None, + ) -> None: + """ + 初始化接口缓存失效装饰器 + + :param namespaces: 需要失效的缓存命名空间列表 + :param namespace_prefixes: 需要失效的缓存命名空间前缀列表 + :param evict_response_codes: 允许触发失效的业务响应码,默认为仅成功响应触发 + """ + self.namespaces = tuple(dict.fromkeys(namespaces or ())) + self.namespace_prefixes = tuple(dict.fromkeys(namespace_prefixes or ())) + if not self.namespaces and not self.namespace_prefixes: + raise ValueError('ApiCacheEvict至少需要指定namespaces或namespace_prefixes') + + self.evict_response_codes = ( + evict_response_codes if evict_response_codes is not None else {HttpStatusConstant.SUCCESS} + ) + + def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + """ + 为目标异步接口函数增加缓存失效能力 + + :param func: 需要在成功后触发缓存失效的异步接口函数 + :return: 包装后的异步接口函数 + """ + + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + result = await func(*args, **kwargs) + _, redis = ApiAnnotationUtil.resolve_request_redis( + func, '当前应用未初始化Redis连接,跳过接口缓存失效', *args, **kwargs + ) + if redis is None: + return result + + if self._should_evict(result): + await self._evict_cache(redis) + + return result + + return wrapper + + async def _evict_cache(self, redis: aioredis.Redis) -> None: + """ + 根据配置统一执行接口缓存失效 + + :param redis: Redis连接对象 + :return: None + """ + if self.namespaces: + await ApiCacheManager.clear_namespaces(redis, self.namespaces) + + if self.namespace_prefixes: + await ApiCacheManager.clear_namespace_prefixes(redis, self.namespace_prefixes) + + def _should_evict(self, result: Any) -> bool: + """ + 判断当前响应是否应触发缓存失效 + + :param result: 原始接口返回结果 + :return: 是否触发缓存失效 + """ + return self._get_matched_response_content(result, self.evict_response_codes) is not None diff --git a/shierfeng-fastapi-backend/common/annotation/log_annotation.py b/shierfeng-fastapi-backend/common/annotation/log_annotation.py new file mode 100644 index 0000000..4b11b59 --- /dev/null +++ b/shierfeng-fastapi-backend/common/annotation/log_annotation.py @@ -0,0 +1,1078 @@ +import inspect +import json +import time +from collections.abc import Awaitable, Callable +from copy import deepcopy +from datetime import datetime +from enum import Enum +from functools import wraps +from typing import Any, Literal, TypeVar + +import httpx +from async_lru import alru_cache +from fastapi import Request +from fastapi.responses import JSONResponse, ORJSONResponse, UJSONResponse +from starlette.status import HTTP_200_OK +from typing_extensions import ParamSpec +from user_agents import parse + +from common.context import RequestContext +from common.enums import BusinessType +from config.env import AppConfig +from exceptions.exception import LoginException, ServiceException, ServiceWarning +from module_admin.entity.vo.log_vo import LogininforModel, OperLogModel +from module_admin.service.log_service import LogQueueService +from utils.client_ip_util import ClientIPUtil +from utils.dependency_util import DependencyUtil +from utils.log_util import LogSanitizer, logger +from utils.response_util import ResponseUtil + +P = ParamSpec('P') +R = TypeVar('R') + + +class _LogFieldRoot(str, Enum): + """ + 日志字段路径根节点 + """ + + def field(self, *parts: str) -> str: + """ + 生成 include 字段路径 + + :param parts: 后续字段路径片段 + :return: 完整字段路径 + """ + return '.'.join((self.value, *parts)) if parts else self.value + + +class RequestLogFieldRoot(_LogFieldRoot): + """ + 请求日志字段路径支持的根节点 + """ + + PATH_PARAMS = 'path_params' + QUERY_PARAMS = 'query_params' + JSON_BODY = 'json_body' + FORM_DATA = 'form_data' + FILES = 'files' + RAW_BODY = 'raw_body' + + +class ResponseLogFieldRoot(_LogFieldRoot): + """ + 响应日志字段路径推荐的根节点 + """ + + CODE = 'code' + MSG = 'msg' + DATA = 'data' + ROWS = 'rows' + SUCCESS = 'success' + TIME = 'time' + + +class Log: + """ + 日志装饰器 + + 支持的日志模式: + - `full`: 记录脱敏后的完整载荷 + - `none`: 不记录对应方向的载荷 + - `summary`: 仅记录摘要信息,如顶层键、状态码、rows数量等 + - `include`: 仅记录白名单字段,适合高敏感接口 + - `exclude`: 记录完整载荷后排除少数字段,适合中敏感接口 + + 模式建议: + - 普通后台管理接口可使用`full` + - 包含大量字段但只关心结构时使用`summary` + - 包含密钥、密码、提示词、配置项等高敏感字段时优先使用`include` + - 字段较多但只需排除极少数字段时可使用`exclude` + - 完全无需保留请求体或响应体时使用`none` + + include / exclude 模式字段路径规则: + - 使用`.`分隔路径,如`json_body.modelCode`、`data.userId` + - 请求日志推荐根节点:`path_params`、`query_params`、`json_body`、`form_data`、`files`、`raw_body` + - 响应日志推荐根节点:`code`、`msg`、`data`、`rows`、`success`、`time` + - 字段名优先按原样精确匹配,若未命中会自动尝试 snake_case、camelCase、kebab-case 归一化匹配 + - 数组使用数字下标,如`rows.0.userName` + - 当前不支持通配符 + - 推荐优先使用`RequestLogFieldRoot.JSON_BODY.field(...)`和`ResponseLogFieldRoot.DATA.field(...)`构造字段路径 + + 示例: + - `request_include_fields=(RequestLogFieldRoot.JSON_BODY.field('model_code'),)` + - `response_include_fields=(ResponseLogFieldRoot.DATA.field('userName'),)` + - `request_exclude_fields=('json_body.api_key',)` + """ + + _REQUEST_INCLUDE_ROOTS = tuple(item.value for item in RequestLogFieldRoot) + _RESPONSE_INCLUDE_ROOTS = tuple(item.value for item in ResponseLogFieldRoot) + _MISSING = object() + _AMBIGUOUS = object() + + def __init__( + self, + title: str, + business_type: BusinessType, + log_type: Literal['login', 'operation'] | None = 'operation', + request_log_mode: Literal['full', 'none', 'summary', 'include', 'exclude'] = 'full', + response_log_mode: Literal['full', 'none', 'summary', 'include', 'exclude'] = 'full', + request_include_fields: tuple[str, ...] | None = None, + response_include_fields: tuple[str, ...] | None = None, + request_exclude_fields: tuple[str, ...] | None = None, + response_exclude_fields: tuple[str, ...] | None = None, + ) -> None: + """ + 日志装饰器 + + :param title: 当前日志装饰器装饰的模块标题 + :param business_type: 业务类型(OTHER其它 INSERT新增 UPDATE修改 DELETE删除 GRANT授权 EXPORT导出 IMPORT导入 FORCE强退 GENCODE生成代码 CLEAN清空数据) + :param log_type: 日志类型;`login`表示登录日志,仅落登录信息,`operation`表示操作日志,会落请求/响应摘要与操作人信息 + :param request_log_mode: 请求日志记录模式;`full`记录脱敏后的完整请求,`none`不记录请求体,`summary`记录请求摘要,`include`仅记录request_include_fields指定的字段,`exclude`记录完整请求后排除request_exclude_fields指定的字段 + :param response_log_mode: 响应日志记录模式;`full`记录脱敏后的完整响应,`none`不记录响应体,`summary`记录响应摘要,`include`仅记录response_include_fields指定的字段,`exclude`记录完整响应后排除response_exclude_fields指定的字段 + :param request_include_fields: 请求日志白名单字段路径,仅在request_log_mode='include'时生效;推荐从path_params/query_params/json_body/form_data/files/raw_body开始,字段名支持 snake_case 与 camelCase 自动兼容 + :param response_include_fields: 响应日志白名单字段路径,仅在response_log_mode='include'时生效;推荐从code/msg/data/rows/success/time开始,字段名支持 snake_case 与 camelCase 自动兼容 + :param request_exclude_fields: 请求日志排除字段路径,仅在request_log_mode='exclude'时生效;推荐从path_params/query_params/json_body/form_data/files/raw_body开始,字段名支持 snake_case 与 camelCase 自动兼容 + :param response_exclude_fields: 响应日志排除字段路径,仅在response_log_mode='exclude'时生效;推荐从code/msg/data/rows/success/time开始,字段名支持 snake_case 与 camelCase 自动兼容 + :return: + """ + self.title = title + self.business_type = business_type.value + self.log_type = log_type + self.request_log_mode = request_log_mode + self.response_log_mode = response_log_mode + self.request_include_fields = request_include_fields or () + self.response_include_fields = response_include_fields or () + self.request_exclude_fields = request_exclude_fields or () + self.response_exclude_fields = response_exclude_fields or () + self._oper_param_len = 2000 + self._json_result_len = 2000 + self._warned_field_path_warnings: set[str] = set() + self._validate_request_field_paths_strict() + self._warn_invalid_field_path_config() + + def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + start_time = time.perf_counter() + # 获取当前被装饰函数所在路径 + func_path = self._get_decorator_func_path(func) + # 获取上下文信息 + request_name_list = get_function_parameters_name_by_type(func, Request) + request = get_function_parameters_value_by_name(func, request_name_list[0], *args, **kwargs) + DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用Log装饰器') + request_method = request.method + user_agent = request.headers.get('User-Agent') or '' + # 获取操作类型 + operator_type = self._get_oper_type(user_agent) + # 获取请求的url + oper_url = request.url.path + # 获取请求ip + oper_ip = ClientIPUtil.get_client_ip(request) + # 获取请求ip归属区域 + oper_location = await self._get_oper_location(oper_ip) + # 获取请求参数 + oper_param_payload = LogSanitizer.sanitize_data(await self._get_request_params(request)) + oper_param = self._build_log_text( + oper_param_payload, + self.request_log_mode, + self.request_include_fields, + self.request_exclude_fields, + payload_kind='request', + ) + # 日志表请求参数字段长度最大为2000,因此在此处判断长度 + oper_param = self._limit_log_text(oper_param, self._oper_param_len, '请求参数过长') + + # 获取操作时间 + oper_time = datetime.now() + # 此处在登录之前向原始函数传递一些登录信息,用于监测在线用户的相关信息 + login_log = self._get_login_log(user_agent, oper_ip, oper_location, oper_time, kwargs) + try: + # 调用原始函数 + result = await func(*args, **kwargs) + except (LoginException, ServiceWarning) as e: + logger.warning(e.message) + result = ResponseUtil.failure(data=e.data, msg=e.message) + except ServiceException as e: + logger.error(e.message) + result = ResponseUtil.error(data=e.data, msg=e.message) + except Exception as e: + logger.exception(e) + result = ResponseUtil.error(msg=str(e)) + # 获取请求耗时 + cost_time = float(time.perf_counter() - start_time) * 1000 + # 判断请求是否来自api文档 + request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) + # 根据响应结果的类型使用不同的方法获取响应结果参数 + sanitized_result_dict = LogSanitizer.sanitize_data( + self._get_result_dict(result, request_from_swagger, request_from_redoc) + ) + json_result = self._build_log_text( + sanitized_result_dict, + self.response_log_mode, + self.response_include_fields, + self.response_exclude_fields, + payload_kind='response', + ) + # 日志表返回参数字段长度最大为2000,因此在此处判断长度 + json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') + # 根据响应结果获取响应状态及异常信息 + status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) + # 根据日志类型向对应的日志表插入数据 + if self.log_type == 'login': + # 登录请求来自于api文档时不记录登录日志,其余情况则记录 + if request_from_swagger or request_from_redoc: + pass + else: + user = kwargs.get('form_data') + login_log.update( + { + 'loginTime': oper_time, + 'userName': user.username, + 'status': str(status), + 'msg': sanitized_result_dict.get('msg') or '', + } + ) + + await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) + else: + current_user = RequestContext.get_current_user() + oper_name = current_user.user.user_name + dept_name = current_user.user.dept.dept_name if current_user.user.dept else None + operation_log = OperLogModel( + title=self.title, + businessType=self.business_type, + method=func_path, + requestMethod=request_method, + operatorType=operator_type, + operName=oper_name, + deptName=dept_name, + operUrl=oper_url, + operIp=oper_ip, + operLocation=oper_location, + operParam=oper_param, + jsonResult=json_result, + status=status, + errorMsg=error_msg, + operTime=oper_time, + costTime=int(cost_time), + ) + await LogQueueService.enqueue_operation_log(request, operation_log, func_path) + + return result + + return wrapper + + def _get_decorator_func_path(self, func: Callable) -> str: + """ + 获取被装饰函数所在路径 + + :param func: 被装饰函数 + :return: 被装饰函数所在路径 + """ + # 获取被装饰函数所在的模块 + module = inspect.getmodule(func) + # 获取完整模块路径 + module_path = module.__name__ if module else '' + # 获取当前被装饰函数所在路径 + func_path = f'{module_path}.{func.__name__}()' + + return func_path + + @staticmethod + def _limit_log_text(log_text: str, max_length: int, overflow_text: str) -> str: + """ + 限制日志文本长度,避免超过日志表字段长度 + + :param log_text: 原始日志文本 + :param max_length: 最大长度 + :param overflow_text: 超长时替代文本 + :return: 处理后的日志文本 + """ + return overflow_text if len(log_text) > max_length else log_text + + def _get_oper_type(self, user_agent: Any) -> int: + """ + 获取操作类型 + + :param user_agent: 用户代理字符串 + :return: 操作类型 + """ + user_agent_text = user_agent or '' + operator_type = 0 + if 'Windows' in user_agent_text or 'Macintosh' in user_agent_text or 'Linux' in user_agent_text: + operator_type = 1 + if 'Mobile' in user_agent_text or 'Android' in user_agent_text or 'iPhone' in user_agent_text: + operator_type = 2 + + return operator_type + + async def _get_oper_location(self, oper_ip: str) -> str: + """ + 获取请求IP归属区域 + + :param oper_ip: 请求IP + :return: 请求IP归属区域 + """ + oper_location = '内网IP' + if AppConfig.app_ip_location_query: + oper_location = await get_ip_location(oper_ip) + + return oper_location + + async def _get_request_params(self, request: Request) -> dict[str, Any]: + """ + 获取请求参数 + + :param request: Request对象 + :return: 结构化请求参数 + """ + params = {} + + # 路径和查询参数 + path_params = dict(request.path_params) + query_params = dict(request.query_params) + params.update({k: v for k, v in {'path_params': path_params, 'query_params': query_params}.items() if v}) + + # 请求体处理 + content_type = request.headers.get('Content-Type', '') + + # JSON请求 + if 'application/json' in content_type: + params.update(await self._get_json_request_params(request, content_type)) + + # 表单数据 + elif 'multipart/form-data' in content_type or 'application/x-www-form-urlencoded' in content_type: + params.update(await self._get_form_request_params(request, content_type)) + + # 其他文本请求 + elif 'application/octet-stream' not in content_type: + params.update(await self._get_raw_request_params(request)) + + return params + + async def _get_json_request_params(self, request: Request, content_type: str) -> dict[str, Any]: + """ + 获取 JSON 请求参数,解析失败时自动降级为原始文本 + + :param request: Request对象 + :param content_type: 请求头中的 content-type + :return: 结构化请求参数 + """ + params = {} + try: + json_body = await request.json() + except Exception as exc: + logger.warning( + 'Log装饰器请求体解析失败,已降级为raw_body记录,path={}, content_type={}, error_type={}', + request.url.path, + content_type, + type(exc).__name__, + ) + params.update(await self._get_raw_request_params(request)) + else: + if json_body: + params['json_body'] = json_body + return params + + async def _get_form_request_params(self, request: Request, content_type: str) -> dict[str, Any]: + """ + 获取表单请求参数 + + :param request: Request对象 + :param content_type: 请求头中的 content-type + :return: 结构化请求参数 + """ + params = {} + form_data = await request.form() + if not form_data: + return params + + # 过滤掉文件对象,只保留普通表单字段 + form_dict = {key: value for key, value in form_data.items() if not hasattr(value, 'filename')} + if form_dict: + params['form_data'] = form_dict + + # 仅在multipart时尝试处理文件 + if 'multipart/form-data' not in content_type: + return params + + file_info = {} + for key, value in form_data.items(): + if hasattr(value, 'filename'): + file_info[key] = { + 'filename': value.filename, + 'content_type': value.content_type, + 'size': value.size, + 'headers': dict(value.headers), + } + if file_info: + params['files'] = file_info + return params + + async def _get_raw_request_params(self, request: Request) -> dict[str, Any]: + """ + 获取原始文本请求参数 + + :param request: Request对象 + :return: 原始文本请求参数 + """ + body = await request.body() + if not body: + return {} + return {'raw_body': self._decode_request_body(body)} + + @staticmethod + def _decode_request_body(body: bytes) -> str: + """ + 安全解码请求体,避免日志采集影响主流程 + + :param body: 原始请求体字节 + :return: 解码后的请求体文本 + """ + return body.decode('utf-8', errors='replace') + + def _build_log_text( + self, + payload: Any, + mode: Literal['full', 'none', 'summary', 'include', 'exclude'], + include_fields: tuple[str, ...], + exclude_fields: tuple[str, ...], + payload_kind: Literal['request', 'response'], + ) -> str: + """ + 根据日志策略构建日志文本 + + :param payload: 已完成脱敏的日志载荷 + :param mode: 日志记录模式 + :param include_fields: 白名单字段路径 + :param exclude_fields: 排除字段路径 + :param payload_kind: 载荷类型 + :return: 日志文本 + """ + if mode == 'none' or not payload: + return '' + if mode == 'summary': + log_payload = self._build_summary_payload(payload, payload_kind) + elif mode == 'include': + log_payload = self._extract_include_fields(payload, include_fields, payload_kind) + elif mode == 'exclude': + log_payload = self._exclude_fields(payload, exclude_fields, payload_kind) + else: + log_payload = payload + return json.dumps(log_payload, ensure_ascii=False, indent=2) if log_payload else '' + + def _build_summary_payload(self, payload: Any, payload_kind: Literal['request', 'response']) -> dict[str, Any]: + """ + 构建摘要日志载荷 + + :param payload: 原始载荷 + :param payload_kind: 载荷类型 + :return: 摘要日志载荷 + """ + summary_payload: dict[str, Any] = { + 'mode': 'summary', + 'kind': payload_kind, + } + if not isinstance(payload, dict): + summary_payload['type'] = type(payload).__name__ + return summary_payload + summary_payload['keys'] = list(payload.keys()) + if payload_kind == 'request': + summary_payload.update( + { + 'path_param_keys': self._get_mapping_keys(payload.get('path_params')), + 'query_param_keys': self._get_mapping_keys(payload.get('query_params')), + 'json_body_keys': self._get_mapping_keys(payload.get('json_body')), + 'form_data_keys': self._get_mapping_keys(payload.get('form_data')), + 'file_fields': self._get_mapping_keys(payload.get('files')), + 'raw_body_length': len(payload.get('raw_body', '')) if payload.get('raw_body') else 0, + } + ) + else: + summary_payload.update( + { + 'code': payload.get('code'), + 'msg': self._get_result_message(payload), + 'data_keys': self._get_mapping_keys(payload.get('data')), + 'rows_count': len(payload.get('rows')) if isinstance(payload.get('rows'), list) else 0, + } + ) + return summary_payload + + def _extract_include_fields( + self, + payload: Any, + include_fields: tuple[str, ...], + payload_kind: Literal['request', 'response'], + ) -> dict[str, Any]: + """ + 提取白名单字段日志载荷 + + :param payload: 原始载荷 + :param include_fields: 白名单字段路径 + :param payload_kind: 载荷类型 + :return: 提取后的日志载荷 + """ + selected_fields = {} + for field_path in include_fields: + field_value = self._get_field_value_by_path(payload, field_path) + if field_value is not self._MISSING: + selected_fields[field_path] = field_value + else: + self._warn_missing_field_path(payload, field_path, payload_kind, strategy='include') + return { + 'mode': 'include', + 'selected': selected_fields, + } + + def _exclude_fields( + self, + payload: Any, + exclude_fields: tuple[str, ...], + payload_kind: Literal['request', 'response'], + ) -> Any: + """ + 排除指定字段后返回日志载荷 + + :param payload: 原始载荷 + :param exclude_fields: 排除字段路径 + :param payload_kind: 载荷类型 + :return: 排除后的日志载荷 + """ + if not exclude_fields or not isinstance(payload, (dict, list)): + return payload + filtered_payload = deepcopy(payload) + for field_path in self._sort_field_paths_for_exclude(exclude_fields): + if not self._remove_field_by_path(filtered_payload, field_path): + self._warn_missing_field_path(payload, field_path, payload_kind, strategy='exclude') + return filtered_payload + + def _warn_missing_field_path( + self, + payload: Any, + field_path: str, + payload_kind: Literal['request', 'response'], + strategy: Literal['include', 'exclude'], + ) -> None: + """ + 记录未命中的字段路径告警,同一路径仅提示一次 + + :param payload: 原始载荷 + :param field_path: 字段路径 + :param payload_kind: 载荷类型 + :param strategy: 当前字段路径策略 + :return: None + """ + warning_key = f'{strategy}:{payload_kind}:{field_path}' + if warning_key in self._warned_field_path_warnings: + return + self._warned_field_path_warnings.add(warning_key) + kind_text = '请求' if payload_kind == 'request' else '响应' + reason = self._describe_missing_field_path(payload, field_path) + if strategy == 'include': + logger.warning( + f'Log装饰器字段白名单未命中:{kind_text}日志字段路径`{field_path}`已忽略。' + f'{reason}若该字段为可选字段,可忽略此提示。' + ) + else: + logger.warning( + f'Log装饰器字段排除路径未命中:{kind_text}日志字段路径`{field_path}`未生效。' + f'{reason}若该字段为可选字段,可忽略此提示。' + ) + + def _describe_missing_field_path(self, payload: Any, field_path: str) -> str: + """ + 描述字段路径未命中的原因 + + :param payload: 原始载荷 + :param field_path: 字段路径 + :return: 原因描述 + """ + current_value = payload + traversed_parts: list[str] = [] + for part in field_path.split('.'): + traversed_path = '.'.join(traversed_parts) or '' + if isinstance(current_value, dict): + mapping_value = self._get_mapping_value_by_part(current_value, part) + if mapping_value is self._MISSING: + available_keys = ', '.join(map(str, current_value.keys())) if current_value else '无' + return f'在`{traversed_path}`下未找到字段`{part}`,可用字段:{available_keys}。' + if mapping_value is self._AMBIGUOUS: + ambiguous_keys = ', '.join( + str(key) + for key in current_value + if self._normalize_include_key(str(key)) == self._normalize_include_key(part) + ) + return ( + f'在`{traversed_path}`下字段`{part}`存在命名冲突,' + f'可匹配字段:{ambiguous_keys};请改用精确字段名。' + ) + current_value = mapping_value + elif isinstance(current_value, list): + if not part.isdigit(): + return f'在`{traversed_path}`处当前值为列表,字段片段`{part}`应为数字下标。' + current_index = int(part) + if current_index >= len(current_value): + return f'在`{traversed_path}`处列表长度为{len(current_value)},下标`{part}`越界。' + current_value = current_value[current_index] + else: + current_type = type(current_value).__name__ if current_value is not None else 'None' + return f'在`{traversed_path}`处当前值类型为`{current_type}`,无法继续匹配后续路径。' + traversed_parts.append(part) + return '' + + def _warn_invalid_field_path_config(self) -> None: + """ + 记录字段路径配置告警,帮助开发者发现路径误配 + + :return: None + """ + warnings = [ + *self._collect_field_path_warnings( + mode=self.request_log_mode, + include_fields=self.request_include_fields, + exclude_fields=self.request_exclude_fields, + payload_kind='request', + ), + *self._collect_field_path_warnings( + mode=self.response_log_mode, + include_fields=self.response_include_fields, + exclude_fields=self.response_exclude_fields, + payload_kind='response', + ), + ] + for warning in warnings: + logger.warning(f'Log装饰器字段路径配置提示:{warning}') + + def _validate_request_field_paths_strict(self) -> None: + """ + 严格校验请求日志字段路径根节点 + + :return: None + """ + field_paths = () + if self.request_log_mode == 'include': + field_paths = self.request_include_fields + elif self.request_log_mode == 'exclude': + field_paths = self.request_exclude_fields + for field_path in field_paths: + if not field_path: + continue + root_part = field_path.split('.', 1)[0] + if self._resolve_include_root(root_part, self._REQUEST_INCLUDE_ROOTS) is None: + raise ValueError( + f'请求日志字段路径`{field_path}`使用了不支持的根节点`{root_part}`;' + f'仅支持:{", ".join(self._REQUEST_INCLUDE_ROOTS)}' + ) + + def _collect_field_path_warnings( + self, + mode: Literal['full', 'none', 'summary', 'include', 'exclude'], + include_fields: tuple[str, ...], + exclude_fields: tuple[str, ...], + payload_kind: Literal['request', 'response'], + ) -> list[str]: + """ + 收集字段路径配置告警信息 + + :param mode: 日志记录模式 + :param include_fields: 白名单字段路径列表 + :param exclude_fields: 排除字段路径列表 + :param payload_kind: 载荷类型 + :return: 告警信息列表 + """ + warnings = [] + kind_text = '请求' if payload_kind == 'request' else '响应' + recommended_roots = self._REQUEST_INCLUDE_ROOTS if payload_kind == 'request' else self._RESPONSE_INCLUDE_ROOTS + if mode == 'include' and not include_fields: + warnings.append( + f'{kind_text}日志已启用include模式,但未配置白名单字段;推荐根节点:{", ".join(recommended_roots)}' + ) + if mode == 'exclude' and not exclude_fields: + warnings.append( + f'{kind_text}日志已启用exclude模式,但未配置排除字段;推荐根节点:{", ".join(recommended_roots)}' + ) + if mode != 'include' and include_fields: + warnings.append(f'{kind_text}日志配置了白名单字段,但当前模式为{mode},这些字段不会生效') + if mode != 'exclude' and exclude_fields: + warnings.append(f'{kind_text}日志配置了排除字段,但当前模式为{mode},这些字段不会生效') + for field_path in include_fields: + path_warning = self._validate_field_path(field_path, payload_kind, strategy='include') + if path_warning: + warnings.append(path_warning) + for field_path in exclude_fields: + path_warning = self._validate_field_path(field_path, payload_kind, strategy='exclude') + if path_warning: + warnings.append(path_warning) + + return warnings + + def _validate_field_path( + self, + field_path: str, + payload_kind: Literal['request', 'response'], + strategy: Literal['include', 'exclude'], + ) -> str | None: + """ + 校验单个字段路径 + + :param field_path: 字段路径 + :param payload_kind: 载荷类型 + :param strategy: 当前字段路径策略 + :return: 告警信息 + """ + kind_text = '请求' if payload_kind == 'request' else '响应' + recommended_roots = self._REQUEST_INCLUDE_ROOTS if payload_kind == 'request' else self._RESPONSE_INCLUDE_ROOTS + strategy_text = '白名单字段路径' if strategy == 'include' else '排除字段路径' + if not field_path: + return f'{kind_text}日志存在空{strategy_text};推荐根节点:{", ".join(recommended_roots)}' + parts = field_path.split('.') + if any(not part for part in parts): + return f'{kind_text}日志{strategy_text}`{field_path}`格式不合法,请使用`.`分隔的完整路径' + canonical_root = self._resolve_include_root(parts[0], recommended_roots) + if canonical_root is None: + return ( + f'{kind_text}日志{strategy_text}`{field_path}`未使用推荐根节点`{parts[0]}`;' + f'推荐根节点:{", ".join(recommended_roots)}' + ) + return None + + def _get_field_value_by_path(self, payload: Any, field_path: str) -> Any: + """ + 通过字段路径获取字段值 + + :param payload: 原始载荷 + :param field_path: 字段路径 + :return: 字段值 + """ + current_value = payload + for part in field_path.split('.'): + if isinstance(current_value, dict): + mapping_value = self._get_mapping_value_by_part(current_value, part) + if mapping_value is self._MISSING or mapping_value is self._AMBIGUOUS: + return self._MISSING + current_value = mapping_value + elif isinstance(current_value, list) and part.isdigit(): + current_index = int(part) + if current_index >= len(current_value): + return self._MISSING + current_value = current_value[current_index] + else: + return self._MISSING + return current_value + + def _get_mapping_value_by_part(self, payload: dict[str, Any], part: str) -> Any: + """ + 从字典中按字段片段获取值,支持 snake_case / camelCase / kebab-case 自动兼容 + + :param payload: 当前字典载荷 + :param part: 当前路径片段 + :return: 字段值 + """ + if part in payload: + return payload[part] + normalized_part = self._normalize_include_key(part) + matched_keys = [key for key in payload if self._normalize_include_key(str(key)) == normalized_part] + if len(matched_keys) == 1: + return payload[matched_keys[0]] + if len(matched_keys) > 1: + return self._AMBIGUOUS + return self._MISSING + + @staticmethod + def _sort_field_paths_for_exclude(field_paths: tuple[str, ...]) -> list[str]: + """ + 对 exclude 字段路径排序,优先处理更深层路径和更大的列表下标,避免列表删除时发生索引位移 + + :param field_paths: 原始字段路径 + :return: 排序后的字段路径列表 + """ + return sorted(field_paths, key=Log._build_exclude_sort_key, reverse=True) + + @staticmethod + def _build_exclude_sort_key(field_path: str) -> tuple[int, tuple[tuple[int, int | str], ...]]: + """ + 构建 exclude 字段路径排序键 + + :param field_path: 字段路径 + :return: 排序键 + """ + parts = field_path.split('.') + normalized_parts: tuple[tuple[int, int | str], ...] = tuple( + (1, int(part)) if part.isdigit() else (0, part) for part in parts + ) + return len(parts), normalized_parts + + def _remove_field_by_path(self, payload: Any, field_path: str) -> bool: + """ + 按路径移除字段 + + :param payload: 原始载荷 + :param field_path: 字段路径 + :return: 是否移除成功 + """ + if not field_path: + return False + current_value = payload + parts = field_path.split('.') + for part in parts[:-1]: + if isinstance(current_value, dict): + mapping_value = self._get_mapping_value_by_part(current_value, part) + if mapping_value is self._MISSING or mapping_value is self._AMBIGUOUS: + return False + current_value = mapping_value + elif isinstance(current_value, list) and part.isdigit(): + current_index = int(part) + if current_index >= len(current_value): + return False + current_value = current_value[current_index] + else: + return False + + target_part = parts[-1] + if isinstance(current_value, dict): + resolved_key = self._resolve_mapping_key_by_part(current_value, target_part) + if resolved_key is self._MISSING or resolved_key is self._AMBIGUOUS: + return False + del current_value[resolved_key] + return True + if isinstance(current_value, list) and target_part.isdigit(): + current_index = int(target_part) + if current_index >= len(current_value): + return False + current_value.pop(current_index) + return True + return False + + def _resolve_mapping_key_by_part(self, payload: dict[str, Any], part: str) -> str | object: + """ + 解析字段片段在字典中的真实键名 + + :param payload: 当前字典载荷 + :param part: 当前路径片段 + :return: 真实键名或哨兵值 + """ + if part in payload: + return part + normalized_part = self._normalize_include_key(part) + matched_keys = [key for key in payload if self._normalize_include_key(str(key)) == normalized_part] + if len(matched_keys) == 1: + return matched_keys[0] + if len(matched_keys) > 1: + return self._AMBIGUOUS + return self._MISSING + + @staticmethod + def _resolve_include_root( + root: RequestLogFieldRoot | ResponseLogFieldRoot | str, candidates: tuple[str, ...] + ) -> str | None: + """ + 解析 include 根节点到标准写法 + + :param root: 原始根节点 + :param candidates: 候选根节点 + :return: 标准根节点 + """ + normalized_root = Log._normalize_include_key(str(root)) + for candidate in candidates: + if Log._normalize_include_key(candidate) == normalized_root: + return candidate + return None + + @staticmethod + def _normalize_include_key(field_name: str) -> str: + """ + 标准化 include 字段名 + + :param field_name: 原始字段名 + :return: 标准化后的字段名 + """ + return ''.join(char.lower() for char in field_name if char.isalnum()) + + @staticmethod + def _get_mapping_keys(payload: Any) -> list[str]: + """ + 获取字典载荷的键列表 + + :param payload: 原始载荷 + :return: 键列表 + """ + if isinstance(payload, dict): + return list(payload.keys()) + return [] + + def _get_login_log( + self, user_agent: Any, oper_ip: str, oper_location: str, oper_time: datetime, origin_kwargs: dict + ) -> dict: + """ + 获取登录日志信息 + + :param user_agent: 用户代理字符串 + :param oper_ip: 操作ip + :param oper_location: 操作区域 + :param oper_time: 操作时间 + :param origin_kwargs: 原始函数参数 + :return: 登录日志信息 + """ + login_log = {} + if self.log_type == 'login': + user_agent_info = parse(user_agent or '') + browser = f'{user_agent_info.browser.family}' + system_os = f'{user_agent_info.os.family}' + if user_agent_info.browser.version != (): + browser += f' {user_agent_info.browser.version[0]}' + if user_agent_info.os.version != (): + system_os += f' {user_agent_info.os.version[0]}' + login_log = { + 'ipaddr': oper_ip, + 'loginLocation': oper_location, + 'browser': browser, + 'os': system_os, + 'loginTime': oper_time.strftime('%Y-%m-%d %H:%M:%S'), + } + self._set_login_data(login_log, origin_kwargs) + + return login_log + + def _set_login_data(self, login_log: dict, origin_kwargs: dict) -> None: + """ + 设置登录日志数据 + + :param login_log: 登录日志信息 + :param origin_kwargs: 原始函数参数 + :return: None + """ + if 'form_data' in origin_kwargs: + origin_kwargs['form_data'].login_info = login_log + + def _get_status_and_error_msg(self, result_dict: dict) -> tuple[int, str]: + """ + 获取操作状态和错误信息 + + :param result_dict: 操作结果字典 + :return: 操作状态和错误信息元组 + """ + status = 1 + error_msg = '' + if result_dict.get('code') == HTTP_200_OK: + status = 0 + else: + error_msg = self._get_result_message(result_dict) + + return status, error_msg + + @staticmethod + def _get_result_message(result_dict: dict[str, Any]) -> Any: + """ + 获取响应结果中的消息字段,兼容 msg / message 两种写法 + + :param result_dict: 操作结果字典 + :return: 消息内容 + """ + return result_dict.get('msg') if result_dict.get('msg') is not None else result_dict.get('message') + + def _is_request_from_swagger_or_redoc(self, request: Request) -> tuple[bool, bool]: + """ + 判断请求是否来自swagger或redoc + + :param request: Request对象 + :return: 是否来自swagger请求和是否来自redoc请求元组 + """ + request_from_swagger = ( + request.headers.get('referer').endswith('docs') if request.headers.get('referer') else False + ) + request_from_redoc = ( + request.headers.get('referer').endswith('redoc') if request.headers.get('referer') else False + ) + + return request_from_swagger, request_from_redoc + + def _get_result_dict(self, result: Any, request_from_swagger: bool, request_from_redoc: bool) -> dict: + """ + 获取操作结果字典 + + :param result: 操作结果 + :param request_from_swagger: 是否来自swagger请求 + :param request_from_redoc: 是否来自redoc请求 + :return: 操作结果字典 + """ + if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)): + result_dict = json.loads(str(result.body, 'utf-8')) + elif request_from_swagger or request_from_redoc: + result_dict = {} + elif result.status_code == HTTP_200_OK: + result_dict = {'code': result.status_code, 'message': '获取成功'} + else: + result_dict = {'code': result.status_code, 'message': '获取失败'} + + return result_dict + + +@alru_cache() +async def get_ip_location(oper_ip: str) -> str: + """ + 查询ip归属区域 + + :param oper_ip: 需要查询的ip + :return: ip归属区域 + """ + oper_location = '内网IP' + try: + if oper_ip not in ['127.0.0.1', 'localhost']: + oper_location = '未知' + async with httpx.AsyncClient() as client: + ip_result = await client.get(f'https://qifu-api.baidubce.com/ip/geo/v1/district?ip={oper_ip}') + if ip_result.status_code == HTTP_200_OK: + prov = ip_result.json().get('data', {}).get('prov') + city = ip_result.json().get('data', {}).get('city') + if prov or city: + oper_location = f'{prov}-{city}' + except Exception as e: + oper_location = '未知' + print(e) + return oper_location + + +def get_function_parameters_name_by_type(func: Callable, param_type: Any) -> list: + """ + 获取函数指定类型的参数名称 + + :param func: 函数 + :param arg_type: 参数类型 + :return: 函数指定类型的参数名称 + """ + # 获取函数的参数信息 + parameters = inspect.signature(func).parameters + # 找到指定类型的参数名称 + parameters_name_list = [] + # 遍历所有参数 + for name, param in parameters.items(): + # 处理参数注解 + annotation = param.annotation + # 检查参数类型是否匹配 + # 1. 直接匹配 + # 2. 检查是否为Annotated类型(通过类型名称判断) + if annotation == param_type or ( + hasattr(annotation, '__class__') + and annotation.__class__.__name__ == '_AnnotatedAlias' + and annotation.__origin__ == param_type + ): + parameters_name_list.append(name) + return parameters_name_list + + +def get_function_parameters_value_by_name(func: Callable, name: str, *args, **kwargs) -> Any: + """ + 获取函数指定参数的值 + + :param func: 函数 + :param name: 参数名 + :return: 参数值 + """ + # 获取参数值 + bound_parameters = inspect.signature(func).bind(*args, **kwargs) + bound_parameters.apply_defaults() + parameters_value = bound_parameters.arguments.get(name) + + return parameters_value diff --git a/shierfeng-fastapi-backend/common/annotation/pydantic_annotation.py b/shierfeng-fastapi-backend/common/annotation/pydantic_annotation.py new file mode 100644 index 0000000..9dde679 --- /dev/null +++ b/shierfeng-fastapi-backend/common/annotation/pydantic_annotation.py @@ -0,0 +1,86 @@ +import inspect +from typing import TYPE_CHECKING, TypeVar + +from fastapi import Form, Query +from pydantic import BaseModel + +if TYPE_CHECKING: + from pydantic.fields import FieldInfo + +BaseModelVar = TypeVar('BaseModelVar', bound=BaseModel) + + +def as_query(cls: type[BaseModelVar]) -> type[BaseModelVar]: + """ + pydantic模型查询参数装饰器,将pydantic模型用于接收查询参数 + """ + new_parameters = [] + + for model_field in cls.model_fields.values(): + model_field: FieldInfo + + if not model_field.is_required(): + new_parameters.append( + inspect.Parameter( + model_field.alias, + inspect.Parameter.POSITIONAL_ONLY, + default=Query(default=model_field.default, description=model_field.description), + annotation=model_field.annotation, + ) + ) + else: + new_parameters.append( + inspect.Parameter( + model_field.alias, + inspect.Parameter.POSITIONAL_ONLY, + default=Query(..., description=model_field.description), + annotation=model_field.annotation, + ) + ) + + async def as_query_func(**data) -> type[BaseModelVar]: + return cls(**data) + + sig = inspect.signature(as_query_func) + sig = sig.replace(parameters=new_parameters) + as_query_func.__signature__ = sig + cls.as_query = as_query_func + return cls + + +def as_form(cls: type[BaseModelVar]) -> type[BaseModelVar]: + """ + pydantic模型表单参数装饰器,将pydantic模型用于接收表单参数 + """ + new_parameters = [] + + for model_field in cls.model_fields.values(): + model_field: FieldInfo + + if not model_field.is_required(): + new_parameters.append( + inspect.Parameter( + model_field.alias, + inspect.Parameter.POSITIONAL_ONLY, + default=Form(default=model_field.default, description=model_field.description), + annotation=model_field.annotation, + ) + ) + else: + new_parameters.append( + inspect.Parameter( + model_field.alias, + inspect.Parameter.POSITIONAL_ONLY, + default=Form(..., description=model_field.description), + annotation=model_field.annotation, + ) + ) + + async def as_form_func(**data) -> type[BaseModelVar]: + return cls(**data) + + sig = inspect.signature(as_form_func) + sig = sig.replace(parameters=new_parameters) + as_form_func.__signature__ = sig + cls.as_form = as_form_func + return cls diff --git a/shierfeng-fastapi-backend/common/annotation/rate_limit_annotation.py b/shierfeng-fastapi-backend/common/annotation/rate_limit_annotation.py new file mode 100644 index 0000000..1d7e9b8 --- /dev/null +++ b/shierfeng-fastapi-backend/common/annotation/rate_limit_annotation.py @@ -0,0 +1,720 @@ +import hashlib +import json +import time +import uuid +from collections import deque +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from functools import wraps +from typing import Literal, TypeVar + +from fastapi import Request +from redis import asyncio as aioredis +from typing_extensions import ParamSpec + +from common.context import RequestContext +from common.enums import HttpMethod, RedisInitKeyConfig +from exceptions.exception import LoginException +from module_admin.entity.vo.user_vo import CurrentUserModel +from utils.api_annotation_util import ApiAnnotationUtil +from utils.api_response_header_util import ApiResponseHeaderUtil +from utils.client_ip_util import ClientIPUtil +from utils.log_util import logger +from utils.response_util import ResponseUtil + +P = ParamSpec('P') +R = TypeVar('R') +RateLimitScope = Literal['ip', 'user', 'user_or_ip'] +RateLimitAlgorithm = Literal['fixed_window', 'sliding_window'] +RateLimitFailStrategy = Literal['open', 'closed', 'local_fallback'] + + +@dataclass(frozen=True) +class ApiRateLimitPresetConfig: + """ + 接口限流预设配置 + """ + + name: str + limit: int + window_seconds: int + scope: RateLimitScope = 'ip' + algorithm: RateLimitAlgorithm = 'fixed_window' + fail_strategy: RateLimitFailStrategy = 'open' + methods: tuple[HttpMethod, ...] | None = None + message: str = '请求过于频繁,请稍后再试' + + +@dataclass(frozen=True) +class ApiRateLimitBypassConfig: + """ + 接口限流角色豁免配置 + """ + + roles: tuple[str, ...] + + +class ApiRateLimitPreset: + """ + 接口限流预设 + + ANON_AUTH_LOGIN: 匿名登录类接口限流预设 + ANON_AUTH_REGISTER: 匿名注册类接口限流预设 + ANON_AUTH_CAPTCHA: 匿名验证码类接口限流预设 + ANON_PUBLIC_METADATA: 匿名公开元数据接口限流预设 + COMMON_UPLOAD: 通用上传接口限流预设 + USER_INTERACTIVE_HIGH_FREQ: 用户高频交互接口限流预设 + USER_RESOURCE_EXECUTION: 用户执行类接口限流预设 + USER_COMMON_MUTATION: 用户普通写操作接口限流预设 + USER_SECURITY_MUTATION: 用户安全敏感操作接口限流预设 + USER_DESTRUCTIVE_MUTATION: 用户破坏性操作接口限流预设 + USER_RESOURCE_EXPORT: 用户导出类接口限流预设 + USER_RESOURCE_IMPORT: 用户导入类接口限流预设 + USER_RESOURCE_UPLOAD: 用户上传类接口限流预设 + USER_RESOURCE_GENERATE: 用户生成类接口限流预设 + USER_RESOURCE_DOWNLOAD: 用户下载类接口限流预设 + USER_RESOURCE_SYNC: 用户同步类接口限流预设 + """ + + ANON_AUTH_LOGIN = ApiRateLimitPresetConfig( + name='ANON_AUTH_LOGIN', + limit=12, + window_seconds=60, + algorithm='sliding_window', + fail_strategy='local_fallback', + ) + ANON_AUTH_REGISTER = ApiRateLimitPresetConfig( + name='ANON_AUTH_REGISTER', + limit=6, + window_seconds=120, + algorithm='sliding_window', + fail_strategy='local_fallback', + ) + ANON_AUTH_CAPTCHA = ApiRateLimitPresetConfig( + name='ANON_AUTH_CAPTCHA', + limit=36, + window_seconds=60, + algorithm='sliding_window', + fail_strategy='local_fallback', + ) + ANON_PUBLIC_METADATA = ApiRateLimitPresetConfig( + name='ANON_PUBLIC_METADATA', + limit=30, + window_seconds=60, + algorithm='sliding_window', + fail_strategy='local_fallback', + ) + COMMON_UPLOAD = ApiRateLimitPresetConfig( + name='COMMON_UPLOAD', + limit=24, + window_seconds=60, + scope='user_or_ip', + ) + + USER_INTERACTIVE_HIGH_FREQ = ApiRateLimitPresetConfig( + name='USER_INTERACTIVE_HIGH_FREQ', + limit=40, + window_seconds=60, + scope='user', + ) + USER_RESOURCE_EXECUTION = ApiRateLimitPresetConfig( + name='USER_RESOURCE_EXECUTION', + limit=12, + window_seconds=60, + scope='user', + ) + USER_COMMON_MUTATION = ApiRateLimitPresetConfig( + name='USER_COMMON_MUTATION', + limit=24, + window_seconds=120, + scope='user', + ) + USER_SECURITY_MUTATION = ApiRateLimitPresetConfig( + name='USER_SECURITY_MUTATION', + limit=12, + window_seconds=120, + scope='user', + ) + USER_DESTRUCTIVE_MUTATION = ApiRateLimitPresetConfig( + name='USER_DESTRUCTIVE_MUTATION', + limit=6, + window_seconds=120, + scope='user', + ) + USER_RESOURCE_EXPORT = ApiRateLimitPresetConfig( + name='USER_RESOURCE_EXPORT', + limit=15, + window_seconds=120, + scope='user', + ) + USER_RESOURCE_IMPORT = ApiRateLimitPresetConfig( + name='USER_RESOURCE_IMPORT', + limit=8, + window_seconds=120, + scope='user', + ) + USER_RESOURCE_UPLOAD = ApiRateLimitPresetConfig( + name='USER_RESOURCE_UPLOAD', + limit=12, + window_seconds=120, + scope='user', + ) + USER_RESOURCE_GENERATE = ApiRateLimitPresetConfig( + name='USER_RESOURCE_GENERATE', + limit=8, + window_seconds=120, + scope='user', + ) + USER_RESOURCE_DOWNLOAD = ApiRateLimitPresetConfig( + name='USER_RESOURCE_DOWNLOAD', + limit=12, + window_seconds=60, + scope='user', + ) + USER_RESOURCE_SYNC = ApiRateLimitPresetConfig( + name='USER_RESOURCE_SYNC', + limit=15, + window_seconds=120, + scope='user', + ) + + +class ApiRateLimit: + """ + 接口限流装饰器,支持Redis固定窗口、滑动窗口及本地应急兜底。 + + `local_fallback` 仅作为Redis异常场景下的进程内应急保护,能提供单进程内的 + 基础限流能力,但不保证多 worker / 多实例部署下的全局一致性。 + + `ip` 维度限流通过 `ClientIPUtil` 提取客户端地址,仅当请求来源命中 + `APP_TRUSTED_PROXY_IPS` 且 `APP_TRUSTED_PROXY_HOPS` 大于0时,才会解析 + `X-Forwarded-For` / `X-Real-IP` 请求头;否则回退到直接连接来源地址。 + """ + + _SUPPORTED_SCOPES: tuple[RateLimitScope, ...] = ('ip', 'user', 'user_or_ip') + _SUPPORTED_ALGORITHMS: tuple[RateLimitAlgorithm, ...] = ('fixed_window', 'sliding_window') + _SUPPORTED_FAIL_STRATEGIES: tuple[RateLimitFailStrategy, ...] = ('open', 'closed', 'local_fallback') + _FIXED_WINDOW_LUA_SCRIPT = """ + local current = redis.call('INCR', KEYS[1]) + local ttl = redis.call('PTTL', KEYS[1]) + if current == 1 or ttl < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + ttl = redis.call('PTTL', KEYS[1]) + end + local limit = tonumber(ARGV[2]) + local remaining = limit - current + if remaining < 0 then + remaining = 0 + end + local allowed = 0 + if current <= limit then + allowed = 1 + end + return {allowed, current, remaining, ttl} + """ + _SLIDING_WINDOW_LUA_SCRIPT = """ + redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2])) + local current = redis.call('ZCARD', KEYS[1]) + local limit = tonumber(ARGV[4]) + local ttl = ARGV[2] + if current >= limit then + local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') + if earliest[2] ~= nil then + ttl = tonumber(ARGV[2]) - (tonumber(ARGV[1]) - tonumber(earliest[2])) + end + if ttl < 1 then + ttl = 1 + end + return {0, current, 0, ttl} + end + redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3]) + redis.call('PEXPIRE', KEYS[1], ARGV[2]) + current = current + 1 + local remaining = limit - current + if remaining < 0 then + remaining = 0 + end + local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') + if earliest[2] ~= nil then + ttl = tonumber(ARGV[2]) - (tonumber(ARGV[1]) - tonumber(earliest[2])) + end + if ttl < 1 then + ttl = 1 + end + return {1, current, remaining, ttl} + """ + # 仅用于Redis异常时的单进程本地兜底,不承担分布式一致性职责。 + _LOCAL_FALLBACK_STORE: dict[str, deque[int]] = {} + + def __init__( + self, + namespace: str, + limit: int | None = None, + window_seconds: int | None = None, + scope: RateLimitScope | None = None, + algorithm: RateLimitAlgorithm | None = None, + fail_strategy: RateLimitFailStrategy | None = None, + bypass: ApiRateLimitBypassConfig | None = None, + methods: Sequence[HttpMethod] | None = None, + message: str | None = None, + preset: ApiRateLimitPresetConfig | None = None, + ) -> None: + """ + 初始化接口限流装饰器 + + :param namespace: 限流命名空间,用于区分不同接口 + :param limit: 窗口内允许的最大请求次数,可覆盖预设 + :param window_seconds: 限流窗口时长,单位秒,可覆盖预设 + :param scope: 限流作用域,ip: 按客户端IP限流,客户端IP由可信代理配置控制提取,user: 仅按当前登录用户限流,未登录请求跳过限流,user_or_ip: 已登录按用户限流,未登录按客户端IP限流,可覆盖预设 + :param algorithm: 限流算法,fixed_window: 固定窗口,sliding_window: 滑动窗口,可覆盖预设 + :param fail_strategy: 限流组件异常时的故障策略,open: 放行,closed: 直接拦截,local_fallback: 使用进程内内存做应急兜底限流,仅保证单进程内生效,可覆盖预设 + :param bypass: 角色豁免配置,仅在显式传入时生效 + :param methods: 需要限流的HttpMethod枚举列表,为None时默认限制所有方法,可覆盖预设 + :param message: 触发限流后的提示信息,可覆盖预设 + :param preset: 限流预设配置 + """ + resolved_limit = limit if limit is not None else preset.limit if preset else None + resolved_window_seconds = ( + window_seconds if window_seconds is not None else preset.window_seconds if preset else None + ) + resolved_scope = scope if scope is not None else preset.scope if preset else 'ip' + resolved_algorithm = algorithm if algorithm is not None else preset.algorithm if preset else 'fixed_window' + resolved_fail_strategy = ( + fail_strategy if fail_strategy is not None else preset.fail_strategy if preset else 'open' + ) + resolved_methods = methods if methods is not None else preset.methods if preset else None + resolved_message = message if message is not None else preset.message if preset else '请求过于频繁,请稍后再试' + resolved_preset_name = preset.name if preset else 'CUSTOM' + + if not namespace: + raise ValueError('ApiRateLimit的namespace不能为空') + if resolved_limit is None or resolved_limit <= 0: + raise ValueError('ApiRateLimit的limit必须大于0') + if resolved_window_seconds is None or resolved_window_seconds <= 0: + raise ValueError('ApiRateLimit的window_seconds必须大于0') + if resolved_scope not in self._SUPPORTED_SCOPES: + raise ValueError(f'ApiRateLimit的scope仅支持: {", ".join(self._SUPPORTED_SCOPES)}') + if resolved_algorithm not in self._SUPPORTED_ALGORITHMS: + raise ValueError(f'ApiRateLimit的algorithm仅支持: {", ".join(self._SUPPORTED_ALGORITHMS)}') + if resolved_fail_strategy not in self._SUPPORTED_FAIL_STRATEGIES: + raise ValueError(f'ApiRateLimit的fail_strategy仅支持: {", ".join(self._SUPPORTED_FAIL_STRATEGIES)}') + if bypass and resolved_scope == 'ip': + raise ValueError('ApiRateLimit在scope=ip时不支持角色豁免配置') + + self.namespace = namespace + self.preset_name = resolved_preset_name + self.limit = resolved_limit + self.window_seconds = resolved_window_seconds + self.scope = resolved_scope + self.algorithm = resolved_algorithm + self.fail_strategy = resolved_fail_strategy + self.bypass_roles = self._normalize_bypass_roles(bypass.roles if bypass else None) + self.methods = ApiAnnotationUtil.normalize_http_methods(resolved_methods) + self.message = resolved_message + + def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + """ + 为目标异步接口函数增加接口限流能力 + + :param func: 需要限流的异步接口函数 + :return: 包装后的异步接口函数 + """ + + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + request = ApiAnnotationUtil.get_request(func, *args, **kwargs) + if request is None: + return await func(*args, **kwargs) + if not self._is_request_method_allowed(request): + return await func(*args, **kwargs) + bypass_role = self._match_bypass_role() + if bypass_role is not None: + self._log_rate_limit_bypass(request, bypass_role) + return await func(*args, **kwargs) + + rate_limit_result: dict[str, int | bool] | None = None + redis = getattr(request.app.state, 'redis', None) + if redis is None: + rate_limit_result = self._resolve_failed_rate_limit( + request, reason='redis_unavailable', error_message='redis client is not initialized' + ) + if rate_limit_result is None: + return await func(*args, **kwargs) + else: + try: + rate_limit_result = await self._acquire_rate_limit(redis, request) + except Exception as exc: + rate_limit_result = self._resolve_failed_rate_limit( + request, reason='redis_error', error_message=str(exc) + ) + if rate_limit_result is None: + return await func(*args, **kwargs) + if rate_limit_result is None: + return await func(*args, **kwargs) + + headers = self._build_rate_limit_headers(rate_limit_result) + if not rate_limit_result['allowed']: + self._log_rate_limit_hit(request, rate_limit_result) + return ResponseUtil.too_many_requests(msg=self.message, headers=headers) # type: ignore[return-value] + + ApiResponseHeaderUtil.merge_headers(request, headers) + result = await func(*args, **kwargs) + return result + + return wrapper + + def _is_request_method_allowed(self, request: Request) -> bool: + """ + 判断当前请求方法是否启用限流 + + :param request: 当前请求对象 + :return: 是否启用限流 + """ + return not self.methods or request.method.upper() in self.methods + + def _normalize_bypass_roles(self, bypass_roles: Sequence[str] | None) -> tuple[str, ...]: + """ + 标准化角色豁免配置 + + :param bypass_roles: 原始角色标识列表 + :return: 去重后的角色标识元组 + """ + if not bypass_roles: + return () + + normalized_roles: list[str] = [] + for role in bypass_roles: + normalized_role = role.strip() + if not normalized_role: + raise ValueError('ApiRateLimit的bypass_roles不能包含空角色标识') + if normalized_role not in normalized_roles: + normalized_roles.append(normalized_role) + + return tuple(normalized_roles) + + def _match_bypass_role(self) -> str | None: + """ + 判断当前登录用户是否命中角色豁免 + + :return: 命中的角色标识,未命中时返回None + """ + if not self.bypass_roles: + return None + + current_user = self._get_current_user() + if current_user is None: + return None + + user_roles = {str(role).strip() for role in current_user.roles if str(role).strip()} + for role in self.bypass_roles: + if role in user_roles: + return role + + return None + + async def _acquire_rate_limit(self, redis: aioredis.Redis, request: Request) -> dict[str, int | bool] | None: + """ + 获取当前请求的限流计数结果 + + :param redis: Redis连接对象 + :param request: 当前请求对象 + :return: 限流结果,当前请求不适用限流时返回None + """ + current_time_ms = int(time.time() * 1000) + rate_limit_key = self._build_rate_limit_key(request, current_time_ms) + if rate_limit_key is None: + return None + if self.algorithm == 'sliding_window': + allowed, current, remaining, reset_after_ms = await redis.eval( + self._SLIDING_WINDOW_LUA_SCRIPT, + 1, + rate_limit_key, + current_time_ms, + self.window_seconds * 1000, + f'{current_time_ms}-{time.time_ns()}-{uuid.uuid4().hex}', + self.limit, + ) + else: + window_ms = self.window_seconds * 1000 + window_bucket = current_time_ms // window_ms + window_end_ms = (window_bucket + 1) * window_ms + ttl_ms = max(window_end_ms - current_time_ms, 1) + allowed, current, remaining, reset_after_ms = await redis.eval( + self._FIXED_WINDOW_LUA_SCRIPT, 1, rate_limit_key, ttl_ms, self.limit + ) + reset_after_ms = max(int(reset_after_ms), 1) + + return { + 'allowed': bool(int(allowed)), + 'current': int(current), + 'remaining': int(remaining), + 'reset_after_seconds': max((reset_after_ms + 999) // 1000, 1), + 'reset_at': (current_time_ms + reset_after_ms + 999) // 1000, + } + + def _build_rate_limit_key( + self, request: Request, current_time_ms: int, include_window_bucket: bool = True + ) -> str | None: + """ + 构建当前请求的限流键 + + :param request: 当前请求对象 + :param current_time_ms: 当前时间戳,单位毫秒 + :return: 限流键,当前请求不适用限流时返回None + """ + scope_value = self._get_scope_value(request) + if scope_value is None: + return None + + key_material = { + 'method': request.method.upper(), + 'path': self._get_route_path(request), + 'scope': self.scope, + 'scope_value': scope_value, + } + key_digest = hashlib.sha256( + json.dumps( + key_material, + ensure_ascii=False, + sort_keys=True, + separators=(',', ':'), + ).encode('utf-8') + ).hexdigest() + + key_prefix = f'{RedisInitKeyConfig.API_RATE_LIMIT.key}:{self.namespace}:{self.algorithm}:{key_digest}' + if self.algorithm == 'fixed_window' and include_window_bucket: + window_bucket = current_time_ms // (self.window_seconds * 1000) + return f'{key_prefix}:{window_bucket}' + + return key_prefix + + def _resolve_failed_rate_limit( + self, request: Request, reason: str, error_message: str + ) -> dict[str, int | bool] | None: + """ + 处理Redis不可用或执行异常时的限流故障策略 + + :param request: 当前请求对象 + :param reason: 降级原因 + :param error_message: 错误详情 + :return: 限流结果,为None时表示按策略放行 + """ + self._log_rate_limit_degrade(request, reason, error_message) + if self.fail_strategy == 'open': + return None + if self.fail_strategy == 'closed': + return self._build_closed_rate_limit_result() + + return self._acquire_local_fallback_rate_limit(request) + + def _acquire_local_fallback_rate_limit(self, request: Request) -> dict[str, int | bool] | None: + """ + 使用进程内内存进行应急兜底限流,仅在Redis异常时启用 + + 该兜底能力仅在当前进程内生效,不保证多 worker / 多实例场景下的 + 全局一致限流,更适合作为短时故障期间的降级保护。 + + :param request: 当前请求对象 + :return: 限流结果,当前请求不适用限流时返回None + """ + current_time_ms = int(time.time() * 1000) + rate_limit_key = self._build_rate_limit_key(request, current_time_ms, include_window_bucket=False) + if rate_limit_key is None: + return None + + window_ms = self.window_seconds * 1000 + window_start_ms = current_time_ms - window_ms + local_window = self._LOCAL_FALLBACK_STORE.setdefault(rate_limit_key, deque()) + while local_window and local_window[0] <= window_start_ms: + local_window.popleft() + if not local_window: + self._LOCAL_FALLBACK_STORE.pop(rate_limit_key, None) + local_window = deque() + self._LOCAL_FALLBACK_STORE[rate_limit_key] = local_window + + current = len(local_window) + if current >= self.limit: + reset_after_ms = max(local_window[0] + window_ms - current_time_ms, 1) if local_window else window_ms + return { + 'allowed': False, + 'current': current, + 'remaining': 0, + 'reset_after_seconds': max((reset_after_ms + 999) // 1000, 1), + 'reset_at': (current_time_ms + reset_after_ms + 999) // 1000, + } + + local_window.append(current_time_ms) + current += 1 + reset_after_ms = max(local_window[0] + window_ms - current_time_ms, 1) + + return { + 'allowed': True, + 'current': current, + 'remaining': max(self.limit - current, 0), + 'reset_after_seconds': max((reset_after_ms + 999) // 1000, 1), + 'reset_at': (current_time_ms + reset_after_ms + 999) // 1000, + } + + def _build_closed_rate_limit_result(self) -> dict[str, int | bool]: + """ + 构建故障关闭策略下的拦截结果 + + :return: 限流结果 + """ + reset_after_seconds = max(self.window_seconds, 1) + current_time_ms = int(time.time() * 1000) + reset_after_ms = reset_after_seconds * 1000 + return { + 'allowed': False, + 'current': self.limit, + 'remaining': 0, + 'reset_after_seconds': reset_after_seconds, + 'reset_at': (current_time_ms + reset_after_ms + 999) // 1000, + } + + def _get_route_path(self, request: Request) -> str: + """ + 获取当前请求的路由模板路径 + + :param request: 当前请求对象 + :return: 路由模板路径 + """ + route = request.scope.get('route') + route_path = getattr(route, 'path', None) + return route_path or request.url.path + + def _get_scope_value(self, request: Request) -> str | None: + """ + 获取当前请求的限流作用域值 + + :param request: 当前请求对象 + :return: 作用域值,当前请求不适用限流时返回None + """ + if self.scope == 'ip': + return f'ip:{self._get_client_ip(request)}' + + current_user_id = self._get_current_user_id() + if current_user_id is not None: + return f'user:{current_user_id}' + + if self.scope == 'user': + return None + + return f'ip:{self._get_client_ip(request)}' + + def _get_current_user_id(self) -> int | None: + """ + 获取当前登录用户ID + + :return: 用户ID,未登录时返回None + """ + current_user = self._get_current_user() + return current_user.user.user_id if current_user and current_user.user else None + + def _get_current_user(self) -> CurrentUserModel | None: + """ + 获取当前登录用户 + + :return: 当前登录用户,未登录时返回None + """ + try: + return RequestContext.get_current_user() + except LoginException: + return None + + def _get_client_ip(self, request: Request) -> str: + """ + 获取客户端IP地址 + + :param request: 当前请求对象 + :return: 客户端IP地址 + """ + return ClientIPUtil.get_client_ip(request) + + def _build_rate_limit_headers(self, rate_limit_result: dict[str, int | bool]) -> dict[str, str]: + """ + 构建限流响应头 + + :param rate_limit_result: 限流结果 + :return: 限流响应头 + """ + headers = { + 'X-RateLimit-Limit': str(self.limit), + 'X-RateLimit-Remaining': str(rate_limit_result['remaining']), + 'X-RateLimit-Reset': str(rate_limit_result['reset_at']), + } + if not rate_limit_result['allowed']: + headers['Retry-After'] = str(rate_limit_result['reset_after_seconds']) + + return headers + + def _log_rate_limit_hit(self, request: Request, rate_limit_result: dict[str, int | bool]) -> None: + """ + 记录限流命中日志 + + :param request: 当前请求对象 + :param rate_limit_result: 限流结果 + :return: None + """ + logger.warning( + '接口限流命中: namespace={} preset={} algorithm={} fail_strategy={} method={} path={} scope={} scope_value={} current={} limit={} retry_after={}s', + self.namespace, + self.preset_name, + self.algorithm, + self.fail_strategy, + request.method.upper(), + self._get_route_path(request), + self.scope, + self._get_scope_value(request), + rate_limit_result['current'], + self.limit, + rate_limit_result['reset_after_seconds'], + ) + + def _log_rate_limit_degrade(self, request: Request, reason: str, error_message: str) -> None: + """ + 记录限流组件异常时的降级日志 + + :param request: 当前请求对象 + :param reason: 降级原因 + :param error_message: 错误详情 + :return: None + """ + log_message = ( + '接口限流降级: namespace={} preset={} algorithm={} fail_strategy={} ' + 'method={} path={} scope={} reason={} error={}' + ) + log_args: list[str] = [ + self.namespace, + self.preset_name, + self.algorithm, + self.fail_strategy, + request.method.upper(), + self._get_route_path(request), + self.scope, + reason, + error_message, + ] + if self.fail_strategy == 'local_fallback': + log_message += ' local_fallback_scope={}' + log_args.append('process_local_only') + + logger.warning(log_message, *log_args) + + def _log_rate_limit_bypass(self, request: Request, bypass_role: str) -> None: + """ + 记录角色豁免限流日志 + + :param request: 当前请求对象 + :param bypass_role: 命中的角色标识 + :return: None + """ + logger.info( + '接口限流绕过: namespace={} preset={} method={} path={} scope={} bypass_role={}', + self.namespace, + self.preset_name, + request.method.upper(), + self._get_route_path(request), + self.scope, + bypass_role, + ) diff --git a/shierfeng-fastapi-backend/common/aspect/data_scope.py b/shierfeng-fastapi-backend/common/aspect/data_scope.py new file mode 100644 index 0000000..4d3f93a --- /dev/null +++ b/shierfeng-fastapi-backend/common/aspect/data_scope.py @@ -0,0 +1,112 @@ +from fastapi import Depends, Request, params +from sqlalchemy import ColumnElement, func, or_, select + +from common.context import RequestContext +from config.database import Base +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.role_do import SysRoleDept +from utils.dependency_util import DependencyUtil + + +class GetDataScope: + """ + 获取当前用户数据权限对应的查询sql语句 + """ + + DATA_SCOPE_ALL = '1' + DATA_SCOPE_CUSTOM = '2' + DATA_SCOPE_DEPT = '3' + DATA_SCOPE_DEPT_AND_CHILD = '4' + DATA_SCOPE_SELF = '5' + + def __init__( + self, + query_alias: Base, + user_alias: str | None = 'user_id', + dept_alias: str | None = 'dept_id', + ) -> None: + """ + 获取当前用户数据权限对应的查询sql语句 + + :param query_alias: 所要查询表对应的sqlalchemy模型类,不可为空 + :param user_alias: 用户id字段别名,默认为'user_id' + :param dept_alias: 部门id字段别名,默认为'dept_id' + """ + self.query_alias = query_alias + self.user_alias = user_alias + self.dept_alias = dept_alias + + def __call__(self, request: Request) -> ColumnElement: + DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项') + current_user = RequestContext.get_current_user() + user_id = current_user.user.user_id + dept_id = current_user.user.dept_id + custom_data_scope_role_id_list = [ + item.role_id for item in current_user.user.role if item.data_scope == self.DATA_SCOPE_CUSTOM + ] + param_sql_list = [] + for role in current_user.user.role: + if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL: + param_sql_list = [True] + break + if role.data_scope == self.DATA_SCOPE_CUSTOM: + if len(custom_data_scope_role_id_list) > 1: + param_sql_list.append( + getattr(self.query_alias, self.dept_alias).in_( + select(SysRoleDept.dept_id).where(SysRoleDept.role_id.in_(custom_data_scope_role_id_list)) + ) + if hasattr(self.query_alias, self.dept_alias) + else False + ) + else: + param_sql_list.append( + getattr(self.query_alias, self.dept_alias).in_( + select(SysRoleDept.dept_id).where(SysRoleDept.role_id == role.role_id) + ) + if hasattr(self.query_alias, self.dept_alias) + else False + ) + elif role.data_scope == self.DATA_SCOPE_DEPT: + param_sql_list.append( + getattr(self.query_alias, self.dept_alias) == dept_id + if hasattr(self.query_alias, self.dept_alias) + else False + ) + elif role.data_scope == self.DATA_SCOPE_DEPT_AND_CHILD: + param_sql_list.append( + getattr(self.query_alias, self.dept_alias).in_( + select(SysDept.dept_id).where( + or_(SysDept.dept_id == dept_id, func.find_in_set(dept_id, SysDept.ancestors)) + ) + ) + if hasattr(self.query_alias, self.dept_alias) + else False + ) + elif role.data_scope == self.DATA_SCOPE_SELF: + param_sql_list.append( + getattr(self.query_alias, self.user_alias) == user_id + if hasattr(self.query_alias, self.user_alias) + else False + ) + else: + param_sql_list.append(False) + param_sql_list = list(dict.fromkeys(param_sql_list)) + param_sql = or_(*param_sql_list) + + return param_sql + + +def DataScopeDependency( # noqa: N802 + query_alias: Base, + user_alias: str | None = 'user_id', + dept_alias: str | None = 'dept_id', +) -> params.Depends: + """ + 当前用户数据权限依赖 + + :param query_alias: 所要查询表对应的sqlalchemy模型类,不可为空 + :param user_alias: 用户id字段别名,默认为'user_id' + :param dept_alias: 部门id字段别名,默认为'dept_id' + :return: 当前用户数据权限依赖 + """ + return Depends(GetDataScope(query_alias, user_alias, dept_alias)) diff --git a/shierfeng-fastapi-backend/common/aspect/db_seesion.py b/shierfeng-fastapi-backend/common/aspect/db_seesion.py new file mode 100644 index 0000000..3c88fb9 --- /dev/null +++ b/shierfeng-fastapi-backend/common/aspect/db_seesion.py @@ -0,0 +1,12 @@ +from fastapi import Depends, params + +from config.get_db import get_db + + +def DBSessionDependency() -> params.Depends: # noqa: N802 + """ + 数据库会话依赖 + + :return: 数据库会话依赖 + """ + return Depends(get_db) diff --git a/shierfeng-fastapi-backend/common/aspect/interface_auth.py b/shierfeng-fastapi-backend/common/aspect/interface_auth.py new file mode 100644 index 0000000..92f20e8 --- /dev/null +++ b/shierfeng-fastapi-backend/common/aspect/interface_auth.py @@ -0,0 +1,94 @@ +from fastapi import Depends, Request, params + +from common.context import RequestContext +from exceptions.exception import PermissionException +from utils.dependency_util import DependencyUtil + + +class CheckUserInterfaceAuth: + """ + 校验当前用户是否具有相应的接口权限 + """ + + def __init__(self, perm: str | list, is_strict: bool = False) -> None: + """ + 校验当前用户是否具有相应的接口权限 + + :param perm: 权限标识 + :param is_strict: 当传入的权限标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个权限标识,所有的校验结果都需要为True才会通过 + """ + self.perm = perm + self.is_strict = is_strict + + def __call__(self, request: Request) -> bool: + DependencyUtil.check_exclude_routes( + request, err_msg='当前路由不在认证规则内,不可使用CheckUserInterfaceAuth依赖项' + ) + current_user = RequestContext.get_current_user() + user_auth_list = current_user.permissions + if '*:*:*' in user_auth_list: + return True + if isinstance(self.perm, str) and self.perm in user_auth_list: + return True + if isinstance(self.perm, list): + if self.is_strict: + if all(perm_str in user_auth_list for perm_str in self.perm): + return True + elif any(perm_str in user_auth_list for perm_str in self.perm): + return True + raise PermissionException(data='', message='该用户无此接口权限') + + +class CheckRoleInterfaceAuth: + """ + 根据角色校验当前用户是否具有相应的接口权限 + """ + + def __init__(self, role_key: str | list, is_strict: bool = False) -> None: + """ + 根据角色校验当前用户是否具有相应的接口权限 + + :param role_key: 角色标识 + :param is_strict: 当传入的角色标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个角色标识,所有的校验结果都需要为True才会通过 + """ + self.role_key = role_key + self.is_strict = is_strict + + def __call__(self, request: Request) -> bool: + DependencyUtil.check_exclude_routes( + request, err_msg='当前路由不在认证规则内,不可使用CheckRoleInterfaceAuth依赖项' + ) + current_user = RequestContext.get_current_user() + user_role_list = current_user.user.role + user_role_key_list = [role.role_key for role in user_role_list] + if isinstance(self.role_key, str) and self.role_key in user_role_key_list: + return True + if isinstance(self.role_key, list): + if self.is_strict: + if all(role_key_str in user_role_key_list for role_key_str in self.role_key): + return True + elif any(role_key_str in user_role_key_list for role_key_str in self.role_key): + return True + raise PermissionException(data='', message='该用户无此接口权限') + + +def UserInterfaceAuthDependency(perm: str | list, is_strict: bool = False) -> params.Depends: # noqa: N802 + """ + 根据权限标识校验当前用户接口权限依赖 + + :param perm: 权限标识 + :param is_strict: 当传入的权限标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个权限标识,所有的校验结果都需要为True才会通过 + :return: 根据权限标识校验当前用户接口权限依赖 + """ + return Depends(CheckUserInterfaceAuth(perm, is_strict)) + + +def RoleInterfaceAuthDependency(role_key: str | list, is_strict: bool = False) -> params.Depends: # noqa: N802 + """ + 根据角色校验当前用户接口权限依赖 + + :param role_key: 角色标识 + :param is_strict: 当传入的角色标识是list类型时,是否开启严格模式,开启表示会校验列表中的每一个角色标识,所有的校验结果都需要为True才会通过 + :return: 根据角色校验当前用户接口权限依赖 + """ + return Depends(CheckRoleInterfaceAuth(role_key, is_strict)) diff --git a/shierfeng-fastapi-backend/common/aspect/pre_auth.py b/shierfeng-fastapi-backend/common/aspect/pre_auth.py new file mode 100644 index 0000000..cdcc579 --- /dev/null +++ b/shierfeng-fastapi-backend/common/aspect/pre_auth.py @@ -0,0 +1,146 @@ +import re +from typing import Literal, TypedDict + +from fastapi import Depends, Request, params +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from common.context import RequestContext +from config.env import AppConfig +from config.get_db import get_db +from exceptions.exception import AuthException +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.login_service import LoginService + + +# 定义排除路由的字典结构 +class ExcludeRoute(TypedDict, total=False): + """ + 排除路由的字典结构 + + :param path: 路由路径(必填) + :param methods: HTTP方法列表,空列表表示所有方法(可选,默认为[]) + :param ignore_paths: 需要忽略的特定路径列表,即使匹配通配符也不排除(可选,默认为[]) + """ + + path: str + methods: list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] + ignore_paths: list[str] + + +# 创建OAuth2PasswordBearer对象 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/login') + + +class PreAuth: + """ + 登录认证前置校验依赖类 + """ + + def __init__(self, exclude_routes: list[ExcludeRoute] | None = None) -> None: + """ + 初始化登录认证前置校验依赖 + + :param exclude_routes: 需要排除的路由列表,格式为: + [{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}] + methods 可以是字符串或列表,空列表表示所有方法 + """ + self.exclude_routes = exclude_routes or [] + # 编译排除路径为正则表达式模式,并存储方法信息 + self.exclude_patterns = [] + + for route in self.exclude_routes: + # 使用TypedDict,确保路由字典包含path字段 + path = route.get('path', '') + methods = route.get('methods', []) + ignore_paths = route.get('ignore_paths', []) + + # 编译路径为正则表达式 + pattern = self._compile_path_pattern(path) + # 存储编译后的模式和方法信息 + self.exclude_patterns.append( + { + 'pattern': pattern, + 'methods': [method.upper() for method in methods], + 'original_path': path, + 'ignore_paths': ignore_paths, + } + ) + + def _compile_path_pattern(self, path: str) -> re.Pattern: + """ + 将FastAPI路径转换为正则表达式模式 + + :param path: FastAPI路径(如 /configKey/{config_key}) + :return: 编译后的正则表达式模式 + """ + # 将FastAPI路径参数转换为正则表达式 + # 例如:/configKey/{config_key} -> /configKey/[^/]+ + pattern_str = re.sub(r'\{[^}]+\}', r'[^/]+', path) + # 添加开始和结束锚点,确保精确匹配 + return re.compile(f'^{pattern_str}$') + + async def __call__(self, request: Request, db: AsyncSession = Depends(get_db)) -> CurrentUserModel | None: + """ + 执行登录认证校验 + + :param request: 当前请求对象 + :param db: 数据库会话 + :return: 当前用户信息 + """ + # 获取当前请求路径和方法 + path = request.url.path + method = request.method.upper() + + # 从配置中获取APP_ROOT_PATH + app_root_path = AppConfig.app_root_path + + # 去掉APP_ROOT_PATH前缀 + if app_root_path and path.startswith(app_root_path): + path = path[len(app_root_path) :] + + # 设置上下文变量 + RequestContext.set_current_exclude_patterns(self.exclude_patterns) + + # 检查路径和方法是否匹配排除模式 + for item in self.exclude_patterns: + pattern = item['pattern'] + exclude_methods = item['methods'] + ignore_paths = item['ignore_paths'] + + # 检查当前路径是否在忽略列表中 + if path in ignore_paths: + continue + + # 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中 + if pattern.match(path) and (not exclude_methods or method in exclude_methods): + # 跳过认证 + return None + + # 否则执行正常认证 + token = request.headers.get('Authorization') + if not token: + raise AuthException(data='', message='用户未登录,请先完成登录') + current_user = await LoginService.get_current_user(request, token, db) + return current_user + + +def PreAuthDependency(exclude_routes: list[ExcludeRoute] | None = None) -> params.Depends: # noqa: N802 + """ + 登录认证前置校验依赖 + + :param exclude_routes: 需要排除的路由列表,格式为: + [{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}] + methods 可以是字符串或列表,空列表表示所有方法 + :return: 登录认证前置校验依赖 + """ + return Depends(PreAuth(exclude_routes)) + + +def CurrentUserDependency() -> params.Depends: # noqa: N802 + """ + 当前登录用户信息依赖 + + :return: 当前登录用户信息依赖 + """ + return Depends(LoginService.get_current_user) diff --git a/shierfeng-fastapi-backend/common/constant.py b/shierfeng-fastapi-backend/common/constant.py new file mode 100644 index 0000000..1d8cf23 --- /dev/null +++ b/shierfeng-fastapi-backend/common/constant.py @@ -0,0 +1,856 @@ +from config.env import DataBaseConfig + + +class CommonConstant: + """ + 常用常量 + + PASSWORD_ERROR_COUNT: 密码错误次数 + WWW: www主域 + HTTP: http请求 + HTTPS: https请求 + LOOKUP_RMI: RMI远程方法调用 + LOOKUP_LDAP: LDAP远程方法调用 + LOOKUP_LDAPS: LDAPS远程方法调用 + YES: 是否为系统默认(是) + NO: 是否为系统默认(否) + DEPT_NORMAL: 部门正常状态 + DEPT_DISABLE: 部门停用状态 + UNIQUE: 校验是否唯一的返回标识(是) + NOT_UNIQUE: 校验是否唯一的返回标识(否) + """ + + PASSWORD_ERROR_COUNT = 5 + WWW = 'www.' + HTTP = 'http://' + HTTPS = 'https://' + LOOKUP_RMI = 'rmi:' + LOOKUP_LDAP = 'ldap:' + LOOKUP_LDAPS = 'ldaps:' + YES = 'Y' + NO = 'N' + DEPT_NORMAL = '0' + DEPT_DISABLE = '1' + UNIQUE = True + NOT_UNIQUE = False + + +class HttpStatusConstant: + """ + 返回状态码 + + SUCCESS: 操作成功 + CREATED: 对象创建成功 + ACCEPTED: 请求已经被接受 + NO_CONTENT: 操作已经执行成功,但是没有返回数据 + MOVED_PERM: 资源已被移除 + SEE_OTHER: 重定向 + NOT_MODIFIED: 资源没有被修改 + BAD_REQUEST: 参数列表错误(缺少,格式不匹配) + UNAUTHORIZED: 未授权 + FORBIDDEN: 访问受限,授权过期 + NOT_FOUND: 资源,服务未找到 + BAD_METHOD: 不允许的http方法 + CONFLICT: 资源冲突,或者资源被锁 + UNSUPPORTED_TYPE: 不支持的数据,媒体类型 + TOO_MANY_REQUESTS: 请求过于频繁 + ERROR: 系统内部错误 + NOT_IMPLEMENTED: 接口未实现 + WARN: 系统警告消息 + """ + + SUCCESS = 200 + CREATED = 201 + ACCEPTED = 202 + NO_CONTENT = 204 + MOVED_PERM = 301 + SEE_OTHER = 303 + NOT_MODIFIED = 304 + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + BAD_METHOD = 405 + CONFLICT = 409 + UNSUPPORTED_TYPE = 415 + TOO_MANY_REQUESTS = 429 + ERROR = 500 + NOT_IMPLEMENTED = 501 + WARN = 601 + + +class JobConstant: + """ + 定时任务常量 + + JOB_ERROR_LIST: 定时任务禁止调用模块及违规字符串列表 + JOB_WHITE_LIST: 定时任务允许调用模块列表 + """ + + JOB_ERROR_LIST = [ + 'app', + 'config', + 'exceptions', + 'import ', + 'middlewares', + 'module_admin', + 'open(', + 'os.', + 'server', + 'sub_applications', + 'subprocess.', + 'sys.', + 'utils', + 'while ', + '__import__', + '"', + "'", + ',', + '?', + ':', + ';', + '/', + '|', + '+', + '-', + '=', + '~', + '!', + '#', + '$', + '%', + '^', + '&', + '*', + '<', + '>', + '(', + ')', + '[', + ']', + '{', + '}', + ' ', + ] + JOB_WHITE_LIST = ['module_task'] + + +class LockConstant: + """ + 分布式锁常量 + """ + + APP_STARTUP_LOCK_KEY = 'app:startup:lock' + LOCK_EXPIRE_SECONDS = 60 + LOCK_RENEWAL_INTERVAL = 20 + + +class ApiNamespace: + """ + 接口注解通用命名空间常量 + + 这一组常量统一提供给 `ApiCache`、`ApiCacheEvict`、`ApiRateLimit` + 等接口注解使用,用同一套“模块:功能”命名规则收敛缓存和限流场景。 + + LOGIN: 登录接口命名空间 + REGISTER: 注册接口命名空间 + LOGIN_USER_INFO: 登录用户信息接口命名空间 + LOGIN_USER_ROUTERS: 登录用户路由接口命名空间 + CAPTCHA_IMAGE: 图片验证码接口命名空间 + COMMON_UPLOAD: 通用上传接口命名空间 + TRANSPORT_CRYPTO_PUBLIC_KEY: 传输层加密公钥接口命名空间 + TRANSPORT_CRYPTO_FRONTEND_CONFIG: 传输层加密前端配置接口命名空间 + + MONITOR_SERVER_INFO: 服务监控信息接口命名空间 + MONITOR_CACHE_CLEAR_NAME: 缓存名称清理接口命名空间 + MONITOR_CACHE_CLEAR_KEY: 缓存键清理接口命名空间 + MONITOR_CACHE_CLEAR_ALL: 缓存全量清理接口命名空间 + MONITOR_ONLINE_FORCE_LOGOUT: 在线用户强退接口命名空间 + MONITOR_OPERLOG_CLEAN: 操作日志清空接口命名空间 + MONITOR_OPERLOG_DELETE: 操作日志删除接口命名空间 + MONITOR_OPERLOG_EXPORT: 操作日志导出接口命名空间 + MONITOR_LOGININFO_CLEAN: 登录日志清空接口命名空间 + MONITOR_LOGININFO_DELETE: 登录日志删除接口命名空间 + MONITOR_LOGININFO_UNLOCK: 账户解锁接口命名空间 + MONITOR_LOGININFO_EXPORT: 登录日志导出接口命名空间 + MONITOR_JOB_LIST: 定时任务分页列表接口命名空间 + MONITOR_JOB_DETAIL: 定时任务详情接口命名空间 + MONITOR_JOB_RUN: 定时任务执行接口命名空间 + MONITOR_JOB_DELETE: 定时任务删除接口命名空间 + MONITOR_JOB_EXPORT: 定时任务导出接口命名空间 + MONITOR_JOB_LOG_CLEAN: 定时任务日志清空接口命名空间 + MONITOR_JOB_LOG_DELETE: 定时任务日志删除接口命名空间 + MONITOR_JOB_LOG_EXPORT: 定时任务日志导出接口命名空间 + + SYSTEM_DEPT_EDIT_TREE: 部门编辑树接口命名空间 + SYSTEM_DEPT_LIST: 部门列表接口命名空间 + SYSTEM_DEPT_DETAIL: 部门详情接口命名空间 + SYSTEM_CONFIG_LIST: 参数配置列表接口命名空间 + SYSTEM_CONFIG_DETAIL: 参数配置详情接口命名空间 + SYSTEM_CONFIG_REFRESH_CACHE: 参数缓存刷新接口命名空间 + SYSTEM_CONFIG_EXPORT: 参数导出接口命名空间 + SYSTEM_DICT_TYPE_LIST: 字典类型列表接口命名空间 + SYSTEM_DICT_TYPE_OPTIONS: 字典类型选项接口命名空间 + SYSTEM_DICT_TYPE_DETAIL: 字典类型详情接口命名空间 + SYSTEM_DICT_REFRESH_CACHE: 字典缓存刷新接口命名空间 + SYSTEM_DICT_TYPE_EXPORT: 字典类型导出接口命名空间 + SYSTEM_DICT_DATA_LIST: 字典数据列表接口命名空间 + SYSTEM_DICT_DATA_DETAIL: 字典数据详情接口命名空间 + SYSTEM_DICT_DATA_EXPORT: 字典数据导出接口命名空间 + SYSTEM_MENU_TREE: 菜单树接口命名空间 + SYSTEM_MENU_ROLE_TREE: 角色菜单树接口命名空间 + SYSTEM_MENU_LIST: 菜单分页列表接口命名空间 + SYSTEM_MENU_DETAIL: 菜单详情接口命名空间 + SYSTEM_NOTICE_LIST: 通知公告列表接口命名空间 + SYSTEM_NOTICE_DETAIL: 通知公告详情接口命名空间 + SYSTEM_POST_LIST: 岗位列表接口命名空间 + SYSTEM_POST_DETAIL: 岗位详情接口命名空间 + SYSTEM_POST_EXPORT: 岗位导出接口命名空间 + SYSTEM_ROLE_DEPT_TREE: 角色部门树接口命名空间 + SYSTEM_ROLE_LIST: 角色列表接口命名空间 + SYSTEM_ROLE_DETAIL: 角色详情接口命名空间 + SYSTEM_ROLE_ALLOCATED_USER_LIST: 已分配用户角色列表接口命名空间 + SYSTEM_ROLE_UNALLOCATED_USER_LIST: 未分配用户角色列表接口命名空间 + SYSTEM_ROLE_EXPORT: 角色导出接口命名空间 + SYSTEM_ROLE_AUTH_USER_SELECT_ALL: 角色批量分配用户接口命名空间 + SYSTEM_ROLE_AUTH_USER_CANCEL: 角色取消分配用户接口命名空间 + SYSTEM_ROLE_AUTH_USER_CANCEL_ALL: 角色批量取消分配用户接口命名空间 + SYSTEM_USER_DEPT_TREE: 用户部门树接口命名空间 + SYSTEM_USER_LIST: 用户列表接口命名空间 + SYSTEM_USER_PROFILE: 用户个人信息接口命名空间 + SYSTEM_USER_DETAIL: 用户详情接口命名空间 + SYSTEM_USER_PROFILE_AVATAR: 用户头像上传接口命名空间 + SYSTEM_USER_IMPORT: 用户导入接口命名空间 + SYSTEM_USER_EXPORT: 用户导出接口命名空间 + + AI_MODEL_LIST: AI模型列表接口命名空间 + AI_MODEL_ALL: AI模型全量列表接口命名空间 + AI_MODEL_DETAIL: AI模型详情接口命名空间 + AI_CHAT_CONFIG: AI对话配置接口命名空间 + AI_CHAT_SEND: AI对话发送接口命名空间 + AI_CHAT_CANCEL: AI对话取消接口命名空间 + + TOOL_GEN_LIST: 代码生成列表接口命名空间 + TOOL_GEN_DB_LIST: 代码生成数据源列表接口命名空间 + TOOL_GEN_DETAIL: 代码生成详情接口命名空间 + TOOL_GEN_PREVIEW: 代码生成预览接口命名空间 + TOOL_GEN_IMPORT_TABLE: 代码生成导入表接口命名空间 + TOOL_GEN_CREATE_TABLE: 代码生成建表接口命名空间 + TOOL_GEN_BATCH_GEN_CODE: 代码生成批量下载接口命名空间 + TOOL_GEN_GEN_CODE_LOCAL: 代码生成到本地接口命名空间 + TOOL_GEN_SYNC_DB: 代码生成同步库结构接口命名空间 + """ + + LOGIN = 'login' + REGISTER = 'register' + LOGIN_USER_INFO = 'login:user:info' + LOGIN_USER_ROUTERS = 'login:user:routers' + CAPTCHA_IMAGE = 'captcha:image' + COMMON_UPLOAD = 'common:upload' + TRANSPORT_CRYPTO_PUBLIC_KEY = 'transport-crypto:public-key' + TRANSPORT_CRYPTO_FRONTEND_CONFIG = 'transport-crypto:frontend-config' + + MONITOR_SERVER_INFO = 'monitor:server:info' + MONITOR_CACHE_CLEAR_NAME = 'monitor:cache:clear-name' + MONITOR_CACHE_CLEAR_KEY = 'monitor:cache:clear-key' + MONITOR_CACHE_CLEAR_ALL = 'monitor:cache:clear-all' + MONITOR_ONLINE_FORCE_LOGOUT = 'monitor:online:force-logout' + MONITOR_OPERLOG_CLEAN = 'monitor:operlog:clean' + MONITOR_OPERLOG_DELETE = 'monitor:operlog:delete' + MONITOR_OPERLOG_EXPORT = 'monitor:operlog:export' + MONITOR_LOGININFO_CLEAN = 'monitor:logininfor:clean' + MONITOR_LOGININFO_DELETE = 'monitor:logininfor:delete' + MONITOR_LOGININFO_UNLOCK = 'monitor:logininfor:unlock' + MONITOR_LOGININFO_EXPORT = 'monitor:logininfor:export' + MONITOR_JOB_LIST = 'monitor:job:list' + MONITOR_JOB_DETAIL = 'monitor:job:detail' + MONITOR_JOB_RUN = 'monitor:job:run' + MONITOR_JOB_DELETE = 'monitor:job:delete' + MONITOR_JOB_EXPORT = 'monitor:job:export' + MONITOR_JOB_LOG_CLEAN = 'monitor:job-log:clean' + MONITOR_JOB_LOG_DELETE = 'monitor:job-log:delete' + MONITOR_JOB_LOG_EXPORT = 'monitor:job-log:export' + + SYSTEM_DEPT_EDIT_TREE = 'system:dept:edit-tree' + SYSTEM_DEPT_LIST = 'system:dept:list' + SYSTEM_DEPT_DETAIL = 'system:dept:detail' + + SYSTEM_CONFIG_LIST = 'system:config:list' + SYSTEM_CONFIG_DETAIL = 'system:config:detail' + SYSTEM_CONFIG_REFRESH_CACHE = 'system:config:refresh-cache' + SYSTEM_CONFIG_EXPORT = 'system:config:export' + + SYSTEM_DICT_TYPE_LIST = 'system:dict:type-list' + SYSTEM_DICT_TYPE_OPTIONS = 'system:dict:type-options' + SYSTEM_DICT_TYPE_DETAIL = 'system:dict:type-detail' + SYSTEM_DICT_REFRESH_CACHE = 'system:dict:refresh-cache' + SYSTEM_DICT_TYPE_EXPORT = 'system:dict:type-export' + SYSTEM_DICT_DATA_LIST = 'system:dict:data-list' + SYSTEM_DICT_DATA_DETAIL = 'system:dict:data-detail' + SYSTEM_DICT_DATA_EXPORT = 'system:dict:data-export' + + SYSTEM_MENU_TREE = 'system:menu:tree' + SYSTEM_MENU_ROLE_TREE = 'system:menu:role-tree' + SYSTEM_MENU_LIST = 'system:menu:list' + SYSTEM_MENU_DETAIL = 'system:menu:detail' + + SYSTEM_NOTICE_LIST = 'system:notice:list' + SYSTEM_NOTICE_DETAIL = 'system:notice:detail' + + SYSTEM_POST_LIST = 'system:post:list' + SYSTEM_POST_DETAIL = 'system:post:detail' + SYSTEM_POST_EXPORT = 'system:post:export' + + SYSTEM_ROLE_DEPT_TREE = 'system:role:dept-tree' + SYSTEM_ROLE_LIST = 'system:role:list' + SYSTEM_ROLE_DETAIL = 'system:role:detail' + SYSTEM_ROLE_ALLOCATED_USER_LIST = 'system:role:allocated-user-list' + SYSTEM_ROLE_UNALLOCATED_USER_LIST = 'system:role:unallocated-user-list' + SYSTEM_ROLE_EXPORT = 'system:role:export' + SYSTEM_ROLE_AUTH_USER_SELECT_ALL = 'system:role:auth-user-select-all' + SYSTEM_ROLE_AUTH_USER_CANCEL = 'system:role:auth-user-cancel' + SYSTEM_ROLE_AUTH_USER_CANCEL_ALL = 'system:role:auth-user-cancel-all' + + SYSTEM_USER_DEPT_TREE = 'system:user:dept-tree' + SYSTEM_USER_LIST = 'system:user:list' + SYSTEM_USER_PROFILE = 'system:user:profile' + SYSTEM_USER_DETAIL = 'system:user:detail' + SYSTEM_USER_PROFILE_AVATAR = 'system:user:profile-avatar' + SYSTEM_USER_IMPORT = 'system:user:import' + SYSTEM_USER_EXPORT = 'system:user:export' + + AI_MODEL_LIST = 'ai:model:list' + AI_MODEL_ALL = 'ai:model:all' + AI_MODEL_DETAIL = 'ai:model:detail' + AI_CHAT_CONFIG = 'ai:chat:config' + AI_CHAT_SEND = 'ai:chat:send' + AI_CHAT_CANCEL = 'ai:chat:cancel' + + TOOL_GEN_LIST = 'tool:gen:list' + TOOL_GEN_DB_LIST = 'tool:gen:db-list' + TOOL_GEN_DETAIL = 'tool:gen:detail' + TOOL_GEN_PREVIEW = 'tool:gen:preview' + + TOOL_GEN_IMPORT_TABLE = 'tool:gen:import-table' + TOOL_GEN_CREATE_TABLE = 'tool:gen:create-table' + TOOL_GEN_BATCH_GEN_CODE = 'tool:gen:batch-gen-code' + TOOL_GEN_GEN_CODE_LOCAL = 'tool:gen:gen-code-local' + TOOL_GEN_SYNC_DB = 'tool:gen:sync-db' + + +class ApiGroup: + """ + 接口命名空间分组常量 + + 当前主要用于 `ApiCacheEvict` 批量清理关联命名空间,分组成员均来自 + `ApiNamespace`,按业务写操作影响范围收敛管理。 + + PERMISSION_MUTATION: 权限与菜单视图关联命名空间分组 + DATA_SCOPE_MUTATION: 数据范围相关视图关联命名空间分组 + MENU_MUTATION: 菜单写操作关联命名空间分组 + JOB_MUTATION: 定时任务写操作关联命名空间分组 + POST_MUTATION: 岗位写操作关联命名空间分组 + NOTICE_MUTATION: 通知公告写操作关联命名空间分组 + ROLE_ENTITY_MUTATION: 角色实体信息变更关联命名空间分组 + ROLE_PERMISSION_MUTATION: 角色权限变更关联命名空间分组 + ROLE_MUTATION: 角色通用写操作关联命名空间组合分组 + USER_ENTITY_MUTATION: 用户实体信息变更关联命名空间分组 + USER_PERMISSION_MUTATION: 用户权限变更关联命名空间分组 + USER_INFO_MUTATION: 用户资料与安全相关写操作关联命名空间分组 + LOGIN_SUCCESS_MUTATION: 登录成功后关联命名空间分组 + LOGOUT_MUTATION: 登出后关联命名空间分组 + CONFIG_MUTATION: 参数配置写操作关联命名空间分组 + DICT_TYPE_MUTATION: 字典类型写操作关联命名空间分组 + DICT_DATA_MUTATION: 字典数据写操作关联命名空间分组 + AI_MODEL_MUTATION: AI模型写操作关联命名空间分组 + AI_CHAT_CONFIG_MUTATION: AI对话配置写操作关联命名空间分组 + GEN_MUTATION: 代码生成写操作关联命名空间分组 + """ + + PERMISSION_MUTATION = ( + ApiNamespace.LOGIN_USER_INFO, + ApiNamespace.LOGIN_USER_ROUTERS, + ApiNamespace.SYSTEM_MENU_TREE, + ApiNamespace.SYSTEM_MENU_ROLE_TREE, + ApiNamespace.SYSTEM_MENU_LIST, + ) + + DATA_SCOPE_MUTATION = ( + ApiNamespace.LOGIN_USER_INFO, + ApiNamespace.SYSTEM_DEPT_EDIT_TREE, + ApiNamespace.SYSTEM_DEPT_LIST, + ApiNamespace.SYSTEM_DEPT_DETAIL, + ApiNamespace.SYSTEM_ROLE_DEPT_TREE, + ApiNamespace.SYSTEM_ROLE_LIST, + ApiNamespace.SYSTEM_ROLE_DETAIL, + ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST, + ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST, + ApiNamespace.SYSTEM_USER_DEPT_TREE, + ApiNamespace.SYSTEM_USER_LIST, + ApiNamespace.SYSTEM_USER_DETAIL, + ApiNamespace.SYSTEM_USER_PROFILE, + ApiNamespace.AI_MODEL_LIST, + ApiNamespace.AI_MODEL_ALL, + ApiNamespace.AI_MODEL_DETAIL, + ) + + MENU_MUTATION = ( + *PERMISSION_MUTATION, + ApiNamespace.SYSTEM_MENU_DETAIL, + ) + + JOB_MUTATION = ( + ApiNamespace.MONITOR_JOB_LIST, + ApiNamespace.MONITOR_JOB_DETAIL, + ) + + POST_MUTATION = ( + ApiNamespace.SYSTEM_POST_LIST, + ApiNamespace.SYSTEM_POST_DETAIL, + ApiNamespace.SYSTEM_USER_DETAIL, + ApiNamespace.SYSTEM_USER_PROFILE, + ) + + NOTICE_MUTATION = ( + ApiNamespace.SYSTEM_NOTICE_LIST, + ApiNamespace.SYSTEM_NOTICE_DETAIL, + ) + + ROLE_ENTITY_MUTATION = ( + ApiNamespace.SYSTEM_ROLE_DEPT_TREE, + ApiNamespace.SYSTEM_ROLE_LIST, + ApiNamespace.SYSTEM_ROLE_DETAIL, + ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST, + ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST, + ApiNamespace.SYSTEM_MENU_ROLE_TREE, + ApiNamespace.SYSTEM_USER_DETAIL, + ) + + ROLE_PERMISSION_MUTATION = ( + *ROLE_ENTITY_MUTATION, + ApiNamespace.SYSTEM_USER_PROFILE, + ApiNamespace.LOGIN_USER_INFO, + *PERMISSION_MUTATION, + ) + + ROLE_MUTATION = ( + *ROLE_PERMISSION_MUTATION, + *DATA_SCOPE_MUTATION, + ) + + USER_ENTITY_MUTATION = ( + ApiNamespace.SYSTEM_USER_LIST, + ApiNamespace.SYSTEM_USER_DETAIL, + ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST, + ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST, + ) + + USER_PERMISSION_MUTATION = ( + *DATA_SCOPE_MUTATION, + *PERMISSION_MUTATION, + ) + + USER_INFO_MUTATION = ( + ApiNamespace.SYSTEM_USER_LIST, + ApiNamespace.SYSTEM_USER_DETAIL, + ApiNamespace.SYSTEM_USER_PROFILE, + ApiNamespace.LOGIN_USER_INFO, + ) + + LOGIN_SUCCESS_MUTATION = ( + ApiNamespace.SYSTEM_USER_LIST, + ApiNamespace.LOGIN_USER_INFO, + ApiNamespace.LOGIN_USER_ROUTERS, + ApiNamespace.SYSTEM_USER_PROFILE, + ApiNamespace.SYSTEM_USER_DETAIL, + ) + + LOGOUT_MUTATION = ( + ApiNamespace.LOGIN_USER_INFO, + ApiNamespace.LOGIN_USER_ROUTERS, + ) + + CONFIG_MUTATION = ( + ApiNamespace.SYSTEM_CONFIG_LIST, + ApiNamespace.SYSTEM_CONFIG_DETAIL, + ) + + DICT_TYPE_MUTATION = ( + ApiNamespace.SYSTEM_DICT_TYPE_LIST, + ApiNamespace.SYSTEM_DICT_TYPE_OPTIONS, + ApiNamespace.SYSTEM_DICT_TYPE_DETAIL, + ApiNamespace.SYSTEM_DICT_DATA_LIST, + ApiNamespace.SYSTEM_DICT_DATA_DETAIL, + ) + + DICT_DATA_MUTATION = ( + ApiNamespace.SYSTEM_DICT_DATA_LIST, + ApiNamespace.SYSTEM_DICT_DATA_DETAIL, + ) + + AI_MODEL_MUTATION = ( + ApiNamespace.AI_MODEL_LIST, + ApiNamespace.AI_MODEL_ALL, + ApiNamespace.AI_MODEL_DETAIL, + ) + + AI_CHAT_CONFIG_MUTATION = (ApiNamespace.AI_CHAT_CONFIG,) + + GEN_MUTATION = ( + ApiNamespace.TOOL_GEN_LIST, + ApiNamespace.TOOL_GEN_DB_LIST, + ApiNamespace.TOOL_GEN_DETAIL, + ApiNamespace.TOOL_GEN_PREVIEW, + ) + + +class MenuConstant: + """ + 菜单常量 + + TYPE_DIR: 菜单类型(目录) + TYPE_MENU: 菜单类型(菜单) + TYPE_BUTTON: 菜单类型(按钮) + YES_FRAME: 是否菜单外链(是) + NO_FRAME: 是否菜单外链(否) + LAYOUT: Layout组件标识 + PARENT_VIEW: ParentView组件标识 + INNER_LINK: InnerLink组件标识 + """ + + TYPE_DIR = 'M' + TYPE_MENU = 'C' + TYPE_BUTTON = 'F' + YES_FRAME = 0 + NO_FRAME = 1 + LAYOUT = 'Layout' + PARENT_VIEW = 'ParentView' + INNER_LINK = 'InnerLink' + + +class GenConstant: + """ + 代码生成常量 + + TPL_CRUD: 单表(增删改查 + TPL_TREE: 树表(增删改查) + TPL_SUB: 主子表(增删改查) + TREE_CODE: 树编码字段 + TREE_PARENT_CODE: 树父编码字段 + TREE_NAME: 树名称字段 + PARENT_MENU_ID: 上级菜单ID字段 + PARENT_MENU_NAME: 上级菜单名称字段 + COLUMNTYPE_STR: 数据库字符串类型 + COLUMNTYPE_TEXT: 数据库文本类型 + COLUMNTYPE_TIME: 数据库时间类型 + COLUMNTYPE_GEOMETRY: 数据库字空间类型 + COLUMNTYPE_NUMBER: 数据库数字类型 + COLUMNNAME_NOT_EDIT: 页面不需要编辑字段 + COLUMNNAME_NOT_LIST: 页面不需要显示的列表字段 + COLUMNNAME_NOT_QUERY: 页面不需要查询字段 + BASE_ENTITY: Entity基类字段 + TREE_ENTITY: Tree基类字段 + HTML_INPUT: 文本框 + HTML_TEXTAREA: 文本域 + HTML_SELECT: 下拉框 + HTML_RADIO: 单选框 + HTML_CHECKBOX: 复选框 + HTML_DATETIME: 日期控件 + HTML_IMAGE_UPLOAD: 图片上传控件 + HTML_FILE_UPLOAD: 文件上传控件 + HTML_EDITOR: 富文本控件 + TYPE_DECIMAL: 高精度计算类型 + TYPE_DATE: 时间类型 + QUERY_LIKE: 模糊查询 + QUERY_EQ: 相等查询 + REQUIRE: 需要 + DB_TO_SQLALCHEMY_TYPE_MAPPING: 数据库类型与sqlalchemy类型映射 + DB_TO_PYTHON_TYPE_MAPPING: 数据库类型与python类型映射 + """ + + TPL_CRUD = 'crud' + TPL_TREE = 'tree' + TPL_SUB = 'sub' + TREE_CODE = 'treeCode' + TREE_PARENT_CODE = 'treeParentCode' + TREE_NAME = 'treeName' + PARENT_MENU_ID = 'parentMenuId' + PARENT_MENU_NAME = 'parentMenuName' + COLUMNTYPE_STR = ( + ['character varying', 'varchar', 'character', 'char'] + if DataBaseConfig.db_type == 'postgresql' + else ['char', 'varchar', 'nvarchar', 'varchar2'] + ) + COLUMNTYPE_TEXT = ( + ['text', 'citext'] if DataBaseConfig.db_type == 'postgresql' else ['tinytext', 'text', 'mediumtext', 'longtext'] + ) + COLUMNTYPE_TIME = ( + [ + 'date', + 'time', + 'time with time zone', + 'time without time zone', + 'timestamp', + 'timestamp with time zone', + 'timestamp without time zone', + 'interval', + ] + if DataBaseConfig.db_type == 'postgresql' + else ['datetime', 'time', 'date', 'timestamp'] + ) + COLUMNTYPE_GEOMETRY = ( + ['point', 'line', 'lseg', 'box', 'path', 'polygon', 'circle'] + if DataBaseConfig.db_type == 'postgresql' + else [ + 'geometry', + 'point', + 'linestring', + 'polygon', + 'multipoint', + 'multilinestring', + 'multipolygon', + 'geometrycollection', + ] + ) + COLUMNTYPE_NUMBER = [ + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'number', + 'integer', + 'bit', + 'bigint', + 'float', + 'double', + 'decimal', + ] + COLUMNNAME_NOT_ADD_SHOW = ['create_by', 'create_time'] + COLUMNNAME_NOT_EDIT_SHOW = ['update_by', 'update_time'] + COLUMNNAME_NOT_EDIT = ['id', 'create_by', 'create_time', 'del_flag'] + COLUMNNAME_NOT_LIST = ['id', 'create_by', 'create_time', 'del_flag', 'update_by', 'update_time'] + COLUMNNAME_NOT_QUERY = ['id', 'create_by', 'create_time', 'del_flag', 'update_by', 'update_time', 'remark'] + BASE_ENTITY = ['createBy', 'createTime', 'updateBy', 'updateTime', 'remark'] + TREE_ENTITY = ['parentName', 'parentId', 'orderNum', 'ancestors', 'children'] + HTML_INPUT = 'input' + HTML_TEXTAREA = 'textarea' + HTML_SELECT = 'select' + HTML_RADIO = 'radio' + HTML_CHECKBOX = 'checkbox' + HTML_DATETIME = 'datetime' + HTML_IMAGE_UPLOAD = 'imageUpload' + HTML_FILE_UPLOAD = 'fileUpload' + HTML_EDITOR = 'editor' + TYPE_DECIMAL = 'Decimal' + TYPE_DATE = ['date', 'time', 'datetime'] + QUERY_LIKE = 'LIKE' + QUERY_EQ = 'EQ' + REQUIRE = '1' + DB_TO_SQLALCHEMY_TYPE_MAPPING = ( + { + 'boolean': 'Boolean', + 'smallint': 'SmallInteger', + 'integer': 'Integer', + 'bigint': 'BigInteger', + 'real': 'Float', + 'double precision': 'Float', + 'numeric': 'Numeric', + 'character varying': 'String', + 'character': 'String', + 'text': 'Text', + 'bytea': 'LargeBinary', + 'date': 'Date', + 'time': 'Time', + 'time with time zone': 'Time', + 'time without time zone': 'Time', + 'timestamp': 'DateTime', + 'timestamp with time zone': 'DateTime', + 'timestamp without time zone': 'DateTime', + 'interval': 'Interval', + 'json': 'JSON', + 'jsonb': 'JSONB', + 'uuid': 'Uuid', + 'inet': 'INET', + 'cidr': 'CIDR', + 'macaddr': 'MACADDR', + 'point': 'Geometry', + 'line': 'Geometry', + 'lseg': 'Geometry', + 'box': 'Geometry', + 'path': 'Geometry', + 'polygon': 'Geometry', + 'circle': 'Geometry', + 'bit': 'Bit', + 'bit varying': 'Bit', + 'tsvector': 'TSVECTOR', + 'tsquery': 'TSQUERY', + 'xml': 'String', + 'array': 'ARRAY', + 'composite': 'JSON', + 'enum': 'Enum', + 'range': 'Range', + 'money': 'Numeric', + 'pg_lsn': 'BigInteger', + 'txid_snapshot': 'String', + 'oid': 'BigInteger', + 'regproc': 'String', + 'regclass': 'String', + 'regtype': 'String', + 'regrole': 'String', + 'regnamespace': 'String', + 'int2vector': 'ARRAY', + 'oidvector': 'ARRAY', + 'pg_node_tree': 'Text', + } + if DataBaseConfig.db_type == 'postgresql' + else { + # 数值类型 + 'TINYINT': 'SmallInteger', + 'SMALLINT': 'SmallInteger', + 'MEDIUMINT': 'Integer', + 'INT': 'Integer', + 'INTEGER': 'Integer', + 'BIGINT': 'BigInteger', + 'FLOAT': 'Float', + 'DOUBLE': 'Float', + 'DECIMAL': 'DECIMAL', + 'BIT': 'Integer', + # 日期和时间类型 + 'DATE': 'Date', + 'TIME': 'Time', + 'DATETIME': 'DateTime', + 'TIMESTAMP': 'TIMESTAMP', + 'YEAR': 'Integer', + # 字符串类型 + 'CHAR': 'CHAR', + 'VARCHAR': 'String', + 'TINYTEXT': 'Text', + 'TEXT': 'Text', + 'MEDIUMTEXT': 'Text', + 'LONGTEXT': 'Text', + 'BINARY': 'BINARY', + 'VARBINARY': 'VARBINARY', + 'TINYBLOB': 'LargeBinary', + 'BLOB': 'LargeBinary', + 'MEDIUMBLOB': 'LargeBinary', + 'LONGBLOB': 'LargeBinary', + # 枚举和集合类型 + 'ENUM': 'Enum', + 'SET': 'String', + # JSON 类型 + 'JSON': 'JSON', + # 空间数据类型(需要扩展支持,如 GeoAlchemy2) + 'GEOMETRY': 'Geometry', # 需要安装 geoalchemy2 + 'POINT': 'Geometry', + 'LINESTRING': 'Geometry', + 'POLYGON': 'Geometry', + 'MULTIPOINT': 'Geometry', + 'MULTILINESTRING': 'Geometry', + 'MULTIPOLYGON': 'Geometry', + 'GEOMETRYCOLLECTION': 'Geometry', + } + ) + DB_TO_PYTHON_TYPE_MAPPING = ( + { + 'boolean': 'bool', + 'smallint': 'int', + 'integer': 'int', + 'bigint': 'int', + 'real': 'float', + 'double precision': 'float', + 'numeric': 'Decimal', + 'character varying': 'str', + 'character': 'str', + 'text': 'str', + 'bytea': 'bytes', + 'date': 'date', + 'time': 'time', + 'time with time zone': 'time', + 'time without time zone': 'time', + 'timestamp': 'datetime', + 'timestamp with time zone': 'datetime', + 'timestamp without time zone': 'datetime', + 'interval': 'timedelta', + 'json': 'dict', + 'jsonb': 'dict', + 'uuid': 'str', + 'inet': 'str', + 'cidr': 'str', + 'macaddr': 'str', + 'point': 'list', + 'line': 'list', + 'lseg': 'list', + 'box': 'list', + 'path': 'list', + 'polygon': 'list', + 'circle': 'list', + 'bit': 'int', + 'bit varying': 'int', + 'tsvector': 'str', + 'tsquery': 'str', + 'xml': 'str', + 'array': 'list', + 'composite': 'dict', + 'enum': 'str', + 'range': 'list', + 'money': 'Decimal', + 'pg_lsn': 'int', + 'txid_snapshot': 'str', + 'oid': 'int', + 'regproc': 'str', + 'regclass': 'str', + 'regtype': 'str', + 'regrole': 'str', + 'regnamespace': 'str', + 'int2vector': 'list', + 'oidvector': 'list', + 'pg_node_tree': 'str', + } + if DataBaseConfig.db_type == 'postgresql' + else { + # 数值类型 + 'TINYINT': 'int', + 'SMALLINT': 'int', + 'MEDIUMINT': 'int', + 'INT': 'int', + 'INTEGER': 'int', + 'BIGINT': 'int', + 'FLOAT': 'float', + 'DOUBLE': 'float', + 'DECIMAL': 'Decimal', + 'BIT': 'int', + # 日期和时间类型 + 'DATE': 'date', + 'TIME': 'time', + 'DATETIME': 'datetime', + 'TIMESTAMP': 'datetime', + 'YEAR': 'int', + # 字符串类型 + 'CHAR': 'str', + 'VARCHAR': 'str', + 'TINYTEXT': 'str', + 'TEXT': 'str', + 'MEDIUMTEXT': 'str', + 'LONGTEXT': 'str', + 'BINARY': 'bytes', + 'VARBINARY': 'bytes', + 'TINYBLOB': 'bytes', + 'BLOB': 'bytes', + 'MEDIUMBLOB': 'bytes', + 'LONGBLOB': 'bytes', + # 枚举和集合类型 + 'ENUM': 'str', + 'SET': 'str', + # JSON 类型 + 'JSON': 'dict', + # 空间数据类型(通常需要特殊处理) + 'GEOMETRY': 'bytes', + 'POINT': 'bytes', + 'LINESTRING': 'bytes', + 'POLYGON': 'bytes', + 'MULTIPOINT': 'bytes', + 'MULTILINESTRING': 'bytes', + 'MULTIPOLYGON': 'bytes', + 'GEOMETRYCOLLECTION': 'bytes', + } + ) diff --git a/shierfeng-fastapi-backend/common/context.py b/shierfeng-fastapi-backend/common/context.py new file mode 100644 index 0000000..67e933c --- /dev/null +++ b/shierfeng-fastapi-backend/common/context.py @@ -0,0 +1,96 @@ +import re +from contextvars import ContextVar, Token +from typing import Literal + +from exceptions.exception import LoginException +from module_admin.entity.vo.user_vo import CurrentUserModel + +# 定义上下文变量 +# 存储当前请求的编译后的排除路由模式列表 +current_exclude_patterns: ContextVar[ + list[dict[str, str | list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] | re.Pattern]] | None +] = ContextVar('current_exclude_patterns', default=None) +# 存储当前用户信息 +current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None) + + +class RequestContext: + """ + 请求上下文管理类,用于设置和清理上下文变量 + """ + + @staticmethod + def set_current_exclude_patterns( + exclude_patterns: list[ + dict[str, str | list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] | re.Pattern] + ], + ) -> Token: + """ + 设置当前请求的编译后的排除路由模式列表 + + :param exclude_patterns: 编译后的排除路由模式列表 + :return: 上下文变量令牌,用于重置 + """ + return current_exclude_patterns.set(exclude_patterns) + + @staticmethod + def get_current_exclude_patterns() -> list[ + dict[str, str | list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] | re.Pattern] + ]: + """ + 获取当前请求的编译后的排除路由模式列表 + + :return: 编译后的排除路由模式列表 + """ + _exclude_patterns = current_exclude_patterns.get() + if _exclude_patterns is None: + _exclude_patterns = [] + return _exclude_patterns + + @staticmethod + def set_current_user(user: CurrentUserModel) -> Token: + """ + 设置当前用户信息 + + :param user: 用户信息 + :return: 上下文变量令牌,用于重置 + """ + return current_user.set(user) + + @staticmethod + def get_current_user() -> CurrentUserModel: + """ + 获取当前用户信息 + + :return: 用户信息 + """ + _current_user = current_user.get() + if _current_user is None: + raise LoginException(data='', message='当前用户信息为空,请检查是否已登录') + return _current_user + + @staticmethod + def reset_current_exclude_patterns(token: Token) -> None: + """ + 重置当前请求的编译后的排除路由模式列表 + + :param token: 设置编译后的排除路由模式列表时返回的令牌 + """ + current_exclude_patterns.reset(token) + + @staticmethod + def reset_current_user(token: Token) -> None: + """ + 重置当前用户信息 + + :param token: 设置用户信息时返回的令牌 + """ + current_user.reset(token) + + @staticmethod + def clear_all() -> None: + """ + 清除所有上下文变量 + """ + current_exclude_patterns.set(None) + current_user.set(None) diff --git a/shierfeng-fastapi-backend/common/enums.py b/shierfeng-fastapi-backend/common/enums.py new file mode 100644 index 0000000..de86877 --- /dev/null +++ b/shierfeng-fastapi-backend/common/enums.py @@ -0,0 +1,79 @@ +from enum import Enum + + +class HttpMethod(str, Enum): + """ + HTTP请求方法枚举 + + GET: 获取资源 + POST: 创建资源 + PUT: 整体更新资源 + DELETE: 删除资源 + PATCH: 局部更新资源 + HEAD: 获取响应头 + OPTIONS: 获取允许的方法信息 + TRACE: 回显诊断请求 + CONNECT: 建立隧道连接 + """ + + GET = 'GET' + POST = 'POST' + PUT = 'PUT' + DELETE = 'DELETE' + PATCH = 'PATCH' + HEAD = 'HEAD' + OPTIONS = 'OPTIONS' + TRACE = 'TRACE' + CONNECT = 'CONNECT' + + +class BusinessType(Enum): + """ + 业务操作类型 + + OTHER: 其它 + INSERT: 新增 + UPDATE: 修改 + DELETE: 删除 + GRANT: 授权 + EXPORT: 导出 + IMPORT: 导入 + FORCE: 强退 + GENCODE: 生成代码 + CLEAN: 清空数据 + """ + + OTHER = 0 + INSERT = 1 + UPDATE = 2 + DELETE = 3 + GRANT = 4 + EXPORT = 5 + IMPORT = 6 + FORCE = 7 + GENCODE = 8 + CLEAN = 9 + + +class RedisInitKeyConfig(Enum): + """ + 系统内置Redis键名 + """ + + @property + def key(self) -> str | None: + return self.value.get('key') + + @property + def remark(self) -> str | None: + return self.value.get('remark') + + ACCESS_TOKEN = {'key': 'access_token', 'remark': '登录令牌信息'} + SYS_DICT = {'key': 'sys_dict', 'remark': '数据字典'} + SYS_CONFIG = {'key': 'sys_config', 'remark': '配置信息'} + API_CACHE = {'key': 'api_cache', 'remark': '接口响应缓存'} + API_RATE_LIMIT = {'key': 'api_rate_limit', 'remark': '接口限流'} + CAPTCHA_CODES = {'key': 'captcha_codes', 'remark': '图片验证码'} + ACCOUNT_LOCK = {'key': 'account_lock', 'remark': '用户锁定'} + PASSWORD_ERROR_COUNT = {'key': 'password_error_count', 'remark': '密码错误次数'} + SMS_CODE = {'key': 'sms_code', 'remark': '短信验证码'} diff --git a/shierfeng-fastapi-backend/common/router.py b/shierfeng-fastapi-backend/common/router.py new file mode 100644 index 0000000..f7106c5 --- /dev/null +++ b/shierfeng-fastapi-backend/common/router.py @@ -0,0 +1,393 @@ +import glob +import importlib +import os +import sys +from collections.abc import Callable, Sequence +from enum import Enum +from typing import Annotated, Any, Literal + +from annotated_doc import Doc +from fastapi import FastAPI, params +from fastapi.datastructures import Default +from fastapi.routing import APIRoute, APIRouter +from fastapi.utils import generate_unique_id +from starlette.responses import JSONResponse, Response +from starlette.routing import BaseRoute +from starlette.types import ASGIApp, Lifespan +from typing_extensions import deprecated + + +class APIRouterPro(APIRouter): + """ + `APIRouterPro` class, inherited from the `APIRouter` class, it has all the functions of `APIRouter` and provides some additional parameter settings. + `APIRouter` class, used to group *path operations*, for example to structure + an app in multiple files. It would then be included in the `FastAPI` app, or + in another `APIRouter` (ultimately included in the app). + + Read more about it in the + [FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/). + + ## Example + + ```python + from common.router import APIRouterPro, FastAPI + + app = FastAPI() + router = APIRouterPro() + + + @router.get('/users/', tags=['users']) + async def read_users(): + return [{'username': 'Rick'}, {'username': 'Morty'}] + + + app.include_router(router) + ``` + """ + + def __init__( # noqa: PLR0913 + self, + *, + prefix: Annotated[str, Doc('An optional path prefix for the router.')] = '', + order_num: Annotated[int, Doc('An optional order number for the router.')] = 100, + auto_register: Annotated[bool, Doc('An optional auto register flag for the router.')] = True, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to all the *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to all the + *path operations* in this router. + + Read more about it in the + [FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + default_response_class: Annotated[ + type[Response], + Doc( + """ + The default response class to be used. + + Read more in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#default-response-class). + """ + ), + ] = Default(JSONResponse), + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses to be shown in OpenAPI. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Additional Responses in OpenAPI](https://fastapi.tiangolo.com/advanced/additional-responses/). + + And in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + OpenAPI callbacks that should apply to all *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + routes: Annotated[ + list[BaseRoute] | None, + Doc( + """ + **Note**: you probably shouldn't use this parameter, it is inherited + from Starlette and supported for compatibility. + + --- + + A list of routes to serve incoming HTTP and WebSocket requests. + """ + ), + deprecated( + """ + You normally wouldn't use this parameter with FastAPI, it is inherited + from Starlette and supported for compatibility. + + In FastAPI, you normally would use the *path operation methods*, + like `router.get()`, `router.post()`, etc. + """ + ), + ] = None, + redirect_slashes: Annotated[ + bool, + Doc( + """ + Whether to detect and redirect slashes in URLs when the client doesn't + use the same format. + """ + ), + ] = True, + default: Annotated[ + ASGIApp | None, + Doc( + """ + Default function handler for this router. Used to handle + 404 Not Found errors. + """ + ), + ] = None, + dependency_overrides_provider: Annotated[ + Any | None, + Doc( + """ + Only used internally by FastAPI to handle dependency overrides. + + You shouldn't need to use it. It normally points to the `FastAPI` app + object. + """ + ), + ] = None, + route_class: Annotated[ + type[APIRoute], + Doc( + """ + Custom route (*path operation*) class to be used by this router. + + Read more about it in the + [FastAPI docs for Custom Request and APIRoute class](https://fastapi.tiangolo.com/how-to/custom-request-and-route/#custom-apiroute-class-in-a-router). + """ + ), + ] = APIRoute, + on_startup: Annotated[ + Sequence[Callable[[], Any]] | None, + Doc( + """ + A list of startup event handler functions. + + You should instead use the `lifespan` handlers. + + Read more in the [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + on_shutdown: Annotated[ + Sequence[Callable[[], Any]] | None, + Doc( + """ + A list of shutdown event handler functions. + + You should instead use the `lifespan` handlers. + + Read more in the + [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + # the generic to Lifespan[AppType] is the type of the top level application + # which the router cannot know statically, so we use typing.Any + lifespan: Annotated[ + Lifespan[Any] | None, + Doc( + """ + A `Lifespan` context manager handler. This replaces `startup` and + `shutdown` functions with a single context manager. + + Read more in the + [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark all *path operations* in this router as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) all the *path operations* in this router in the + generated OpenAPI. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> None: + self.order_num = order_num + self.auto_register = auto_register + super().__init__( + prefix=prefix, + tags=tags, + dependencies=dependencies, + default_response_class=default_response_class, + responses=responses, + callbacks=callbacks, + routes=routes, + redirect_slashes=redirect_slashes, + default=default, + dependency_overrides_provider=dependency_overrides_provider, + route_class=route_class, + on_startup=on_startup, + on_shutdown=on_shutdown, + lifespan=lifespan, + deprecated=deprecated, + include_in_schema=include_in_schema, + generate_unique_id_function=generate_unique_id_function, + ) + + +class RouterRegister: + """ + 路由注册器,用于自动注册所有controller目录下的路由 + """ + + def __init__(self, app: FastAPI) -> None: + """ + 初始化路由注册器 + + :param app: FastAPI对象 + """ + self.app = app + # 获取项目根目录 + self.project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + sys.path.insert(0, self.project_root) + + def _find_controller_files(self) -> list[str]: + """ + 查找所有controller目录下的py文件 + + :return: py文件路径列表 + """ + pattern = os.path.join(self.project_root, '*', 'controller', '[!_]*.py') + return sorted(glob.glob(pattern)) + + def _import_module_and_get_routers(self, controller_files: list[str]) -> list[tuple[str, APIRouter]]: + """ + 导入模块并获取路由实例 + + :param controller_files: controller目录下的py文件路径列表 + :return: 路由实例列表 + """ + routers = [] + for file_path in controller_files: + # 计算模块路径 + relative_path = os.path.relpath(file_path, self.project_root) + module_name = relative_path.replace(os.sep, '.')[:-3] + + # 动态导入模块 + module = importlib.import_module(module_name) + # 直接遍历模块__dict__,只检查模块自身定义的属性 + for attr_name, attr in module.__dict__.items(): + # 对于APIRouterPro实例,只有当auto_register=True时才添加 + if isinstance(attr, APIRouterPro): + if attr.auto_register: + routers.append((attr_name, attr)) + # 对于APIRouter实例,直接添加 + elif isinstance(attr, APIRouter): + routers.append((attr_name, attr)) + return routers + + def _sort_routers(self, routers: list[tuple[str, APIRouter]]) -> list[tuple[str, APIRouter]]: + """ + 按规则排序路由 + + :param routers: 路由实例列表 + :return: 排序后的路由实例列表 + """ + + # 按规则排序路由 + def sort_key(item: tuple[str, APIRouter]) -> tuple[Literal[0], int, str] | tuple[Literal[1], str]: + attr_name, router = item + # APIRouterPro实例按order_num排序,序号越小越靠前 + if isinstance(router, APIRouterPro): + return (0, router.order_num, attr_name) + # APIRouter实例按变量名首字母排序 + return (1, attr_name) + + return sorted(routers, key=sort_key) + + def _register_routers_to_app(self, routers: list[tuple[str, APIRouter]]) -> None: + """ + 将路由注册到FastAPI应用 + + :param routers: 排序后的路由实例列表 + :return: None + """ + for _attr_name, router in routers: + self.app.include_router(router=router) + + def register_routers(self) -> None: + """ + 自动注册所有controller目录下的路由 + + :return: None + """ + # 查找所有controller目录下的py文件 + controller_files = self._find_controller_files() + # 导入模块并获取路由实例 + routers = self._import_module_and_get_routers(controller_files) + # 按规则排序路由 + sorted_routers = self._sort_routers(routers) + # 注册路由到FastAPI应用 + self._register_routers_to_app(sorted_routers) + + +def auto_register_routers(app: FastAPI) -> None: + """ + 自动注册所有controller目录下的路由 + + :param app: FastAPI对象 + :return: None + """ + # 使用路由注册器进行注册 + router_register = RouterRegister(app) + router_register.register_routers() diff --git a/shierfeng-fastapi-backend/common/vo.py b/shierfeng-fastapi-backend/common/vo.py new file mode 100644 index 0000000..951facd --- /dev/null +++ b/shierfeng-fastapi-backend/common/vo.py @@ -0,0 +1,105 @@ +from datetime import datetime +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, create_model +from pydantic.alias_generators import to_camel +from typing_extensions import Self + +from common.constant import HttpStatusConstant + +T = TypeVar('T') + + +class CrudResponseModel(BaseModel): + """ + 操作响应模型 + """ + + is_success: bool = Field(description='操作是否成功') + message: str = Field(description='响应信息') + result: Any | None = Field(default=None, description='响应结果') + + +class ResponseBaseModel(BaseModel): + """ + 响应模型 + """ + + code: int = Field(default=HttpStatusConstant.SUCCESS, description='响应码') + msg: str = Field(default='操作成功', description='响应信息') + success: bool = Field(default=True, description='响应是否成功') + time: datetime = Field(default_factory=datetime.now, description='响应时间') + + +class DynamicResponseModel(ResponseBaseModel, Generic[T]): + """ + 动态响应模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + def __class_getitem__(cls, item: Any) -> Any | Self: + """ + 当使用 DynamicResponseModel[Item] 语法时,动态创建一个包含所有字段的新模型 + """ + # 检查是否已经为该类型创建了模型 + if not hasattr(cls, '_cached_models'): + cls._cached_models = {} + + if item in cls._cached_models: + return cls._cached_models[item] + + # 检查item是否为Pydantic模型 + if not hasattr(item, 'model_fields'): + raise TypeError(f'{item} 不是一个Pydantic模型,请使用Pydantic模型作为泛型参数') + + # 获取ResponseBaseModel的字段 + base_fields = {} + for field_name, field in cls.model_fields.items(): + base_fields[field_name] = (field.annotation, field) + + # 获取泛型类型的字段 + item_fields = {} + for field_name, field in item.model_fields.items(): + item_fields[field_name] = (field.annotation, field) + + # 合并所有字段 + all_fields = {**base_fields, **item_fields} + + # 动态创建新模型 + new_model = create_model( + f'DynamicResponseModel[{item.__name__}]', __base__=cls, __config__=cls.model_config, **all_fields + ) + + # 缓存模型 + cls._cached_models[item] = new_model + + return new_model + + +class PageModel(BaseModel, Generic[T]): + """ + 分页模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + rows: list[T] = Field(description='记录列表') + page_num: int = Field(description='当前页码') + page_size: int = Field(description='每页记录数') + total: int = Field(description='总记录数') + has_next: bool = Field(description='是否有下一页') + + +class PageResponseModel(PageModel, ResponseBaseModel, Generic[T]): + """ + 分页响应模型 + """ + + +class DataResponseModel(ResponseBaseModel, Generic[T]): + """ + 数据响应模型 + """ + + data: T = Field(description='响应数据') diff --git a/shierfeng-fastapi-backend/config/database.py b/shierfeng-fastapi-backend/config/database.py new file mode 100644 index 0000000..e526b42 --- /dev/null +++ b/shierfeng-fastapi-backend/config/database.py @@ -0,0 +1,109 @@ +from urllib.parse import quote_plus + +from sqlalchemy import Engine, create_engine +from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncEngine, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from config.env import DataBaseConfig + + +def build_async_sqlalchemy_database_url() -> str: + """ + 构建异步 SQLAlchemy 数据库连接 URL + + :return: 异步 SQLAlchemy 数据库连接 URL + """ + if DataBaseConfig.db_type == 'postgresql': + return ( + f'postgresql+asyncpg://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' + f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' + ) + return ( + f'mysql+asyncmy://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' + f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' + ) + + +ASYNC_SQLALCHEMY_DATABASE_URL = build_async_sqlalchemy_database_url() + + +def build_sync_sqlalchemy_database_url() -> str: + """ + 构建同步 SQLAlchemy 数据库连接 URL + + :return: 同步 SQLAlchemy 数据库连接 URL + """ + if DataBaseConfig.db_type == 'postgresql': + return ( + f'postgresql+psycopg2://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' + f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' + ) + return ( + f'mysql+pymysql://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' + f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' + ) + + +SYNC_SQLALCHEMY_DATABASE_URL = build_sync_sqlalchemy_database_url() + + +def create_async_db_engine(echo: bool | None = None) -> AsyncEngine: + """ + 创建异步 SQLAlchemy Engine + + :param echo: 可选,是否输出 SQLAlchemy SQL 日志 + :return: 异步 SQLAlchemy Engine + """ + return create_async_engine( + ASYNC_SQLALCHEMY_DATABASE_URL, + echo=DataBaseConfig.db_echo if echo is None else echo, + max_overflow=DataBaseConfig.db_max_overflow, + pool_size=DataBaseConfig.db_pool_size, + pool_recycle=DataBaseConfig.db_pool_recycle, + pool_timeout=DataBaseConfig.db_pool_timeout, + ) + + +def create_sync_db_engine(echo: bool | None = None) -> Engine: + """ + 创建同步 SQLAlchemy Engine + + :param echo: 可选,是否输出 SQLAlchemy SQL 日志 + :return: 同步 SQLAlchemy Engine + """ + return create_engine( + SYNC_SQLALCHEMY_DATABASE_URL, + echo=DataBaseConfig.db_echo if echo is None else echo, + max_overflow=DataBaseConfig.db_max_overflow, + pool_size=DataBaseConfig.db_pool_size, + pool_recycle=DataBaseConfig.db_pool_recycle, + pool_timeout=DataBaseConfig.db_pool_timeout, + ) + + +def create_async_session_local(engine: AsyncEngine) -> async_sessionmaker: + """ + 创建异步 Session 工厂 + + :param engine: 异步 SQLAlchemy Engine + :return: 异步 Session 工厂 + """ + return async_sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def create_sync_session_local(engine: Engine) -> sessionmaker: + """ + 创建同步 Session 工厂 + + :param engine: 同步 SQLAlchemy Engine + :return: 同步 Session 工厂 + """ + return sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +async_engine = create_async_db_engine() +AsyncSessionLocal = create_async_session_local(async_engine) + + +class Base(AsyncAttrs, DeclarativeBase): + pass diff --git a/shierfeng-fastapi-backend/config/env.py b/shierfeng-fastapi-backend/config/env.py new file mode 100644 index 0000000..317013e --- /dev/null +++ b/shierfeng-fastapi-backend/config/env.py @@ -0,0 +1,334 @@ +import argparse +import configparser +import os +import sys +from typing import Literal + +from dotenv import load_dotenv +from pydantic import computed_field +from pydantic_settings import BaseSettings + + +class AppSettings(BaseSettings): + """ + 应用配置 + """ + + app_env: str = 'dev' + app_name: str = 'ShiErFeng-FastAPI' + app_root_path: str = '/dev-api' + app_host: str = '0.0.0.0' + app_port: int = 9099 + app_version: str = '1.0.0' + app_reload: bool = True + app_workers: int = 1 + app_ip_location_query: bool = True + app_same_time_login: bool = True + app_demo_mode: bool = False + app_disable_swagger: bool = False + app_disable_redoc: bool = False + app_trusted_proxy_ips: str = '127.0.0.1,::1' + app_trusted_proxy_hops: int = 1 + + +class JwtSettings(BaseSettings): + """ + Jwt配置 + """ + + jwt_secret_key: str = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' + jwt_algorithm: str = 'HS256' + jwt_expire_minutes: int = 1440 + jwt_redis_expire_minutes: int = 30 + + +class DataBaseSettings(BaseSettings): + """ + 数据库配置 + """ + + db_type: Literal['mysql', 'postgresql'] = 'mysql' + db_host: str = '127.0.0.1' + db_port: int = 3306 + db_username: str = 'root' + db_password: str = 'mysqlroot' + db_database: str = 'shierfeng-fastapi' + db_echo: bool = True + db_max_overflow: int = 10 + db_pool_size: int = 50 + db_pool_recycle: int = 3600 + db_pool_timeout: int = 30 + + @computed_field + @property + def sqlglot_parse_dialect(self) -> str: + if self.db_type == 'postgresql': + return 'postgres' + return self.db_type + + +class RedisSettings(BaseSettings): + """ + Redis配置 + """ + + redis_host: str = '127.0.0.1' + redis_port: int = 6379 + redis_username: str = '' + redis_password: str = '' + redis_database: int = 2 + + +class LogSettings(BaseSettings): + """ + 日志与队列配置 + """ + + log_mask_enabled: bool = True + log_mask_placeholder: str = '******' + log_mask_fields: str = ( + 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,' + 'authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,' + 'sms_code,captcha_code,system_prompt' + ) + log_partial_mask_fields: str = 'phonenumber,phone,mobile,email' + log_config_secret_patterns: str = 'password,token,secret,key,private,credential,access,jwt,captcha,sms' + log_stream_key: str = 'log:stream' + log_stream_group: str = 'log_aggregator' + log_stream_consumer_prefix: str = 'worker' + log_stream_batch_size: int = 100 + log_stream_block_ms: int = 2000 + log_stream_maxlen: int = 100000 + log_stream_claim_idle_ms: int = 60000 + log_stream_claim_interval_ms: int = 5000 + log_stream_claim_batch_size: int = 100 + log_stream_dedup_ttl: int = 3600 + log_stream_dedup_prefix: str = 'log:dedup' + + loguru_json: bool = False + loguru_level: str = 'INFO' + loguru_stdout: bool = True + log_file_enabled: bool = True + log_file_base_dir: str = 'logs' + loguru_rotation: str = '50MB' + loguru_retention: str = '30 days' + loguru_compression: str = 'zip' + log_instance_id: str = 'prod' + log_service_name: str = 'shierfeng-fastapi-backend' + log_worker_id: str = 'auto' + + +class TransportCryptoSettings(BaseSettings): + """ + 传输层加解密配置 + """ + + transport_crypto_enabled: bool = True + transport_crypto_mode: Literal['off', 'optional', 'required'] = 'optional' + transport_crypto_algorithm: str = 'RSA_OAEP_AES_256_GCM' + transport_crypto_kid: str = 'default' + transport_crypto_public_key: str = '' + transport_crypto_private_key: str = '' + transport_crypto_legacy_key_pairs: str = '[]' + transport_crypto_rsa_key_size: int = 2048 + transport_crypto_public_key_ttl_seconds: int = 3600 + transport_crypto_frontend_config_ttl_seconds: int = 300 + transport_crypto_max_get_url_length: int = 4096 + transport_crypto_clock_skew_seconds: int = 120 + transport_crypto_replay_ttl_seconds: int = 300 + transport_crypto_enabled_paths: str = '' + transport_crypto_required_paths: str = '' + transport_crypto_exclude_paths: str = ( + '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,' + '/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' + ) + + +class GenSettings: + """ + 代码生成配置 + """ + + author = 'insistence' + package_name = 'module_admin.system' + auto_remove_pre = False + table_prefix = 'sys_' + allow_overwrite = False + + GEN_PATH = 'vf_admin/gen_path' + + def __init__(self) -> None: + if not os.path.exists(self.GEN_PATH): + os.makedirs(self.GEN_PATH) + + +class UploadSettings: + """ + 上传配置 + """ + + UPLOAD_PREFIX = '/profile' + UPLOAD_PATH = 'vf_admin/upload_path' + UPLOAD_MACHINE = 'A' + DEFAULT_ALLOWED_EXTENSION = [ + # 图片 + 'bmp', + 'gif', + 'jpg', + 'jpeg', + 'png', + # word excel powerpoint + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'html', + 'htm', + 'txt', + # 压缩文件 + 'rar', + 'zip', + 'gz', + 'bz2', + # 视频格式 + 'mp4', + 'avi', + 'rmvb', + # pdf + 'pdf', + ] + DOWNLOAD_PATH = 'vf_admin/download_path' + + def __init__(self) -> None: + if not os.path.exists(self.UPLOAD_PATH): + os.makedirs(self.UPLOAD_PATH) + if not os.path.exists(self.DOWNLOAD_PATH): + os.makedirs(self.DOWNLOAD_PATH) + + +class CachePathConfig: + """ + 缓存目录配置 + """ + + PATH = os.path.join(os.path.abspath(os.getcwd()), 'caches') + PATHSTR = 'caches' + + +class GetConfig: + """ + 获取配置 + """ + + def __init__(self) -> None: + self.parse_cli_args() + + def get_app_config(self) -> AppSettings: + """ + 获取应用配置 + """ + # 实例化应用配置模型 + return AppSettings() + + def get_jwt_config(self) -> JwtSettings: + """ + 获取Jwt配置 + """ + # 实例化Jwt配置模型 + return JwtSettings() + + def get_database_config(self) -> DataBaseSettings: + """ + 获取数据库配置 + """ + # 实例化数据库配置模型 + return DataBaseSettings() + + def get_redis_config(self) -> RedisSettings: + """ + 获取Redis配置 + """ + # 实例化Redis配置模型 + return RedisSettings() + + def get_log_config(self) -> LogSettings: + """ + 获取日志配置 + """ + return LogSettings() + + def get_transport_crypto_config(self) -> TransportCryptoSettings: + """ + 获取传输层加解密配置 + """ + return TransportCryptoSettings() + + def get_gen_config(self) -> GenSettings: + """ + 获取代码生成配置 + """ + # 实例化代码生成配置 + return GenSettings() + + def get_upload_config(self) -> UploadSettings: + """ + 获取上传配置 + """ + # 实例上传配置 + return UploadSettings() + + @staticmethod + def parse_cli_args() -> None: + """ + 解析命令行参数 + """ + # 检查是否在alembic环境中运行,如果是则跳过参数解析 + if 'alembic' in sys.argv[0] or any('alembic' in arg for arg in sys.argv): + ini_config = configparser.ConfigParser() + ini_config.read('alembic.ini', encoding='utf-8') + if 'settings' in ini_config: + # 获取env选项 + env_value = ini_config['settings'].get('env') + os.environ['APP_ENV'] = env_value if env_value else 'dev' + elif 'uvicorn' in sys.argv[0]: + # 使用uvicorn启动时,命令行参数需要按照uvicorn的文档进行配置,无法自定义参数 + pass + else: + # 使用argparse定义命令行参数 + parser = argparse.ArgumentParser(description='命令行参数') + parser.add_argument('--env', type=str, default='', help='运行环境') + # 解析命令行参数 + args, _ = parser.parse_known_args() + # 设置环境变量,如果未设置命令行参数,默认APP_ENV为dev + os.environ['APP_ENV'] = args.env if args.env else 'dev' + # 读取运行环境 + run_env = os.environ.get('APP_ENV', '') + # 运行环境未指定时默认加载.env.dev + env_file = '.env.dev' + # 运行环境不为空时按命令行参数加载对应.env文件 + if run_env != '': + env_file = f'.env.{run_env}' + # 加载配置 + load_dotenv(env_file) + + +# 实例化获取配置类 +get_config = GetConfig() +# 应用配置 +AppConfig = get_config.get_app_config() +# Jwt配置 +JwtConfig = get_config.get_jwt_config() +# 数据库配置 +DataBaseConfig = get_config.get_database_config() +# Redis配置 +RedisConfig = get_config.get_redis_config() +# 日志配置 +LogConfig = get_config.get_log_config() +# 传输层加解密配置 +TransportCryptoConfig = get_config.get_transport_crypto_config() +# 代码生成配置 +GenConfig = get_config.get_gen_config() +# 上传配置 +UploadConfig = get_config.get_upload_config() diff --git a/shierfeng-fastapi-backend/config/get_db.py b/shierfeng-fastapi-backend/config/get_db.py new file mode 100644 index 0000000..43f838a --- /dev/null +++ b/shierfeng-fastapi-backend/config/get_db.py @@ -0,0 +1,37 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession + +from config.database import AsyncSessionLocal, Base, async_engine +from utils.log_util import logger + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + 每一个请求处理完毕后会关闭当前连接,不同的请求使用不同的连接 + + :return: + """ + async with AsyncSessionLocal() as current_db: + yield current_db + + +async def init_create_table() -> None: + """ + 应用启动时初始化数据库连接 + + :return: + """ + logger.info('🔎 初始化数据库连接...') + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info('✅️ 数据库连接成功') + + +async def close_async_engine() -> None: + """ + 应用关闭时释放数据库连接池 + + :return: + """ + await async_engine.dispose() diff --git a/shierfeng-fastapi-backend/config/get_redis.py b/shierfeng-fastapi-backend/config/get_redis.py new file mode 100644 index 0000000..559c677 --- /dev/null +++ b/shierfeng-fastapi-backend/config/get_redis.py @@ -0,0 +1,107 @@ +from fastapi import FastAPI +from redis import asyncio as aioredis +from redis.exceptions import AuthenticationError, RedisError +from redis.exceptions import TimeoutError as RedisTimeoutError + +from config.database import AsyncSessionLocal +from config.env import RedisConfig +from module_admin.service.config_service import ConfigService +from module_admin.service.dict_service import DictDataService +from utils.log_util import logger + + +class RedisUtil: + """ + Redis相关方法 + """ + + @classmethod + async def create_redis_pool(cls, log_enabled: bool = True, log_start_enabled: bool | None = None) -> aioredis.Redis: + """ + 应用启动时初始化redis连接 + + :param log_enabled: 是否输出日志 + :param log_start_enabled: 是否输出开始连接日志 + :return: Redis连接对象 + """ + redis = await aioredis.from_url( + url=f'redis://{RedisConfig.redis_host}', + port=RedisConfig.redis_port, + username=RedisConfig.redis_username, + password=RedisConfig.redis_password, + db=RedisConfig.redis_database, + encoding='utf-8', + decode_responses=True, + ) + if log_start_enabled is None: + log_start_enabled = log_enabled + if log_enabled or log_start_enabled: + await cls.check_redis_connection(redis, log_enabled=log_enabled, log_start_enabled=log_start_enabled) + return redis + + @classmethod + async def check_redis_connection( + cls, redis: aioredis.Redis, log_enabled: bool = True, log_start_enabled: bool | None = None + ) -> None: + """ + 检查redis连接状态 + + :param redis: redis对象 + :param log_enabled: 是否输出日志 + :param log_start_enabled: 是否输出开始连接日志 + :return: None + """ + if log_start_enabled is None: + log_start_enabled = log_enabled + if log_start_enabled: + logger.info('🔎 开始连接redis...') + try: + connection = await redis.ping() + if not log_enabled: + return + if connection: + logger.info('✅️ redis连接成功') + else: + logger.error('❌️ redis连接失败') + except AuthenticationError as e: + if log_enabled: + logger.error(f'❌️ redis用户名或密码错误,详细错误信息:{e}') + except RedisTimeoutError as e: + if log_enabled: + logger.error(f'❌️ redis连接超时,详细错误信息:{e}') + except RedisError as e: + if log_enabled: + logger.error(f'❌️ redis连接错误,详细错误信息:{e}') + + @classmethod + async def close_redis_pool(cls, app: FastAPI) -> None: + """ + 应用关闭时关闭redis连接 + + :param app: fastapi对象 + :return: + """ + await app.state.redis.close() + logger.info('✅️ 关闭redis连接成功') + + @classmethod + async def init_sys_dict(cls, redis: FastAPI) -> None: + """ + 应用启动时缓存字典表 + + :param redis: redis对象 + :return: + """ + async with AsyncSessionLocal() as session: + await DictDataService.init_cache_sys_dict_services(session, redis) + + @classmethod + async def init_sys_config(cls, redis: aioredis.Redis) -> None: + """ + 应用启动时缓存参数配置表 + + :param redis: redis对象 + :return: + """ + async with AsyncSessionLocal() as session: + await ConfigService.init_cache_sys_config_services(session, redis) diff --git a/shierfeng-fastapi-backend/config/get_scheduler.py b/shierfeng-fastapi-backend/config/get_scheduler.py new file mode 100644 index 0000000..a60f31c --- /dev/null +++ b/shierfeng-fastapi-backend/config/get_scheduler.py @@ -0,0 +1,940 @@ +import asyncio +import importlib +import json +from asyncio import iscoroutinefunction +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Any + +from apscheduler.events import EVENT_ALL, SchedulerEvent +from apscheduler.executors.asyncio import AsyncIOExecutor +from apscheduler.executors.pool import ProcessPoolExecutor +from apscheduler.job import Job +from apscheduler.jobstores.memory import MemoryJobStore +from apscheduler.jobstores.redis import RedisJobStore +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.combining import OrTrigger +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from redis import asyncio as aioredis +from sqlalchemy.engine import Engine +from sqlalchemy.ext.asyncio import AsyncEngine + +import module_task # noqa: F401 +from common.constant import LockConstant +from config.database import ( + SYNC_SQLALCHEMY_DATABASE_URL, + create_async_db_engine, + create_async_session_local, + create_sync_db_engine, + create_sync_session_local, +) +from config.env import AppConfig, LogConfig, RedisConfig +from module_admin.dao.job_dao import JobDao +from module_admin.entity.vo.job_vo import JobLogModel, JobModel +from module_admin.service.job_log_service import JobLogService +from utils.log_util import logger +from utils.server_util import StartupUtil, WorkerIdUtil + + +# 重写Cron定时 +class MyCronTrigger(CronTrigger): + CRON_EXPRESSION_LENGTH_MIN = 6 + CRON_EXPRESSION_LENGTH_MAX = 7 + WEEKDAY_COUNT = 5 + + @classmethod + def from_crontab(cls, expr: str, timezone: str | None = None) -> 'MyCronTrigger': + values = expr.split() + if len(values) != cls.CRON_EXPRESSION_LENGTH_MIN and len(values) != cls.CRON_EXPRESSION_LENGTH_MAX: + raise ValueError(f'Wrong number of fields; got {len(values)}, expected 6 or 7') + + second = values[0] + minute = values[1] + hour = values[2] + if '?' in values[3]: + day = None + elif 'L' in values[5]: + day = f'last {values[5].replace("L", "")}' + elif 'W' in values[3]: + day = cls.__find_recent_workday(int(values[3].split('W')[0])) + else: + day = values[3].replace('L', 'last') + month = values[4] + if '?' in values[5] or 'L' in values[5]: + week = None + elif '#' in values[5]: + week = int(values[5].split('#')[1]) + else: + week = values[5] + day_of_week = int(values[5].split('#')[0]) - 1 if '#' in values[5] else None + year = values[6] if len(values) == cls.CRON_EXPRESSION_LENGTH_MAX else None + return cls( + second=second, + minute=minute, + hour=hour, + day=day, + month=month, + week=week, + day_of_week=day_of_week, + year=year, + timezone=timezone, + ) + + @classmethod + def __find_recent_workday(cls, day: int) -> int: + now = datetime.now() + date = datetime(now.year, now.month, day) + if date.weekday() < cls.WEEKDAY_COUNT: + return date.day + diff = 1 + while True: + previous_day = date - timedelta(days=diff) + if previous_day.weekday() < cls.WEEKDAY_COUNT: + return previous_day.day + diff += 1 + + +redis_config = { + 'host': RedisConfig.redis_host, + 'port': RedisConfig.redis_port, + 'username': RedisConfig.redis_username, + 'password': RedisConfig.redis_password, + 'db': RedisConfig.redis_database, +} +job_defaults = {'coalesce': False, 'max_instance': 1} +scheduler = AsyncIOScheduler() + + +class SchedulerUtil: + """ + 定时任务相关方法 + """ + + # 分布式锁相关类变量 + _is_leader: bool = False + _worker_id: str = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id) + _redis: aioredis.Redis | None = None + _job_update_time_cache: dict[str, datetime] = {} + _sync_channel: str = 'scheduler:sync:request' + _sync_listener_task: asyncio.Task | None = None + _lock_lost_task: asyncio.Task | None = None + _sync_task: asyncio.Task | None = None + _sync_pending: bool = False + _sync_lock: asyncio.Lock = asyncio.Lock() + _last_sync_at: datetime | None = None + _sync_debounce_seconds: float = 0.5 + _sync_min_interval_seconds: float = 2.0 + _reacquire_task: asyncio.Task | None = None + _reacquire_interval_seconds: float = 5.0 + _sync_async_engine: AsyncEngine | None = None + _sync_async_sessionmaker: Any | None = None + _disposed_sync_engines: bool = False + + # 懒加载的同步 Engine 和 SessionLocal + _jobstore_engine: Engine | None = None + _listener_engine: Engine | None = None + _session_local: Any | None = None + _scheduler_configured: bool = False + + @classmethod + def _get_jobstore_engine(cls) -> Engine: + """ + 懒加载获取 jobstore 使用的同步 Engine + + :return: 同步 Engine + """ + if cls._jobstore_engine is None: + cls._jobstore_engine = create_sync_db_engine(echo=False) + return cls._jobstore_engine + + @classmethod + def _get_listener_engine(cls) -> Engine: + """ + 懒加载获取 listener 使用的同步 Engine + + :return: 同步 Engine + """ + if cls._listener_engine is None: + cls._listener_engine = create_sync_db_engine() + return cls._listener_engine + + @classmethod + def _get_session_local(cls) -> Any: + """ + 懒加载获取同步 SessionLocal + + :return: SessionLocal + """ + if cls._session_local is None: + cls._session_local = create_sync_session_local(cls._get_listener_engine()) + return cls._session_local + + @classmethod + def _configure_scheduler(cls) -> None: + """ + 配置 scheduler(懒加载 jobstore) + + :return: None + """ + if cls._scheduler_configured: + return + job_stores = { + 'default': MemoryJobStore(), + 'sqlalchemy': SQLAlchemyJobStore(url=SYNC_SQLALCHEMY_DATABASE_URL, engine=cls._get_jobstore_engine()), + 'redis': RedisJobStore(**redis_config), + } + executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)} + scheduler.configure(jobstores=job_stores, executors=executors, job_defaults=job_defaults) + cls._scheduler_configured = True + + @classmethod + def _should_enable_scheduler_sync(cls) -> bool: + """ + 判断是否需要启用多 worker 的任务状态同步机制 + + :return: 是否开启定时同步与监听 + """ + return not AppConfig.app_reload and AppConfig.app_workers > 1 + + @classmethod + async def init_system_scheduler(cls, redis: aioredis.Redis) -> None: + """ + 应用启动时初始化定时任务(使用分布式锁确保只有一个worker启动scheduler) + + :param redis: Redis连接对象 + :return: + """ + cls._redis = redis + logger.info(f'🔎 Worker {cls._worker_id} 尝试获取 Application 锁...') + + acquired = await StartupUtil.acquire_startup_log_gate( + redis=redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + worker_id=cls._worker_id, + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + ) + + if acquired: + await cls._start_scheduler_as_leader(redis) + else: + cls._is_leader = False + logger.info(f'⏸️ Worker {cls._worker_id} 未持有 Application 锁,跳过 Scheduler 启动') + + @classmethod + async def _start_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: + """ + 以 Leader 身份启动 Scheduler(内部方法,调用前需确保已持有锁) + + :param redis: Redis连接对象 + :return: None + """ + cls._is_leader = True + cls._disposed_sync_engines = False + logger.info(f'🎯 Worker {cls._worker_id} 持有 Application 锁,开始启动定时任务...') + # 懒加载配置 scheduler + cls._configure_scheduler() + scheduler.start() + + # 加载数据库中的定时任务 + async with cls._get_sync_async_session() as session: + job_list = await JobDao.get_job_list_for_scheduler(session) + for item in job_list: + cls._add_job_to_scheduler(item) + + # 添加事件监听器 + scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL) + + if cls._should_enable_scheduler_sync(): + # 添加任务状态同步任务(每30秒从数据库同步一次任务状态) + scheduler.add_job( + func=cls.request_scheduler_sync, + trigger='interval', + seconds=30, + id='_scheduler_job_sync', + name='Scheduler任务同步', + replace_existing=True, + ) + cls._sync_listener_task = asyncio.create_task(cls._listen_sync_channel(redis)) + + logger.info('✅️ 系统初始定时任务加载成功') + + @classmethod + def on_lock_lost(cls) -> None: + """ + 锁丢失处理入口 + + :return: None + """ + if not cls._is_leader: + return + cls._is_leader = False + logger.warning(f'⚠️ Worker {cls._worker_id} 失去 Application 锁') + if cls._lock_lost_task: + cls._lock_lost_task.cancel() + cls._lock_lost_task = asyncio.create_task(cls._handle_lock_lost()) + + @classmethod + async def _handle_lock_lost(cls) -> None: + """ + 处理锁丢失后的资源释放 + + :return: None + """ + if cls._sync_listener_task: + cls._sync_listener_task.cancel() + try: + await cls._sync_listener_task + except asyncio.CancelledError: + pass + cls._sync_listener_task = None + if cls._sync_task: + cls._sync_task.cancel() + try: + await cls._sync_task + except asyncio.CancelledError: + pass + cls._sync_task = None + cls._sync_pending = False + if getattr(scheduler, 'running', False): + scheduler.shutdown() + await cls._dispose_sync_async_engine() + cls._dispose_sync_engines() + cls._ensure_reacquire_task() + + @classmethod + async def _sync_jobs_from_database(cls) -> None: + """ + 从数据库同步任务状态,确保多worker环境下任务状态一致 + """ + if not cls._is_leader: + return + + try: + async with cls._get_sync_async_session() as session: + db_jobs_all = await JobDao.get_all_job_list_for_scheduler(session) + db_jobs_enabled = [job for job in db_jobs_all if job.status == '0'] + db_enabled_ids = {str(job.job_id) for job in db_jobs_enabled} + db_job_map = {str(job.job_id): job for job in db_jobs_enabled} + db_job_update_time_map = { + str(job.job_id): job.update_time for job in db_jobs_enabled if job.update_time is not None + } + scheduler_jobs = scheduler.get_jobs() + scheduler_job_map = {job.id: job for job in scheduler_jobs if not job.id.startswith('_')} + scheduler_job_ids = set(scheduler_job_map.keys()) + + jobs_to_remove = scheduler_job_ids - db_enabled_ids + for job_id in jobs_to_remove: + scheduler.remove_job(job_id=job_id) + logger.info(f'🗑️ 同步移除任务: {job_id}') + cls._refresh_job_update_cache(job_id, None) + + jobs_to_add = db_enabled_ids - scheduler_job_ids + for job_id in jobs_to_add: + job_info = db_job_map.get(job_id) + if job_info: + cls._add_job_to_scheduler(job_info) + logger.info(f'➕ 同步添加任务: {job_info.job_name}') + cls._refresh_job_update_cache(job_id, job_info.update_time) + + jobs_to_update = db_enabled_ids & scheduler_job_ids + for job_id in jobs_to_update: + job_info = db_job_map.get(job_id) + scheduler_job = scheduler_job_map.get(job_id) + job_update_time = db_job_update_time_map.get(job_id) + cls._sync_update_job(job_id, job_info, scheduler_job, job_update_time) + + except Exception as e: + logger.error(f'❌ 任务同步异常: {e}') + + @classmethod + def _is_job_config_in_sync(cls, scheduler_job: Job, job_info: JobModel) -> bool: + """ + 判断任务配置是否一致 + + :param scheduler_job: 调度器任务对象 + :param job_info: 数据库任务对象 + :return: 是否一致 + """ + job_state = scheduler_job.__getstate__() + job_kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else None + job_args = job_info.job_args.split(',') if job_info.job_args else None + job_executor = job_info.job_executor + if iscoroutinefunction(cls._import_function(job_info.invoke_target)): + job_executor = 'default' + expected = { + 'name': job_info.job_name, + 'executor': job_executor, + 'jobstore': job_info.job_group, + 'misfire_grace_time': 1000000000000 if job_info.misfire_policy == '3' else None, + 'coalesce': job_info.misfire_policy == '2', + 'max_instances': 3 if job_info.concurrent == '0' else 1, + 'trigger': str(MyCronTrigger.from_crontab(job_info.cron_expression)), + 'args': tuple(job_args) if job_args else None, + 'kwargs': job_kwargs if job_kwargs else None, + 'func': str(cls._import_function(job_info.invoke_target)), + } + current = { + 'name': job_state.get('name'), + 'executor': job_state.get('executor'), + 'jobstore': scheduler_job._jobstore_alias, + 'misfire_grace_time': job_state.get('misfire_grace_time'), + 'coalesce': job_state.get('coalesce'), + 'max_instances': job_state.get('max_instances'), + 'trigger': str(job_state.get('trigger')), + 'args': job_state.get('args'), + 'kwargs': job_state.get('kwargs'), + 'func': str(job_state.get('func')), + } + return expected == current + + @classmethod + def _sync_update_job( + cls, job_id: str, job_info: JobModel | None, scheduler_job: Job | None, job_update_time: datetime | None + ) -> None: + """ + 同步更新任务配置 + + :param job_id: 任务ID + :param job_info: 数据库任务对象 + :param scheduler_job: 调度器任务对象 + :param job_update_time: 任务更新时间 + :return: None + """ + if not job_info or not scheduler_job: + return + if cls._should_skip_job_update(job_id, job_update_time): + return + if not cls._is_job_config_in_sync(scheduler_job, job_info): + scheduler.remove_job(job_id=job_id) + cls._add_job_to_scheduler(job_info) + logger.info(f'♻️ 同步更新任务: {job_info.job_name}') + cls._refresh_job_update_cache(job_id, job_update_time) + + @classmethod + def _should_skip_job_update(cls, job_id: str, job_update_time: datetime | None) -> bool: + """ + 判断是否跳过同步更新 + + :param job_id: 任务ID + :param job_update_time: 任务更新时间 + :return: 是否跳过 + """ + if job_update_time is None: + return False + return cls._job_update_time_cache.get(job_id) == job_update_time + + @classmethod + def _refresh_job_update_cache(cls, job_id: str, job_update_time: datetime | None) -> None: + """ + 刷新任务更新时间缓存 + + :param job_id: 任务ID + :param job_update_time: 任务更新时间 + :return: None + """ + if job_update_time is not None: + cls._job_update_time_cache[job_id] = job_update_time + else: + cls._job_update_time_cache.pop(job_id, None) + + @classmethod + async def request_scheduler_sync(cls) -> None: + """ + 请求调度器同步任务状态 + + :return: None + """ + if cls._is_leader: + cls._sync_pending = True + cls._ensure_sync_task() + return + if cls._redis: + await cls._redis.publish(cls._sync_channel, cls._worker_id) + + @classmethod + def _ensure_sync_task(cls) -> None: + """ + 启动同步调度任务 + + :return: None + """ + if cls._sync_task and not cls._sync_task.done(): + return + cls._sync_task = asyncio.create_task(cls._run_sync_loop()) + + @classmethod + def _get_sync_async_session(cls) -> Any: + """ + 获取同步任务使用的异步 Session + + :return: 异步 Session + """ + if not cls._sync_async_sessionmaker: + cls._sync_async_engine = create_async_db_engine(echo=False) + cls._sync_async_sessionmaker = create_async_session_local(cls._sync_async_engine) + return cls._sync_async_sessionmaker() + + @classmethod + async def _dispose_sync_async_engine(cls) -> None: + """ + 释放同步任务使用的异步 Engine + + :return: None + """ + if cls._sync_async_engine: + await cls._sync_async_engine.dispose() + cls._sync_async_engine = None + cls._sync_async_sessionmaker = None + + @classmethod + def _dispose_sync_engines(cls) -> None: + """ + 释放 Scheduler 使用的同步 Engine + + :return: None + """ + if cls._disposed_sync_engines: + return + if cls._jobstore_engine: + cls._jobstore_engine.dispose() + cls._jobstore_engine = None + if cls._listener_engine: + cls._listener_engine.dispose() + cls._listener_engine = None + cls._session_local = None + cls._disposed_sync_engines = True + + @classmethod + def _ensure_reacquire_task(cls) -> None: + """ + 启动锁重新竞争任务 + + :return: None + """ + if not cls._redis: + return + if cls._reacquire_task and not cls._reacquire_task.done(): + return + cls._reacquire_task = asyncio.create_task(cls._run_reacquire_loop()) + + @classmethod + async def _run_reacquire_loop(cls) -> None: + """ + 循环尝试重新获取锁并恢复调度器 + + :return: None + """ + try: + while not cls._is_leader: + if not cls._redis: + await asyncio.sleep(cls._reacquire_interval_seconds) + continue + acquired = await StartupUtil.acquire_startup_log_gate( + redis=cls._redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + worker_id=cls._worker_id, + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + ) + if acquired: + # 直接调用 _start_scheduler_as_leader,避免重复获取锁 + await cls._start_scheduler_as_leader(cls._redis) + return + await asyncio.sleep(cls._reacquire_interval_seconds) + except asyncio.CancelledError: + raise + finally: + cls._reacquire_task = None + + @classmethod + async def _run_sync_loop(cls) -> None: + """ + 执行同步调度循环 + + :return: None + """ + try: + while True: + if not cls._sync_pending: + break + cls._sync_pending = False + await asyncio.sleep(cls._sync_debounce_seconds) + await cls._sync_with_throttle() + except asyncio.CancelledError: + raise + finally: + cls._sync_task = None + + @classmethod + async def _sync_with_throttle(cls) -> None: + """ + 按节流规则执行同步 + + :return: None + """ + async with cls._sync_lock: + if not cls._is_leader: + return + if cls._last_sync_at: + elapsed = datetime.now() - cls._last_sync_at + min_interval = timedelta(seconds=cls._sync_min_interval_seconds) + if elapsed < min_interval: + await asyncio.sleep((min_interval - elapsed).total_seconds()) + await cls._sync_jobs_from_database() + cls._last_sync_at = datetime.now() + + @classmethod + async def _listen_sync_channel(cls, redis: aioredis.Redis) -> None: + """ + 监听同步请求通道 + + :param redis: Redis连接对象 + :return: None + """ + while True: + pubsub = redis.pubsub() + try: + await pubsub.subscribe(cls._sync_channel) + async for message in pubsub.listen(): + if not cls._is_leader: + continue + if message.get('type') != 'message': + continue + await cls.request_scheduler_sync() + except asyncio.CancelledError: + await pubsub.unsubscribe(cls._sync_channel) + await pubsub.close() + raise + except Exception as e: + logger.error(f'❌ Scheduler 同步监听异常: {e},5秒后重试...') + await pubsub.close() + await asyncio.sleep(5) + finally: + try: + await pubsub.close() + except Exception: + pass + + @classmethod + async def _execute_async_job_with_log( + cls, job_func: Callable[..., Any], job_info: JobModel, args: list, kwargs: dict + ) -> None: + """ + 执行异步任务并记录日志 + + :param job_func: 任务函数 + :param job_info: 任务对象信息 + :param args: 位置参数 + :param kwargs: 关键字参数 + :return: None + """ + status = '0' + exception_info = '' + job_executor = job_info.job_executor + if iscoroutinefunction(job_func): + job_executor = 'default' + try: + await job_func(*args, **kwargs) + except Exception as e: + status = '1' + exception_info = str(e) + logger.error(f'❌ 异步执行任务 {job_info.job_name} 失败: {e}') + finally: + cls._record_job_execution_log(job_info, job_executor, status, exception_info) + + @classmethod + def _record_job_execution_log(cls, job_info: JobModel, job_executor: str, status: str, exception_info: str) -> None: + """ + 记录任务执行日志(用于非 Leader Worker 直接执行任务时) + + :param job_info: 任务对象信息 + :param job_executor: 任务执行器 + :param status: 执行状态 0-成功 1-失败 + :param exception_info: 异常信息 + :return: None + """ + try: + job_args = job_info.job_args if job_info.job_args else '' + job_kwargs = job_info.job_kwargs if job_info.job_kwargs else '{}' + job_trigger = str(MyCronTrigger.from_crontab(job_info.cron_expression)) if job_info.cron_expression else '' + job_message = ( + f'事件类型: DirectExecution(非Leader), 任务ID: {job_info.job_id}, ' + f'任务名称: {job_info.job_name}, 执行于{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}' + ) + job_log = JobLogModel( + jobName=job_info.job_name, + jobGroup=job_info.job_group, + jobExecutor=job_executor, + invokeTarget=job_info.invoke_target, + jobArgs=job_args, + jobKwargs=job_kwargs, + jobTrigger=job_trigger, + jobMessage=job_message, + status=status, + exceptionInfo=exception_info, + createTime=datetime.now(), + ) + session = cls._get_session_local()() + try: + JobLogService.add_job_log_services(session, job_log) + finally: + session.close() + except Exception as e: + logger.error(f'❌ 记录任务执行日志失败: {e}') + + @classmethod + def _prepare_scheduler_job_add(cls, job_info: JobModel) -> dict[str, Any]: + """ + 构建调度器任务参数 + + :param job_info: 任务对象信息 + :return: 调度器任务参数 + """ + job_func = cls._import_function(job_info.invoke_target) + job_executor = job_info.job_executor + if iscoroutinefunction(job_func): + job_executor = 'default' + return { + 'func': job_func, + 'trigger': MyCronTrigger.from_crontab(job_info.cron_expression), + 'args': job_info.job_args.split(',') if job_info.job_args else None, + 'kwargs': json.loads(job_info.job_kwargs) if job_info.job_kwargs else None, + 'id': str(job_info.job_id), + 'name': job_info.job_name, + 'misfire_grace_time': 1000000000000 if job_info.misfire_policy == '3' else None, + 'coalesce': job_info.misfire_policy == '2', + 'max_instances': 3 if job_info.concurrent == '0' else 1, + 'jobstore': job_info.job_group, + 'executor': job_executor, + } + + @classmethod + def _add_job_to_scheduler(cls, job_info: JobModel) -> None: + """ + 内部方法:将任务添加到调度器(不检查应用锁状态,仅供内部使用) + + :param job_info: 任务对象信息 + """ + try: + # 先移除已存在的同ID任务 + existing_job = scheduler.get_job(job_id=str(job_info.job_id)) + if existing_job: + scheduler.remove_job(job_id=str(job_info.job_id)) + scheduler.add_job(**cls._prepare_scheduler_job_add(job_info)) + except Exception as e: + logger.error(f'❌ 添加任务 {job_info.job_name} 失败: {e}') + + @classmethod + async def close_system_scheduler(cls) -> None: + """ + 应用关闭时关闭定时任务 + + :return: + """ + if cls._sync_listener_task: + cls._sync_listener_task.cancel() + try: + await cls._sync_listener_task + except asyncio.CancelledError: + pass + cls._sync_listener_task = None + if cls._sync_task: + cls._sync_task.cancel() + try: + await cls._sync_task + except asyncio.CancelledError: + pass + cls._sync_task = None + cls._sync_pending = False + if cls._reacquire_task: + cls._reacquire_task.cancel() + try: + await cls._reacquire_task + except asyncio.CancelledError: + pass + cls._reacquire_task = None + await cls._dispose_sync_async_engine() + cls._dispose_sync_engines() + if cls._lock_lost_task: + cls._lock_lost_task.cancel() + try: + await cls._lock_lost_task + except asyncio.CancelledError: + pass + cls._lock_lost_task = None + if getattr(scheduler, 'running', False): + scheduler.shutdown() + logger.info('✅️ 关闭定时任务成功') + # 释放锁 + if cls._redis: + current_holder = await cls._redis.get(LockConstant.APP_STARTUP_LOCK_KEY) + if current_holder == cls._worker_id: + await cls._redis.delete(LockConstant.APP_STARTUP_LOCK_KEY) + logger.info(f'🔓 Worker {cls._worker_id} 释放 Application 锁') + + @classmethod + def _import_function(cls, func_path: str) -> Callable[..., Any]: + """ + 动态导入函数 + + :param func_path: 函数字符串,如module_task.scheduler_test.job + :return: 导入的函数对象 + """ + module_path, func_name = func_path.rsplit('.', 1) + module = importlib.import_module(module_path) + return getattr(module, func_name) + + @classmethod + def get_scheduler_job(cls, job_id: str | int) -> Job: + """ + 根据任务id获取任务对象 + + :param job_id: 任务id + :return: 任务对象 + """ + query_job = scheduler.get_job(job_id=str(job_id)) + + return query_job + + @classmethod + def add_scheduler_job(cls, job_info: JobModel) -> None: + """ + 根据输入的任务对象信息添加任务 + + :param job_info: 任务对象信息 + :return: + """ + # 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会加载) + if not cls._is_leader: + return + scheduler.add_job(**cls._prepare_scheduler_job_add(job_info)) + + @classmethod + def execute_scheduler_job_once(cls, job_info: JobModel) -> None: + """ + 根据输入的任务对象执行一次任务 + + :param job_info: 任务对象信息 + :return: + """ + job_func = cls._import_function(job_info.invoke_target) + job_executor = job_info.job_executor + if iscoroutinefunction(job_func): + job_executor = 'default' + + # 非应用锁 worker:直接执行函数(不通过 scheduler) + if not cls._is_leader: + logger.info(f'📍 当前 Worker 未持有 Application 锁,直接执行任务 {job_info.job_name}') + args = job_info.job_args.split(',') if job_info.job_args else [] + kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else {} + status = '0' + exception_info = '' + try: + if iscoroutinefunction(job_func): + asyncio.create_task(cls._execute_async_job_with_log(job_func, job_info, args, kwargs)) # noqa: RUF006 + else: + job_func(*args, **kwargs) + except Exception as e: + status = '1' + exception_info = str(e) + logger.error(f'❌ 直接执行任务 {job_info.job_name} 失败: {e}') + finally: + # 同步任务记录日志(异步任务在 _execute_async_job_with_log 中记录) + if not iscoroutinefunction(job_func): + cls._record_job_execution_log(job_info, job_executor, status, exception_info) + return + + # 应用锁 worker:通过 scheduler 执行 + job_trigger = DateTrigger() + if job_info.status == '0': + job_trigger = OrTrigger(triggers=[DateTrigger(), MyCronTrigger.from_crontab(job_info.cron_expression)]) + scheduler.add_job( + func=job_func, + trigger=job_trigger, + args=job_info.job_args.split(',') if job_info.job_args else None, + kwargs=json.loads(job_info.job_kwargs) if job_info.job_kwargs else None, + id=str(job_info.job_id), + name=job_info.job_name, + misfire_grace_time=1000000000000 if job_info.misfire_policy == '3' else None, + coalesce=job_info.misfire_policy == '2', + max_instances=3 if job_info.concurrent == '0' else 1, + jobstore=job_info.job_group, + executor=job_executor, + ) + + @classmethod + def remove_scheduler_job(cls, job_id: str | int) -> None: + """ + 根据任务id移除任务 + + :param job_id: 任务id + :return: + """ + # 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会根据状态加载) + if not cls._is_leader: + return + query_job = cls.get_scheduler_job(job_id=job_id) + if query_job: + scheduler.remove_job(job_id=str(job_id)) + + @classmethod + def scheduler_event_listener(cls, event: SchedulerEvent) -> None: + """ + 调度器事件监听器,记录任务执行日志 + """ + try: + # 获取事件类型和任务ID + event_type = event.__class__.__name__ + # 获取任务执行异常信息 + status = '0' + exception_info = '' + if event_type == 'JobExecutionEvent' and event.exception: + exception_info = str(event.exception) + status = '1' + if hasattr(event, 'job_id'): + job_id = event.job_id + # 跳过内部系统任务(以 _ 开头的任务ID),不记录日志 + if str(job_id).startswith('_'): + return + query_job = cls.get_scheduler_job(job_id=job_id) + if query_job: + query_job_info = query_job.__getstate__() + # 获取任务名称 + job_name = query_job_info.get('name') + # 获取任务组名 + job_group = query_job._jobstore_alias + # 获取任务执行器 + job_executor = query_job_info.get('executor') + # 获取调用目标字符串 + invoke_target = query_job_info.get('func') + # 获取调用函数位置参数(安全处理) + args = query_job_info.get('args') + job_args = ','.join(str(arg) for arg in args) if args else '' + # 获取调用函数关键字参数 + kwargs = query_job_info.get('kwargs') + job_kwargs = json.dumps(kwargs) if kwargs else '{}' + # 获取任务触发器 + job_trigger = str(query_job_info.get('trigger')) + # 构造日志消息 + job_message = f'事件类型: {event_type}, 任务ID: {job_id}, 任务名称: {job_name}, 执行于{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}' + job_log = JobLogModel( + jobName=job_name, + jobGroup=job_group, + jobExecutor=job_executor, + invokeTarget=invoke_target, + jobArgs=job_args, + jobKwargs=job_kwargs, + jobTrigger=job_trigger, + jobMessage=job_message, + status=status, + exceptionInfo=exception_info, + createTime=datetime.now(), + ) + session = cls._get_session_local()() + try: + JobLogService.add_job_log_services(session, job_log) + finally: + session.close() + except Exception as e: + logger.error(f'❌ 调度任务事件监听器异常: {e}') diff --git a/shierfeng-fastapi-backend/docs/cli_usage.md b/shierfeng-fastapi-backend/docs/cli_usage.md new file mode 100644 index 0000000..558319d --- /dev/null +++ b/shierfeng-fastapi-backend/docs/cli_usage.md @@ -0,0 +1,841 @@ +# ShiErFeng Backend CLI 使用文档 + +## 1. 文档说明 + +本文档用于说明 `shierfeng-fastapi-backend` 当前已经落地的 CLI 用法。 + +统一命令入口为: + +```bash +shierfeng [options] +``` + +当前已实现的命令组包括: + +- `app` +- `db` +- `ops` +- `cache` +- `job` +- `config` +- `crypto` +- `gen` +- `dev` +- `completion` +- `wizard` +- `tui` + +## 2. 快速开始 + +### 2.1 执行目录 + +`shierfeng` 命令必须在后端项目根目录执行,也就是 `shierfeng-fastapi-backend` 目录。 + +```bash +cd shierfeng-fastapi-backend +shierfeng --help +``` + +### 2.2 安装依赖 + +MySQL 版本: + +```bash +cd shierfeng-fastapi-backend +pip3 install -r requirements.txt +``` + +PostgreSQL 版本: + +```bash +cd shierfeng-fastapi-backend +pip3 install -r requirements-pg.txt +``` + +说明: + +- `requirements*.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `pip install -e .` +- 安装完成后,`shierfeng` 会随当前 Python 环境一起可用 + +如果本地使用 Conda,推荐先进入项目环境再执行命令: + +```bash +conda activate shierfeng-fastapi +cd shierfeng-fastapi-backend +shierfeng --help +``` + +`textual` 已包含在现有依赖文件中,因此安装 `requirements.txt` 或 `requirements-pg.txt` 后即可直接使用 TUI。 + +### 2.3 第一个命令 + +开发环境启动应用: + +```bash +shierfeng app run --env=dev +``` + +这个命令的目标是等价替代: + +```bash +python app.py --env=dev +``` + +## 3. 使用规则 + +### 3.1 根参数位置 + +根参数必须写在命令组前面。 + +正确示例: + +```bash +shierfeng --color=never --icon=none ops health --env=dev +``` + +推荐不要写成把根参数放到子命令后面的形式。 + +### 3.2 环境参数 + +CLI 不单独维护配置系统,仍然复用项目原有的 `config/env.py` 解析逻辑。 + +常用环境映射如下: + +- `--env=dev` -> `.env.dev` +- `--env=prod` -> `.env.prod` +- `--env=dockermy` -> `.env.dockermy` +- `--env=dockerpg` -> `.env.dockerpg` + +### 3.3 帮助命令 + +可以通过以下方式逐层查看帮助: + +```bash +shierfeng --help +shierfeng app --help +shierfeng app run --help +shierfeng db --help +shierfeng cache clear --help +``` + +### 3.4 输出模式 + +除 `app run` 这类进程接管型命令外,大多数命令支持: + +- `--output=text` +- `--output=json` + +推荐约定: + +- 人工排查优先使用 `text` +- 脚本集成优先使用 `json` +- `text` 输出中的字段名统一使用 `snake_case` +- `json` 输出中的字段名保持稳定结构化契约,不为显示效果重命名 +- `json` 输出不会混入颜色码、emoji 或装饰文本 +- `json` 输出不会混入 SQLAlchemy SQL 日志、普通业务日志或其他非 JSON 文本 +- `app run` 会直接接管应用前台进程,因此不提供 `--output` + +示例: + +```bash +shierfeng ops health --env=dev --output=text +shierfeng ops health --env=dev --output=json +``` + +### 3.5 视觉选项 + +根命令支持: + +- `--color=auto|always|never` +- `--icon=emoji|ascii|none` + +当前默认值为: + +- `--color=always` +- `--icon=emoji` + +说明: + +- 这两个参数只影响 `text` 输出 +- `json` 输出始终保持结构化结果,不受颜色和图标影响 + +示例: + +```bash +shierfeng --color=always ops server-info --env=dev +shierfeng --color=never ops server-info --env=dev +shierfeng --icon=none ops server-info --env=dev +``` + +### 3.6 危险命令 + +会产生真实副作用的命令会纳入危险命令保护。 + +危险命令分为两类: + +- `high`:必须支持 `--dry-run` 或等价预览能力 +- `normal`:默认要求确认,但不强制要求 `--dry-run` + +保护规则如下: + +- 非 `prod` 环境下默认会要求确认 +- 非交互终端中如果未传 `--yes`,命令会直接拒绝执行 +- `prod` 环境下默认禁止执行,必须显式传入 `--allow-prod --yes` +- 只有 `high` 风险命令或已实现预演能力的命令才会出现 `--dry-run` + +示例: + +```bash +shierfeng cache clear --env=dev --all --yes +shierfeng db upgrade --env=prod --revision=head --allow-prod --yes +shierfeng gen export sys_user --env=dev --mode=local --dry-run +``` + +## 4. 常用工作流 + +### 4.1 本地开发启动 + +```bash +cd shierfeng-fastapi-backend +shierfeng app doctor --env=dev +shierfeng app run --env=dev +``` + +### 4.2 发布前检查 + +```bash +shierfeng ops health --env=prod --output=json +shierfeng ops server-info --env=prod +shierfeng db current --env=prod --output=json +``` + +### 4.3 数据库迁移 + +```bash +shierfeng db check --env=dev +shierfeng db revision --env=dev --message="add user index" --yes +shierfeng db upgrade --env=dev --revision=head --yes +``` + +### 4.4 缓存与调度排查 + +```bash +shierfeng cache stats --env=dev +shierfeng cache keys sys_config --env=dev --output=json +shierfeng job list --env=dev --output=json +shierfeng job sync --env=dev --yes +``` + +### 4.5 开发态检查 + +```bash +shierfeng dev lint cli --check-only +shierfeng dev test tests --keyword sanitize --maxfail=1 -q +``` + +### 4.6 Shell Completion 初始化 + +```bash +shierfeng completion doctor --output=json +shierfeng completion show bash +shierfeng completion install --activate +shierfeng completion install --shell=bash --activate +``` + +### 4.7 交互式向导与 TUI + +```bash +shierfeng wizard app-run +shierfeng wizard db-upgrade +shierfeng wizard cache-clear +shierfeng wizard gen-export +shierfeng wizard gen-import +shierfeng wizard prod-check +shierfeng tui --env=dev +``` + +## 5. 命令速查 + +### 5.1 `app` + +用于启动当前 FastAPI 应用、启动前检查、配置快照和路由巡检。 + +```bash +shierfeng app run --env=dev +shierfeng app doctor --env=dev --output=json +shierfeng app env --env=dev +shierfeng app config --env=dev --output=json +shierfeng app routes --env=dev +shierfeng app routes --env=dev --method=GET --path-prefix=/system +shierfeng app routes --env=dev --group-by=tag +shierfeng app routes --env=dev --include-hidden --output=json +``` + +### 5.2 `db` + +用于数据库连接检查和 Alembic 迁移封装。 + +```bash +shierfeng db check --env=dev +shierfeng db current --env=dev --output=json +shierfeng db heads --env=dev --output=json +shierfeng db history --env=dev --limit=10 +shierfeng db upgrade --env=dev +shierfeng db upgrade --env=dev --revision=head --dry-run +shierfeng db init --env=dev +shierfeng db downgrade --env=dev --revision=-1 +shierfeng db downgrade --env=dev --revision=-1 --dry-run +shierfeng db revision --env=dev --message="add user index" --yes +shierfeng db revision --env=dev --message="sync table structure" --autogenerate --yes +``` + +### 5.3 `ops` + +用于基础运维检查。 + +```bash +shierfeng ops deps --env=dev +shierfeng ops ping-db --env=dev +shierfeng ops ping-redis --env=dev +shierfeng ops health --env=dev +shierfeng ops health --env=dev --output=json +shierfeng ops server-info --env=dev +shierfeng ops server-info --env=dev --output=json +``` + +说明: + +- `server-info --output=text` 适合人工巡检 +- `server-info --output=json` 更适合脚本消费 + +### 5.4 `cache` + +用于缓存统计、查询、清理和预热。 + +```bash +shierfeng cache stats --env=dev +shierfeng cache stats --env=dev --output=json +shierfeng cache keys login_tokens --env=dev --output=json +shierfeng cache get sys_config site.name --env=dev --output=json +shierfeng cache ttl sys_config site.name --env=dev --output=json +shierfeng cache clear --env=dev --cache-name=sys_config --yes +shierfeng cache clear --env=dev --cache-key=site.name --yes +shierfeng cache clear --env=dev --all --yes +shierfeng cache warmup --env=dev --yes +``` + +### 5.5 `job` + +用于定时任务查询、执行和同步。 + +```bash +shierfeng job list --env=dev --output=json +shierfeng job list --env=dev --job-name=同步任务 --status=0 --paged +shierfeng job detail 1 --env=dev --output=json +shierfeng job logs --env=dev --output=json +shierfeng job logs --env=dev --job-name=同步任务 --status=1 --paged +shierfeng job run-once 1 --env=dev --yes +shierfeng job pause 1 --env=dev --yes +shierfeng job resume 1 --env=dev --yes +shierfeng job sync --env=dev --yes +shierfeng job run-once 1 --env=prod --allow-prod --yes +``` + +### 5.6 `config` + +用于系统参数配置读取、写入和缓存同步。 + +```bash +shierfeng config list --env=dev --output=json +shierfeng config list --env=dev --paged +shierfeng config get --env=dev --output=json +shierfeng config get --env=dev --source=both --output=json +shierfeng config get --env=dev --source=db --output=json +shierfeng config get --env=dev --source=cache +shierfeng config doctor --env=dev --output=json +shierfeng config set sys.user.initPassword --env=dev --value=123456 --name="初始密码" --yes +shierfeng config set sys.user.initPassword --env=dev --value=123456 --remark="CLI update" --yes +shierfeng config sync-cache --env=dev --yes +``` + +说明: + +- `config get --source=both` 会同时读取数据库和 Redis,并在 JSON 中返回 `database`、`cache` 与 `inSync` +- `config get --source=db` 只读取参数配置表,适合确认数据库中的真实存量值 +- `config get --source=cache` 只读取 Redis 缓存,适合确认当前运行时命中的值 +- 如果某个键只在缓存里存在、不在数据库里存在,`--source=db` 会返回“参数配置不存在”,这代表数据库侧没有对应记录,不表示缓存读取异常 + +### 5.7 `crypto` + +用于传输加密配置校验、公钥导出和密钥辅助操作。 + +```bash +shierfeng crypto validate --env=dev --output=json +shierfeng crypto keygen --env=dev --kid=default --key-size=2048 +shierfeng crypto keygen --env=dev --output=json --kid=default --key-size=2048 +shierfeng crypto export-public --env=dev +shierfeng crypto export-public --env=dev --output=json +shierfeng crypto rotate --env=dev --output=json --next-kid=v2 --key-size=2048 --yes +``` + +说明: + +- `crypto keygen` 会输出新生成的公钥、私钥和建议写入的 `envPatch` +- `crypto rotate` 当前只生成轮换辅助结果,不会直接改写 `.env.*` 文件 +- 涉及私钥的输出只建议在安全终端中使用 + +### 5.8 `gen` + +用于代码生成业务表查询、导入、建表、预览和导出。 + +```bash +shierfeng gen list --env=dev --output=json +shierfeng gen db-list --env=dev --output=json +shierfeng gen detail 1 --env=dev --output=json +shierfeng gen import-table sys_user sys_role --env=dev --yes +shierfeng gen import-table sys_user sys_role --env=dev --dry-run +shierfeng gen create-table --env=dev --sql="create table demo_test (id bigint primary key)" --yes +shierfeng gen create-table --env=dev --sql-file=./sql/demo.sql --dry-run +shierfeng gen preview 1 --env=dev +shierfeng gen preview 1 --env=dev --output=json +shierfeng gen export sys_user --env=dev --yes +shierfeng gen export sys_user sys_role --env=dev --mode=zip --output-file=./build/gen.zip --yes +shierfeng gen export sys_user --env=dev --mode=local --dry-run +shierfeng gen sync-db sys_user --env=dev --yes +``` + +说明: + +- `import-table` 与 `create-table` 支持 `--dry-run` +- `create-table` 必须且只能传入 `--sql` 或 `--sql-file` 其中一种 +- `gen preview --output=text` 会按模板分块展示预览代码内容 +- `export --mode=local` 会复用现有生成逻辑,并遵守 `GenConfig.allow_overwrite` + +### 5.9 `dev` + +用于开发态代码检查与测试执行。 + +```bash +shierfeng dev lint +shierfeng dev lint cli tests --check-only +shierfeng dev lint cli --fix +shierfeng dev lint cli --output=json +shierfeng dev test +shierfeng dev test tests/test_log_sanitize_util.py +shierfeng dev test tests --keyword sanitize --maxfail=1 -q +shierfeng dev test tests --output=json +``` + +说明: + +- `dev lint` 默认顺序是先执行 `ruff format`,再执行 `ruff check` +- `--check-only` 会改成只检查,不写回 +- `--fix` 会执行 `ruff check --fix` +- `dev test` 通过当前环境的 `python -m pytest` 执行测试 + +### 5.10 `completion` + +用于生成、安装和诊断 shell completion。 + +```bash +shierfeng completion doctor --output=json +shierfeng completion show bash +shierfeng completion show zsh +shierfeng completion show fish +shierfeng completion show powershell +shierfeng completion install --activate +shierfeng completion install --shell=bash +shierfeng completion install --shell=zsh --activate +shierfeng completion install --shell=fish +shierfeng completion install --shell=powershell --activate +``` + +说明: + +- 当前版本已支持 `bash`、`zsh`、`fish`、`powershell` +- `install` 在未传 `--shell` 时会优先自动识别当前 shell +- `install` 默认写入 shell 对应的默认脚本位置 +- Bash 和 Zsh 如需自动加载,建议配合 `--activate` +- PowerShell 如需自动加载,建议配合 `--activate` +- Bash 脚本已对旧版本 Bash 做兼容处理,若之前已安装过脚本,请重新执行一次 `shierfeng completion install --activate` +- `completion doctor` 会给出推荐安装命令和 source 建议 +- 当前已补充的上下文补全包括 `--env`、`cache_name`、`cache_key`、`config_key`、`db --revision`、`gen` 业务表名、`gen` 数据库表名、`gen --sql-file`、`gen --output-file`、`job_name`、`job_id` + +### 5.11 `wizard` + +用于通过交互方式组装危险命令或复杂命令,并在真正执行前输出预览。 + +```bash +shierfeng wizard app-run +shierfeng wizard db-upgrade --default-env=dev --default-revision=head +shierfeng wizard cache-clear --default-env=dev --default-mode=cache-name +shierfeng wizard gen-export --default-env=dev --default-mode=zip +shierfeng wizard gen-import --default-env=dev --default-table-names=sys_notice +shierfeng wizard prod-check --default-env=prod +``` + +说明: + +- 所有向导都会先采集输入,再输出预览摘要与最终将执行的 CLI 命令 +- `db-upgrade`、`cache-clear`、`gen-export`、`gen-import` 默认都支持先走 `dry-run` +- 向导本质上仍然是对底层 CLI 的封装,最终返回值、退出码和危险命令保护规则与底层命令保持一致 + +### 5.12 `tui` + +用于进入只读巡检工作台,以页面方式浏览应用、运维、数据库、缓存、任务、代码生成、参数配置和加密状态。 + +```bash +shierfeng tui --env=dev +shierfeng tui --env=prod +``` + +说明: + +- 当前 TUI 是只读巡检工作台,页面内的写操作入口会通过确认弹窗或向导二次确认 +- 页面切换快捷键为 `D/A/O/B/C/T/G/P/E`,分别对应总览、应用、运维、数据库、缓存、任务、代码生成、参数配置、加密 +- 通用快捷键包括 `R` 刷新、`Q` 退出、`S` 聚焦侧栏、`←/→` 切换焦点或区域、`J/K` 滚动、`PgUp/PgDn` 翻页、`Home/End` 首尾跳转 +- 若当前 Python 环境缺少 TUI 依赖,`shierfeng tui` 会返回失败结果并提示重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt` + +## 6. 危险命令清单 + +当前已接入保护的命令包括: + +- `cache clear` +- `cache warmup` +- `db upgrade` +- `db init` +- `db downgrade` +- `db revision` +- `config set` +- `config sync-cache` +- `crypto rotate` +- `job run-once` +- `job pause` +- `job resume` +- `job sync` +- `gen import-table` +- `gen create-table` +- `gen export` +- `gen sync-db` + +说明: + +- 在 `prod` 环境下,这些命令默认拒绝执行 +- 在非 `prod` 环境下,这些命令默认也会进入交互确认 +- 在非交互终端中,如果未传入 `--yes`,命令会直接拒绝执行 +- 如果命令支持 `--dry-run`,优先先跑一次预演 + +## 7. 输出与退出码 + +### 7.1 输出格式 + +CLI 支持两种输出格式: + +- `text` +- `json` + +字段命名约定: + +- `text` 输出优先面向人工阅读,字段名统一使用 `snake_case` +- `json` 输出优先面向脚本消费,字段名保持命令契约定义,不因视觉优化变化 + +示例: + +```bash +shierfeng ops health --env=dev --output=json +``` + +标准样例: + +文本输出样例: + +命令: + +```bash +shierfeng --color=never --icon=none app config --env=dev +``` + +输出: + +```text +OK SUCCESS +env: dev +application: + name: ShiErFeng-FastAPI + host: 0.0.0.0:9099 + root_path: /dev-api + reload: true + workers: 1 + disable_swagger: false + disable_redoc: false +database: + type: mysql + host: 127.0.0.1:3306 + name: shierfeng-fastapi +redis: + host: 127.0.0.1:6379 +logging: + level: INFO +transport_crypto: + enabled: false + mode: off +``` + +JSON 输出样例: + +命令: + +```bash +shierfeng app config --env=dev --output=json +``` + +输出: + +```json +{ + "ok": true, + "env": "dev", + "config": { + "env": "dev", + "name": "ShiErFeng-FastAPI", + "host": "0.0.0.0", + "port": 9099, + "rootPath": "/dev-api", + "reload": true, + "workers": 1, + "disableSwagger": false, + "disableRedoc": false, + "dbType": "mysql", + "dbHost": "127.0.0.1", + "dbPort": 3306, + "dbDatabase": "shierfeng-fastapi", + "redisHost": "127.0.0.1", + "redisPort": 6379, + "logLevel": "INFO", + "transportCryptoEnabled": false, + "transportCryptoMode": "off" + } +} +``` + +危险命令拒绝样例: + +命令: + +```bash +shierfeng db revision --env=prod --message="doc-sample" --output=json +``` + +输出: + +```json +{ + "ok": false, + "message": "生产环境默认禁止直接执行危险命令:db revision", + "hint": "如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes" +} +``` + +`dry-run` 输出样例: + +命令: + +```bash +shierfeng db upgrade --env=dev --revision=head --dry-run --yes --output=json +``` + +输出: + +```json +{ + "ok": true, + "message": "数据库已升级到 head(dry-run)", + "dryRun": true, + "command": [ + "alembic", + "-c", + "/path/to/shierfeng-fastapi-backend/alembic.ini", + "upgrade", + "head" + ], + "cwd": "/path/to/shierfeng-fastapi-backend" +} +``` + +代码生成 `dry-run` 文本样例: + +命令: + +```bash +shierfeng --color=never --icon=none gen export demo_table --env=dev --dry-run --yes --output=text +``` + +输出: + +```text +OK SUCCESS +env: dev +mode: zip +dry_run: true +message: 代码导出演练完成,未执行实际导出 +table_names: + - demo_table +output_file: /path/to/shierfeng-fastapi-backend/gen_code_demo_table.zip +``` + +代码生成 `dry-run` JSON 样例: + +命令: + +```bash +shierfeng gen create-table --env=dev --dry-run --yes --sql='CREATE TABLE demo_cli_test (id bigint);' --output=json +``` + +输出: + +```json +{ + "ok": true, + "message": "建表语句演练完成,未执行实际建表", + "dryRun": true, + "statementCount": 1, + "tableNames": [ + "demo_cli_test" + ], + "sql": "CREATE TABLE demo_cli_test (id bigint);", + "env": "dev" +} +``` + +参数错误样例: + +命令: + +```bash +shierfeng --color=never --icon=none gen create-table --env=dev --dry-run --yes --sql='DROP TABLE demo_cli_test;' --output=text +``` + +输出: + +```text +FAIL FAILED +message: 创建表结构失败 +error: 建表语句不合法,仅允许 CREATE TABLE 语句 +env: dev +``` + +参数错误 JSON 样例: + +命令: + +```bash +shierfeng gen create-table --env=dev --dry-run --yes --sql='DROP TABLE demo_cli_test;' --output=json +``` + +输出: + +```json +{ + "ok": false, + "message": "创建表结构失败", + "error": "建表语句不合法,仅允许 CREATE TABLE 语句", + "env": "dev" +} +``` + +依赖检查失败样例: + +说明: + +- `app doctor` 和 `ops health` 的返回结果会受到当前数据库、Redis 连通性影响 +- 如果依赖不可用,命令会输出失败结果,并返回退出码 `10` + +命令: + +```bash +shierfeng --color=never --icon=none app doctor --env=dev --output=text +``` + +输出: + +```text +FAIL FAILED +env: dev +checks: + database: false | 数据库连接失败 | error: + redis: false | Redis连接失败 | error: + crypto: true | 传输加密配置校验通过 +``` + +命令: + +```bash +shierfeng ops health --env=dev --output=json +``` + +输出: + +```json +{ + "env": "dev", + "database": { + "ok": false, + "message": "数据库连接失败", + "error": "", + "exit_code": 20 + }, + "redis": { + "ok": false, + "message": "Redis连接失败", + "error": "", + "exit_code": 21 + }, + "ok": false +} +``` + +### 7.2 退出码 + +当前统一退出码如下: + +- `0`:成功 +- `2`:参数错误 +- `10`:依赖检查失败 +- `20`:数据库失败 +- `21`:Redis 失败 +- `22`:调度器失败 +- `30`:危险操作被拒绝 +- `50`:未分类运行错误 + +## 8. 常见问题 + +### 8.1 `shierfeng` 命令不可用 + +请依次确认: + +- 当前目录是否为 `shierfeng-fastapi-backend` +- 当前 Python 环境是否执行过 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt` +- 当前终端是否真的使用了安装依赖的那个 Python/Conda 环境 + +### 8.2 命令报数据库或 Redis 连接失败 + +请检查: + +- `.env.*` 中数据库配置是否正确 +- `.env.*` 中 Redis 配置是否正确 +- 当前网络、容器或主机是否允许连接目标服务 +- `app doctor` 与 `ops health` 在依赖异常时返回退出码 `10` +- 单项依赖失败会在 JSON 中带出原始 `error` 和对应依赖退出码 + +### 8.3 文本输出太花或不适合脚本处理 + +可以直接切换输出与视觉参数: + +```bash +shierfeng --color=never --icon=none ops health --env=dev --output=text +shierfeng ops health --env=dev --output=json +``` + +### 8.4 使用文档与实现不一致 + +应以当前 CLI 实现和命令帮助输出为准,并同步更新本文档。 diff --git a/shierfeng-fastapi-backend/docs/transport_crypto_config.md b/shierfeng-fastapi-backend/docs/transport_crypto_config.md new file mode 100644 index 0000000..4dc8238 --- /dev/null +++ b/shierfeng-fastapi-backend/docs/transport_crypto_config.md @@ -0,0 +1,129 @@ +# 传输层加解密配置说明 + +## 模式说明 + +`TRANSPORT_CRYPTO_MODE` 共有三种模式: + +- `off` + 完全关闭传输层加解密。中间件不执行请求解密与响应加密,前端通过 `/transport/crypto/frontend-config` 获取到的策略也会同步关闭。 +- `optional` + 可选加密模式。命中的接口既接受明文请求,也接受加密请求;如果请求已加密,后端会解密后处理,并对命中的 JSON 响应自动加密。适合灰度接入和上线初期观察。 +- `required` + 强制加密模式。命中的接口必须携带合法加密信封,明文请求会被直接拒绝;同时防重放校验会按严格模式执行,Redis 不可用时也会拒绝请求。适合链路稳定后的正式强制启用阶段。 + +补充说明: + +- `TRANSPORT_CRYPTO_ENABLED=false` 时,整体效果等同于关闭,不再进入传输层加解密逻辑。 +- `TRANSPORT_CRYPTO_ENABLED_PATHS`、`TRANSPORT_CRYPTO_REQUIRED_PATHS` 和 `TRANSPORT_CRYPTO_EXCLUDE_PATHS` 会在上述模式基础上继续约束命中范围。 + +## 开发环境 + +开发环境直接使用 `.env.dev` 中默认提供的可用密钥对即可。 + +说明: + +- 传输层加解密启用后,后端启动时必须读到一对匹配的 `TRANSPORT_CRYPTO_PUBLIC_KEY` / `TRANSPORT_CRYPTO_PRIVATE_KEY`。 +- 前端会自动读取 `/transport/crypto/frontend-config`,并跟随后端配置完成请求加密、响应解密。 +- `/transport/crypto/frontend-config` 和 `/transport/crypto/public-key` 为公开接口,已配置匿名限流,前端会直接调用这两个接口完成初始化。 +- `TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS` 用于控制前端多久重新拉取一次运行策略。 +- `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 用于控制前端多久重新拉取一次公钥,两者已经独立。 + +## 生产环境 + +生产环境使用后端 `.env.prod` 中的密钥配置;仓库默认提供一套可用示例值,正式部署前请替换为正式密钥。 + +推荐最小配置如下: + +```env +TRANSPORT_CRYPTO_ENABLED=true +TRANSPORT_CRYPTO_MODE='optional' +TRANSPORT_CRYPTO_KID='2026-prod-v1' +TRANSPORT_CRYPTO_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n' +TRANSPORT_CRYPTO_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS='[]' +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS=300 +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS=3600 +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS=120 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH=4096 +``` + +说明: + +- `TRANSPORT_CRYPTO_PUBLIC_KEY` 和 `TRANSPORT_CRYPTO_PRIVATE_KEY` 必须是一对匹配密钥,缺一不可。 +- `TRANSPORT_CRYPTO_KID` 表示当前启用的密钥版本。 +- `TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS` 控制 `/transport/crypto/frontend-config` 的前端缓存时长,适合在策略经常调整时适当缩短。 +- `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 控制 `/transport/crypto/public-key` 的前端缓存时长,主要服务于公钥缓存与密钥轮换。 +- `TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS` 建议控制在 `60-120` 秒,默认收紧为 `120` 秒。 +- `TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS` 控制防重放随机数在 Redis 中的有效期;如果准备使用 `required` 模式,建议保证 Redis 稳定可用。 +- 初次上线建议先用 `TRANSPORT_CRYPTO_MODE='optional'`,确认链路稳定后再考虑切到 `required`。 +- `TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH` 用于限制 GET/DELETE 请求加密后的 URL 长度,前端会通过 `/transport/crypto/frontend-config` 自动同步该值,超限时直接提示改用 POST 或精简查询条件。 +- 传输层加密主要面向查询参数、`application/json` 与 `application/x-www-form-urlencoded` 请求;`multipart/form-data` 上传和下载接口默认排除。 + +## Docker 环境 + +当前项目的 Docker 部署使用: + +- `shierfeng-fastapi-test/docker-compose.test.my.yml` + `shierfeng-fastapi-backend/Dockerfile.my` +- `shierfeng-fastapi-test/docker-compose.test.pg.yml` + `shierfeng-fastapi-backend/Dockerfile.pg` + +后端容器启动命令分别是: + +- `shierfeng app run --env=dockermy` +- `shierfeng app run --env=dockerpg` + +所以 Docker 环境需要直接在以下文件中配置传输层密钥: + +- `shierfeng-fastapi-backend/.env.dockermy` +- `shierfeng-fastapi-backend/.env.dockerpg` + +配置方式与生产环境相同;`.env.dockermy` / `.env.dockerpg` 里也已经默认提供一套可用示例值,正式部署前请替换为正式密钥。 + +使用时只需要: + +1. 修改对应的 `.env.dockermy` 或 `.env.dockerpg` +2. 重新构建并启动 Docker 服务 + +## 密钥生成 + +使用 `openssl` 生成一套 RSA 密钥: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out transport_private.pem +openssl rsa -pubout -in transport_private.pem -out transport_public.pem +``` + +如果需要写入 `.env`,先转成单行带 `\n` 的格式: + +```bash +awk 'NF {sub(/\r/, ""); printf "%s\\\\n",$0;}' transport_private.pem +awk 'NF {sub(/\r/, ""); printf "%s\\\\n",$0;}' transport_public.pem +``` + +## 使用流程 + +1. 后端启动时读取当前 `TRANSPORT_CRYPTO_*` 配置,并校验公私钥是否同时存在且彼此匹配。 +2. 前端通过 `/transport/crypto/frontend-config` 获取当前运行策略,再通过 `/transport/crypto/public-key` 获取当前 `kid`、协议版本和公钥。 +3. `TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS` 和 `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 分别控制这两类缓存的刷新周期。 +4. 前端用公钥加密请求,后端用私钥解密请求。 +5. 后端会对命中的 JSON 响应自动加密,前端自动解密;下载、上传等排除场景保持明文。 + +## 密钥轮换 + +如果需要更换密钥: + +1. 生成新密钥对。 +2. 修改 `TRANSPORT_CRYPTO_KID` 为新版本,例如 `2026-prod-v2`。 +3. 配置新的 `TRANSPORT_CRYPTO_PUBLIC_KEY` 和 `TRANSPORT_CRYPTO_PRIVATE_KEY`。 +4. 把旧私钥放入 `TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS`。 + +补充说明: + +- `TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS` 主要用于兼容旧报文解密,最少提供 `kid` 和旧私钥即可;`publicKey` 可选,不填时后端会从私钥推导。 +- 轮换期间建议保留旧私钥直到旧公钥缓存全部过期,至少覆盖 `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 对应的缓存窗口。 + +示例: + +```env +TRANSPORT_CRYPTO_KID='2026-prod-v2' +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS='[{"kid":"2026-prod-v1","privateKey":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"}]' +``` diff --git a/shierfeng-fastapi-backend/exceptions/exception.py b/shierfeng-fastapi-backend/exceptions/exception.py new file mode 100644 index 0000000..02580ed --- /dev/null +++ b/shierfeng-fastapi-backend/exceptions/exception.py @@ -0,0 +1,58 @@ +class LoginException(Exception): + """ + 自定义登录异常LoginException + """ + + def __init__(self, data: str | None = None, message: str | None = None) -> None: + self.data = data + self.message = message + + +class AuthException(Exception): + """ + 自定义令牌异常AuthException + """ + + def __init__(self, data: str | None = None, message: str | None = None) -> None: + self.data = data + self.message = message + + +class PermissionException(Exception): + """ + 自定义权限异常PermissionException + """ + + def __init__(self, data: str | None = None, message: str | None = None) -> None: + self.data = data + self.message = message + + +class ServiceException(Exception): + """ + 自定义服务异常ServiceException + """ + + def __init__(self, data: str | None = None, message: str | None = None) -> None: + self.data = data + self.message = message + + +class ServiceWarning(Exception): + """ + 自定义服务警告ServiceWarning + """ + + def __init__(self, data: str | None = None, message: str | None = None) -> None: + self.data = data + self.message = message + + +class ModelValidatorException(Exception): + """ + 自定义模型校验异常ModelValidatorException + """ + + def __init__(self, data: str | None = None, message: str | None = None) -> None: + self.data = data + self.message = message diff --git a/shierfeng-fastapi-backend/exceptions/handle.py b/shierfeng-fastapi-backend/exceptions/handle.py new file mode 100644 index 0000000..3e8e590 --- /dev/null +++ b/shierfeng-fastapi-backend/exceptions/handle.py @@ -0,0 +1,72 @@ +from fastapi import FastAPI, Request, Response +from fastapi.exceptions import HTTPException +from pydantic_validation_decorator import FieldValidationError + +from exceptions.exception import ( + AuthException, + LoginException, + ModelValidatorException, + PermissionException, + ServiceException, + ServiceWarning, +) +from utils.log_util import logger +from utils.response_util import JSONResponse, ResponseUtil, jsonable_encoder + + +def handle_exception(app: FastAPI) -> None: + """ + 全局异常处理 + """ + + # 自定义token检验异常 + @app.exception_handler(AuthException) + async def auth_exception_handler(request: Request, exc: AuthException) -> Response: + return ResponseUtil.unauthorized(data=exc.data, msg=exc.message) + + # 自定义登录检验异常 + @app.exception_handler(LoginException) + async def login_exception_handler(request: Request, exc: LoginException) -> Response: + return ResponseUtil.failure(data=exc.data, msg=exc.message) + + # 自定义模型检验异常 + @app.exception_handler(ModelValidatorException) + async def model_validator_exception_handler(request: Request, exc: ModelValidatorException) -> Response: + logger.warning(exc.message) + return ResponseUtil.failure(data=exc.data, msg=exc.message) + + # 自定义字段检验异常 + @app.exception_handler(FieldValidationError) + async def field_validation_error_handler(request: Request, exc: FieldValidationError) -> Response: + logger.warning(exc.message) + return ResponseUtil.failure(msg=exc.message) + + # 自定义权限检验异常 + @app.exception_handler(PermissionException) + async def permission_exception_handler(request: Request, exc: PermissionException) -> Response: + return ResponseUtil.forbidden(data=exc.data, msg=exc.message) + + # 自定义服务异常 + @app.exception_handler(ServiceException) + async def service_exception_handler(request: Request, exc: ServiceException) -> Response: + logger.error(exc.message) + return ResponseUtil.error(data=exc.data, msg=exc.message) + + # 自定义服务警告 + @app.exception_handler(ServiceWarning) + async def service_warning_handler(request: Request, exc: ServiceWarning) -> Response: + logger.warning(exc.message) + return ResponseUtil.failure(data=exc.data, msg=exc.message) + + # 处理其他http请求异常 + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException) -> Response: + return JSONResponse( + content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), status_code=exc.status_code + ) + + # 处理其他异常 + @app.exception_handler(Exception) + async def exception_handler(request: Request, exc: Exception) -> Response: + logger.exception(exc) + return ResponseUtil.error(msg=str(exc)) diff --git a/shierfeng-fastapi-backend/middlewares/api_response_header_middleware.py b/shierfeng-fastapi-backend/middlewares/api_response_header_middleware.py new file mode 100644 index 0000000..5393eaf --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/api_response_header_middleware.py @@ -0,0 +1,28 @@ +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + + +class ApiResponseHeaderMiddleware(BaseHTTPMiddleware): + """ + 接口响应头追加中间件 + """ + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """ + 在响应返回前统一追加接口响应头 + """ + response = await call_next(request) + api_response_headers = getattr(request.state, 'api_response_headers', None) + if api_response_headers: + response.headers.update(api_response_headers) + return response + + +def add_api_response_header_middleware(app: FastAPI) -> None: + """ + 添加接口响应头追加中间件 + + :param app: FastAPI对象 + """ + app.add_middleware(ApiResponseHeaderMiddleware) diff --git a/shierfeng-fastapi-backend/middlewares/context_middleware.py b/shierfeng-fastapi-backend/middlewares/context_middleware.py new file mode 100644 index 0000000..89c9054 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/context_middleware.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +from common.context import RequestContext + + +class ContextCleanupMiddleware(BaseHTTPMiddleware): + """ + 上下文清理中间件 + """ + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """ + 在每个请求处理完成后清理上下文信息 + """ + response = await call_next(request) + # 请求处理完成后清理所有上下文变量 + RequestContext.clear_all() + return response + + +def add_context_cleanup_middleware(app: FastAPI) -> None: + """ + 添加上下文清理中间件 + + :param app: FastAPI对象 + """ + app.add_middleware(ContextCleanupMiddleware) diff --git a/shierfeng-fastapi-backend/middlewares/cors_middleware.py b/shierfeng-fastapi-backend/middlewares/cors_middleware.py new file mode 100644 index 0000000..df6a0ce --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/cors_middleware.py @@ -0,0 +1,28 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + + +def add_cors_middleware(app: FastAPI) -> None: + """ + 添加跨域中间件 + + :param app: FastAPI对象 + :return: + """ + # 前端页面url + origins = ['*'] + expose_headers = [ + 'x-body-encrypted', + 'x-key-id', + 'x-encrypt-alg', + ] + + # 后台api允许跨域 + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + expose_headers=expose_headers, + ) diff --git a/shierfeng-fastapi-backend/middlewares/demo_mode_middleware.py b/shierfeng-fastapi-backend/middlewares/demo_mode_middleware.py new file mode 100644 index 0000000..f2eb03f --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/demo_mode_middleware.py @@ -0,0 +1,63 @@ +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +from utils.client_ip_util import ClientIPUtil +from utils.log_util import logger +from utils.response_util import ResponseUtil + + +class DemoModeMiddleware(BaseHTTPMiddleware): + """ + 演示模式中间件 + """ + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """ + 演示模式下拦截指定请求 + """ + url_path = str(request.url) + method = request.method.lower() + intercept_url_list = [ + 'system/user', + 'system/role', + 'system/menu', + 'system/dept', + 'system/post', + 'system/dict', + 'system/config', + 'system/notice', + 'monitor/operlog', + 'monitor/logininfor', + 'monitor/online', + 'monitor/job', + 'monitor/jobLog', + 'monitor/cache', + 'ai/model', + 'ai/chat', + ] + + for item in intercept_url_list: + if (url_path.startswith(f'{request.base_url!s}{item}') and method != 'get') or url_path.startswith( + ( + f'{request.base_url!s}common', + f'{request.base_url!s}register', + f'{request.base_url!s}tool/gen/createTable', + ) + ): + operate_ip = ClientIPUtil.get_client_ip(request) + logger.warning( + '请求IP:{}||请求API:{}||请求方法:{}||请求结果:演示模式,不允许操作!', operate_ip, url_path, method + ) + return ResponseUtil.failure(msg='演示模式,不允许操作!') + response = await call_next(request) + return response + + +def add_demo_mode_middleware(app: FastAPI) -> None: + """ + 添加演示模式中间件 + + :param app: FastAPI对象 + """ + app.add_middleware(DemoModeMiddleware) diff --git a/shierfeng-fastapi-backend/middlewares/gzip_middleware.py b/shierfeng-fastapi-backend/middlewares/gzip_middleware.py new file mode 100644 index 0000000..867b786 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/gzip_middleware.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI +from starlette.middleware.gzip import GZipMiddleware + + +def add_gzip_middleware(app: FastAPI) -> None: + """ + 添加gzip压缩中间件 + + :param app: FastAPI对象 + :return: + """ + app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=9) diff --git a/shierfeng-fastapi-backend/middlewares/handle.py b/shierfeng-fastapi-backend/middlewares/handle.py new file mode 100644 index 0000000..b56c0c0 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/handle.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI + +from config.env import AppConfig +from middlewares.api_response_header_middleware import add_api_response_header_middleware +from middlewares.context_middleware import add_context_cleanup_middleware +from middlewares.cors_middleware import add_cors_middleware +from middlewares.demo_mode_middleware import add_demo_mode_middleware +from middlewares.gzip_middleware import add_gzip_middleware +from middlewares.trace_middleware import add_trace_middleware +from middlewares.transport_crypto_middleware import add_transport_crypto_middleware + + +def handle_middleware(app: FastAPI) -> None: + """ + 全局中间件处理 + """ + # 加载上下文清理中间件 + add_context_cleanup_middleware(app) + # 加载跨域中间件 + add_cors_middleware(app) + # 加载gzip压缩中间件 + add_gzip_middleware(app) + # 加载接口响应头追加中间件 + add_api_response_header_middleware(app) + # 加载trace中间件 + add_trace_middleware(app) + if AppConfig.app_demo_mode: + # 加载演示模式中间件 + add_demo_mode_middleware(app) + # 加载传输层请求解密/响应加密中间件 + add_transport_crypto_middleware(app) diff --git a/shierfeng-fastapi-backend/middlewares/trace_middleware/__init__.py b/shierfeng-fastapi-backend/middlewares/trace_middleware/__init__.py new file mode 100644 index 0000000..c2ea302 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/trace_middleware/__init__.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +from .ctx import TraceCtx +from .middle import TraceASGIMiddleware + +__all__ = ('TraceASGIMiddleware', 'TraceCtx') + +__version__ = '0.1.0' + + +def add_trace_middleware(app: FastAPI) -> None: + """ + 添加trace中间件 + + :param app: FastAPI对象 + :return: + """ + app.add_middleware(TraceASGIMiddleware) diff --git a/shierfeng-fastapi-backend/middlewares/trace_middleware/ctx.py b/shierfeng-fastapi-backend/middlewares/trace_middleware/ctx.py new file mode 100644 index 0000000..f5461d1 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/trace_middleware/ctx.py @@ -0,0 +1,64 @@ +import contextvars +from uuid import uuid4 + +CTX_TRACE_ID: contextvars.ContextVar[str] = contextvars.ContextVar('trace-id', default='') +CTX_REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar('request-id', default='') +CTX_SPAN_ID: contextvars.ContextVar[str] = contextvars.ContextVar('span-id', default='') +CTX_REQUEST_PATH: contextvars.ContextVar[str] = contextvars.ContextVar('request-path', default='') +CTX_REQUEST_METHOD: contextvars.ContextVar[str] = contextvars.ContextVar('request-method', default='') + + +class TraceCtx: + @staticmethod + def set_trace_id() -> str: + _id = uuid4().hex + CTX_TRACE_ID.set(_id) + return _id + + @staticmethod + def get_trace_id() -> str: + return CTX_TRACE_ID.get() + + @staticmethod + def set_request_id() -> str: + _id = uuid4().hex + CTX_REQUEST_ID.set(_id) + return _id + + @staticmethod + def get_request_id() -> str: + return CTX_REQUEST_ID.get() + + @staticmethod + def set_span_id() -> str: + _id = uuid4().hex + CTX_SPAN_ID.set(_id) + return _id + + @staticmethod + def get_span_id() -> str: + return CTX_SPAN_ID.get() + + @staticmethod + def set_request_path(path: str) -> None: + CTX_REQUEST_PATH.set(path) + + @staticmethod + def get_request_path() -> str: + return CTX_REQUEST_PATH.get() + + @staticmethod + def set_request_method(method: str) -> None: + CTX_REQUEST_METHOD.set(method) + + @staticmethod + def get_request_method() -> str: + return CTX_REQUEST_METHOD.get() + + @staticmethod + def clear() -> None: + CTX_TRACE_ID.set('') + CTX_REQUEST_ID.set('') + CTX_SPAN_ID.set('') + CTX_REQUEST_PATH.set('') + CTX_REQUEST_METHOD.set('') diff --git a/shierfeng-fastapi-backend/middlewares/trace_middleware/middle.py b/shierfeng-fastapi-backend/middlewares/trace_middleware/middle.py new file mode 100644 index 0000000..57a9a28 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/trace_middleware/middle.py @@ -0,0 +1,46 @@ +from functools import wraps + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from .ctx import TraceCtx +from .span import Span, get_current_span + + +class TraceASGIMiddleware: + """ + fastapi-example: + app = FastAPI() + app.add_middleware(TraceASGIMiddleware) + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + @staticmethod + async def my_receive(receive: Receive, span: Span) -> Receive: + await span.request_before() + + @wraps(receive) + async def my_receive() -> Message: + message = await receive() + await span.request_after(message) + return message + + return my_receive + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + async with get_current_span(scope) as span: + handle_outgoing_receive = await self.my_receive(receive, span) + + async def handle_outgoing_request(message: 'Message') -> None: + await span.response(message) + await send(message) + + try: + await self.app(scope, handle_outgoing_receive, handle_outgoing_request) + finally: + TraceCtx.clear() diff --git a/shierfeng-fastapi-backend/middlewares/trace_middleware/span.py b/shierfeng-fastapi-backend/middlewares/trace_middleware/span.py new file mode 100644 index 0000000..ba66000 --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/trace_middleware/span.py @@ -0,0 +1,54 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from starlette.types import Message, Scope + +from .ctx import TraceCtx + + +class Span: + """ + 整个http生命周期: + request(before) --> request(after) --> response(before) --> response(after) + """ + + def __init__(self, scope: Scope) -> None: + self.scope = scope + + async def request_before(self) -> None: + """ + request_before: 处理header信息等, 如记录请求体信息 + """ + TraceCtx.set_trace_id() + TraceCtx.set_request_id() + TraceCtx.set_span_id() + TraceCtx.set_request_path(self.scope.get('path', '')) + TraceCtx.set_request_method(self.scope.get('method', '')) + + async def request_after(self, message: Message) -> Message: + """ + request_after: 处理请求bytes, 如记录请求参数 + + example: + message: {'type': 'http.request', 'body': b'{\r\n "name": "\xe8\x8b\x8f\xe8\x8b\x8f\xe8\x8b\x8f"\r\n}', 'more_body': False} + """ + return message + + async def response(self, message: Message) -> Message: + """ + if message['type'] == "http.response.start": -----> request-before + pass + if message['type'] == "http.response.body": -----> request-after + message.get('body', b'') + pass + """ + if message['type'] == 'http.response.start': + message['headers'].append((b'request-id', TraceCtx.get_request_id().encode())) + message['headers'].append((b'trace-id', TraceCtx.get_trace_id().encode())) + message['headers'].append((b'span-id', TraceCtx.get_span_id().encode())) + return message + + +@asynccontextmanager +async def get_current_span(scope: Scope) -> AsyncGenerator[Span, None]: + yield Span(scope) diff --git a/shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py b/shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py new file mode 100644 index 0000000..623ea1c --- /dev/null +++ b/shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py @@ -0,0 +1,757 @@ +import json +from collections.abc import Awaitable, Callable +from urllib.parse import parse_qs, urlencode + +from fastapi import FastAPI, Request +from fastapi.datastructures import Headers, QueryParams +from fastapi.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from common.constant import HttpStatusConstant +from config.env import AppConfig, TransportCryptoConfig +from utils.transport_crypto_util import ( + DecryptedTransportEnvelope, + TransportCryptoMonitorUtil, + TransportCryptoUtil, + TransportSecurityUtil, +) + + +class TransportCryptoMiddleware: + """ + 传输层请求解密与响应加密中间件 + """ + + _ENCRYPT_REQUEST_HEADER = 'x-transport-encrypt' + _ENCRYPT_RESPONSE_HEADER = 'x-body-encrypted' + _ENCRYPT_ALG_HEADER = 'x-encrypt-alg' + _ENCRYPT_KID_HEADER = 'x-key-id' + _MONITOR_REQUEST_MODE_HEADER = 'x-transport-request-mode' + _MONITOR_RESPONSE_MODE_HEADER = 'x-transport-response-mode' + _MONITOR_STATUS_HEADER = 'x-transport-crypto-status' + _MONITOR_KID_HEADER = 'x-transport-key-id' + + def __init__(self, app: ASGIApp) -> None: + """ + 初始化传输层加解密中间件 + + :param app: FastAPI/Starlette应用对象 + :return: None + """ + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + 拦截HTTP请求,按配置执行请求解密与响应加密 + + :param scope: 当前ASGI请求作用域 + :param receive: ASGI receive函数 + :param send: ASGI send函数 + :return: None + """ + if scope['type'] != 'http' or not TransportCryptoConfig.transport_crypto_enabled: + await self.app(scope, receive, send) + return + + current_app = scope.get('app') + path = self._normalize_path(str(scope.get('path', ''))) + if ( + self._is_excluded_path(path) + or TransportCryptoConfig.transport_crypto_mode == 'off' + or not self._is_enabled_path(path) + ): + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + request_encrypted = headers.get(self._ENCRYPT_REQUEST_HEADER) == '1' + request_required = TransportCryptoConfig.transport_crypto_mode == 'required' or self._is_required_path(path) + + if request_required and not request_encrypted: + await TransportCryptoMonitorUtil.record_plain_request(current_app) + await TransportCryptoMonitorUtil.record_required_rejected( + current_app, str(scope.get('method', '')).upper(), path + ) + await TransportCryptoMonitorUtil.record_plain_response(current_app) + await self._send_error_response( + scope, + receive, + send, + '当前接口要求使用加密传输', + headers=self._build_monitor_headers( + request_mode='plain', + response_mode='plain', + crypto_status='required_missing', + ), + ) + return + + if not request_encrypted: + await TransportCryptoMonitorUtil.record_plain_request(current_app) + response_observer = self._build_passthrough_response_observer( + app=current_app, + send=send, + request_mode='plain', + crypto_status='pass_through', + ) + await self.app(scope, receive, response_observer) + return + + body = await self._read_body(receive) + request = Request(scope, receive=self._build_receive(body)) + try: + decrypted_scope, decrypted_body, crypto_context = await self._decrypt_request(scope, request, headers, body) + await TransportCryptoMonitorUtil.record_encrypted_request(current_app, str(crypto_context['kid'])) + await TransportCryptoMonitorUtil.record_decrypt_success(current_app, str(crypto_context['kid'])) + except Exception as exc: + error_crypto_context = self._build_error_crypto_context(scope, headers, body) + error_kid = ( + str(error_crypto_context['kid']) + if error_crypto_context + else self._extract_request_kid(scope, headers, body) + ) + await TransportCryptoMonitorUtil.record_encrypted_request(current_app, error_kid) + failure_reason = self._classify_failure_reason(str(exc)) + await TransportCryptoMonitorUtil.record_decrypt_failure( + current_app, + method=str(scope.get('method', '')).upper(), + path=path, + reason=failure_reason, + kid=error_kid, + ) + if error_crypto_context: + await TransportCryptoMonitorUtil.record_encrypted_response(current_app, error_kid, is_error=True) + else: + await TransportCryptoMonitorUtil.record_plain_response(current_app) + await self._send_error_response( + scope, + receive, + send, + str(exc) or '加密请求解析失败', + error_crypto_context, + headers=self._build_monitor_headers( + request_mode='encrypted', + response_mode='encrypted' if error_crypto_context else 'plain', + crypto_status=failure_reason, + kid=error_kid, + ), + ) + return + + async def send_wrapper(message: Message) -> None: + await response_encryptor(message) + + response_encryptor = self._build_response_encryptor( + app=current_app, + scope=decrypted_scope, + send=send, + crypto_context=crypto_context, + ) + await self.app(decrypted_scope, self._build_receive(decrypted_body), send_wrapper) + + async def _decrypt_request( + self, + scope: Scope, + request: Request, + headers: Headers, + body: bytes, + ) -> tuple[Scope, bytes, dict[str, str | bytes | bool]]: + """ + 解密请求并回写解密后的headers、query和body + + :param scope: 当前ASGI请求作用域 + :param request: FastAPI请求对象 + :param headers: 当前请求头对象 + :param body: 原始请求体字节串 + :return: 解密后的scope、请求体与加密上下文 + """ + new_scope = dict(scope) + new_scope['state'] = dict(scope.get('state', {})) + new_scope['headers'] = self._remove_header(new_scope.get('headers', []), b'accept-encoding') + content_type = headers.get('content-type', '') + + query_envelope = self._extract_query_envelope(new_scope) + body_envelope = self._extract_body_envelope(content_type, body) + + if query_envelope is None and body_envelope is None: + raise ValueError('未找到可解密的请求载荷') + + crypto_context: dict[str, str | bytes | bool] | None = None + if query_envelope is not None: + decrypted_query = await self._decrypt_envelope(request, scope, query_envelope) + query_payload = self._loads_json_mapping(decrypted_query.plaintext.decode('utf-8')) + new_scope['query_string'] = urlencode(query_payload, doseq=True).encode('utf-8') + crypto_context = self._build_crypto_context(decrypted_query) + + decrypted_body = body + if body_envelope is not None: + decrypted_body_payload = await self._decrypt_envelope(request, scope, body_envelope) + if crypto_context and crypto_context['kid'] != decrypted_body_payload.kid: + raise ValueError('请求中存在不一致的密钥版本') + if crypto_context and crypto_context['aes_key'] != decrypted_body_payload.aes_key: + raise ValueError('请求中存在不一致的会话密钥') + if crypto_context is None: + crypto_context = self._build_crypto_context(decrypted_body_payload) + + if 'application/x-www-form-urlencoded' in content_type: + form_payload = self._loads_json_mapping(decrypted_body_payload.plaintext.decode('utf-8')) + decrypted_body = urlencode(form_payload, doseq=True).encode('utf-8') + else: + decrypted_body = decrypted_body_payload.plaintext + new_scope['headers'] = self._replace_header( + new_scope.get('headers', []), b'content-length', str(len(decrypted_body)).encode('utf-8') + ) + + if crypto_context is None: + raise ValueError('加密请求缺少可用的密钥上下文') + + new_scope['state']['transport_crypto_context'] = crypto_context + return new_scope, decrypted_body, crypto_context + + async def _decrypt_envelope( + self, + request: Request, + scope: Scope, + envelope: dict[str, str], + ) -> DecryptedTransportEnvelope: + """ + 解密单个请求信封并执行时间窗、防重放校验 + + :param request: 当前请求对象 + :param scope: 当前ASGI请求作用域 + :param envelope: 请求信封字典 + :return: 解密后的请求信封对象 + """ + decrypted_payload = TransportCryptoUtil.decrypt_envelope( + envelope, + expected_method=str(scope.get('method', '')).upper(), + expected_path=self._normalize_path(str(scope.get('path', ''))), + ) + TransportSecurityUtil.validate_timestamp(decrypted_payload.timestamp) + await TransportSecurityUtil.validate_replay(request, decrypted_payload.kid, decrypted_payload.nonce) + return decrypted_payload + + def _extract_query_envelope(self, scope: Scope) -> dict[str, str] | None: + """ + 从查询参数中提取加密信封 + + :param scope: 当前ASGI请求作用域 + :return: 查询参数中的信封字典,不存在时返回None + """ + query_params = QueryParams(scope.get('query_string', b'').decode('utf-8')) + encrypted_query = query_params.get('__enc') + if not encrypted_query: + return None + return TransportCryptoUtil.decode_query_envelope(encrypted_query) + + def _extract_body_envelope(self, content_type: str, body: bytes) -> dict[str, str] | None: + """ + 根据内容类型从请求体中提取加密信封 + + :param content_type: 当前请求内容类型 + :param body: 原始请求体字节串 + :return: 请求体中的信封字典,不存在时返回None + """ + if not body or 'multipart/form-data' in content_type: + return None + + if 'application/json' in content_type: + body_payload = json.loads(body.decode('utf-8')) + if not isinstance(body_payload, dict): + raise ValueError('加密请求体格式不合法') + return body_payload + + if 'application/x-www-form-urlencoded' in content_type: + parsed_form = parse_qs(body.decode('utf-8'), keep_blank_values=True) + body_envelope = { + key: values[-1] if isinstance(values, list) else values for key, values in parsed_form.items() + } + aad = body_envelope.get('aad') + if isinstance(aad, str) and aad: + try: + parsed_aad = json.loads(aad) + if isinstance(parsed_aad, dict): + body_envelope['aad'] = parsed_aad + except json.JSONDecodeError: + pass + return body_envelope + + return None + + def _build_response_encryptor( + self, + app: FastAPI | None, + scope: Scope, + send: Send, + crypto_context: dict[str, str | bytes | bool], + ) -> Callable[[Message], Awaitable[None]]: + """ + 构建响应加密发送器,仅对JSON响应执行加密 + + :param scope: 当前ASGI请求作用域 + :param send: ASGI send函数 + :param crypto_context: 当前请求加密上下文 + :return: 包装后的ASGI send函数 + """ + response_start_message: Message | None = None + buffered_json_body: list[bytes] = [] + should_buffer_json = False + + async def _encrypt_response(message: Message) -> None: + nonlocal response_start_message, should_buffer_json + + if message['type'] == 'http.response.start': + response_start_message = message + headers = Headers(raw=message.get('headers', [])) + content_type = headers.get('content-type', '') + should_buffer_json = 'application/json' in content_type + if not should_buffer_json: + await TransportCryptoMonitorUtil.record_plain_response(app) + await send( + { + **message, + 'headers': self._merge_response_headers( + message.get('headers', []), + self._build_monitor_headers( + request_mode='encrypted', + response_mode='plain', + crypto_status='ok', + kid=str(crypto_context['kid']), + ), + ), + } + ) + return + + if message['type'] != 'http.response.body': + await send(message) + return + + if not should_buffer_json or response_start_message is None: + await send(message) + return + + buffered_json_body.append(message.get('body', b'')) + if message.get('more_body', False): + return + + encrypted_body = TransportCryptoUtil.encrypt_response_body( + aes_key=crypto_context['aes_key'], + payload=b''.join(buffered_json_body), + kid=str(crypto_context['kid']), + method=str(scope.get('method', '')), + path=self._normalize_path(str(scope.get('path', ''))), + ) + response_headers = self._replace_header( + response_start_message.get('headers', []), + b'content-length', + str(len(encrypted_body)).encode('utf-8'), + ) + response_headers = self._replace_header(response_headers, b'content-type', b'application/json') + response_headers = self._replace_header( + response_headers, self._ENCRYPT_RESPONSE_HEADER.encode('utf-8'), b'1' + ) + response_headers = self._replace_header( + response_headers, + self._ENCRYPT_ALG_HEADER.encode('utf-8'), + TransportCryptoUtil.get_response_envelope_algorithm().encode('utf-8'), + ) + response_headers = self._replace_header( + response_headers, + self._ENCRYPT_KID_HEADER.encode('utf-8'), + str(crypto_context['kid']).encode('utf-8'), + ) + response_headers = self._merge_response_headers( + response_headers, + self._build_monitor_headers( + request_mode='encrypted', + response_mode='encrypted', + crypto_status='ok', + kid=str(crypto_context['kid']), + ), + ) + await TransportCryptoMonitorUtil.record_encrypted_response(app, str(crypto_context['kid'])) + await send({**response_start_message, 'headers': response_headers}) + await send({'type': 'http.response.body', 'body': encrypted_body, 'more_body': False}) + + return _encrypt_response + + def _build_passthrough_response_observer( + self, + app: FastAPI | None, + send: Send, + request_mode: str, + crypto_status: str, + kid: str | None = None, + ) -> Callable[[Message], Awaitable[None]]: + """ + 构建明文响应观察器,为响应追加监控诊断头 + + :param send: ASGI send函数 + :param request_mode: 请求传输模式 + :param crypto_status: 当前传输层处理状态 + :param kid: 可选的密钥版本 + :return: 包装后的ASGI send函数 + """ + has_recorded_response = False + + async def _observe_response(message: Message) -> None: + nonlocal has_recorded_response + + if message['type'] == 'http.response.start': + if not has_recorded_response: + await TransportCryptoMonitorUtil.record_plain_response(app) + has_recorded_response = True + await send( + { + **message, + 'headers': self._merge_response_headers( + message.get('headers', []), + self._build_monitor_headers( + request_mode=request_mode, + response_mode='plain', + crypto_status=crypto_status, + kid=kid, + ), + ), + } + ) + return + + await send(message) + + return _observe_response + + def _build_crypto_context(self, decrypted_payload: DecryptedTransportEnvelope) -> dict[str, str | bytes | bool]: + """ + 从解密结果构建请求生命周期内的加密上下文 + + :param decrypted_payload: 解密后的请求信封对象 + :return: 请求加密上下文字典 + """ + return { + 'active': True, + 'kid': decrypted_payload.kid, + 'aes_key': decrypted_payload.aes_key, + } + + def _build_error_crypto_context( + self, + scope: Scope, + headers: Headers, + body: bytes, + ) -> dict[str, str | bytes | bool] | None: + """ + 尝试在解密失败场景下提取AES会话密钥,以便返回加密错误响应 + + :param scope: 当前ASGI请求作用域 + :param headers: 当前请求头对象 + :param body: 原始请求体字节串 + :return: 可用于构造加密错误响应的上下文字典,失败时返回None + """ + content_type = headers.get('content-type', '') + try: + query_envelope = self._extract_query_envelope(scope) + body_envelope = self._extract_body_envelope(content_type, body) + envelope = body_envelope or query_envelope + if envelope is None: + return None + TransportCryptoUtil._extract_and_validate_aad( + envelope, + expected_method=str(scope.get('method', '')).upper(), + expected_path=self._normalize_path(str(scope.get('path', ''))), + ) + return { + 'active': True, + 'kid': str(envelope['kid']), + 'aes_key': TransportCryptoUtil.decrypt_request_key(envelope), + } + except Exception: + return None + + def _extract_request_kid(self, scope: Scope, headers: Headers, body: bytes) -> str | None: + """ + 尝试从原始请求信封中提取密钥版本 + + :param scope: 当前ASGI请求作用域 + :param headers: 当前请求头对象 + :param body: 原始请求体字节串 + :return: 密钥版本,不存在时返回None + """ + content_type = headers.get('content-type', '') + try: + query_envelope = self._extract_query_envelope(scope) + body_envelope = self._extract_body_envelope(content_type, body) + envelope = body_envelope or query_envelope + except Exception: + return None + if envelope is None or not envelope.get('kid'): + return None + return str(envelope['kid']) + + @staticmethod + def _loads_json_mapping(payload: str) -> dict: + """ + 将JSON字符串解析为字典,并限制结果必须为JSON对象 + + :param payload: JSON字符串 + :return: 解析后的字典对象 + """ + json_payload = json.loads(payload) + if not isinstance(json_payload, dict): + raise ValueError('解密后的请求载荷必须为JSON对象') + return json_payload + + @staticmethod + async def _read_body(receive: Receive) -> bytes: + """ + 从ASGI receive中读取完整请求体 + + :param receive: ASGI receive函数 + :return: 完整请求体字节串 + """ + body_chunks: list[bytes] = [] + more_body = True + while more_body: + message = await receive() + if message['type'] != 'http.request': + continue + body_chunks.append(message.get('body', b'')) + more_body = message.get('more_body', False) + return b''.join(body_chunks) + + @staticmethod + def _build_receive(body: bytes) -> Receive: + """ + 根据指定请求体重建一次性可消费的ASGI receive函数 + + :param body: 需要回放的请求体字节串 + :return: 重建后的ASGI receive函数 + """ + has_been_called = False + + async def _receive() -> Message: + nonlocal has_been_called + if has_been_called: + return {'type': 'http.request', 'body': b'', 'more_body': False} + has_been_called = True + return {'type': 'http.request', 'body': body, 'more_body': False} + + return _receive + + @staticmethod + def _replace_header(headers: list[tuple[bytes, bytes]], key: bytes, value: bytes) -> list[tuple[bytes, bytes]]: + """ + 替换或新增指定响应头 + + :param headers: 原始请求/响应头列表 + :param key: 头名称 + :param value: 头值 + :return: 替换后的头列表 + """ + normalized_key = key.lower() + filtered_headers = [ + (header_key, header_value) for header_key, header_value in headers if header_key.lower() != normalized_key + ] + filtered_headers.append((key, value)) + return filtered_headers + + @staticmethod + def _remove_header(headers: list[tuple[bytes, bytes]], key: bytes) -> list[tuple[bytes, bytes]]: + """ + 删除指定请求头 + + :param headers: 原始请求头列表 + :param key: 头名称 + :return: 删除后的头列表 + """ + normalized_key = key.lower() + return [ + (header_key, header_value) for header_key, header_value in headers if header_key.lower() != normalized_key + ] + + @staticmethod + def _normalize_path(path: str) -> str: + """ + 标准化请求路径,剥离应用根路径前缀 + + :param path: 原始请求路径 + :return: 标准化后的业务路径 + """ + app_root_path = AppConfig.app_root_path + if app_root_path and path.startswith(app_root_path): + normalized_path = path[len(app_root_path) :] + return normalized_path or '/' + return path or '/' + + @classmethod + def _merge_response_headers( + cls, + headers: list[tuple[bytes, bytes]], + extra_headers: dict[str, str], + ) -> list[tuple[bytes, bytes]]: + """ + 将字符串响应头批量写回到原始headers列表 + + :param headers: 原始请求/响应头列表 + :param extra_headers: 需要追加的响应头 + :return: 合并后的响应头列表 + """ + merged_headers = headers + for header_key, header_value in extra_headers.items(): + merged_headers = cls._replace_header( + merged_headers, header_key.encode('utf-8'), header_value.encode('utf-8') + ) + return merged_headers + + @classmethod + def _build_monitor_headers( + cls, + request_mode: str, + response_mode: str, + crypto_status: str, + kid: str | None = None, + ) -> dict[str, str]: + """ + 构建传输层加解密监控响应头 + + :param request_mode: 请求传输模式 + :param response_mode: 响应传输模式 + :param crypto_status: 当前传输层处理状态 + :param kid: 可选的密钥版本 + :return: 监控响应头字典 + """ + headers = { + cls._MONITOR_REQUEST_MODE_HEADER: request_mode, + cls._MONITOR_RESPONSE_MODE_HEADER: response_mode, + cls._MONITOR_STATUS_HEADER: crypto_status, + } + if kid: + headers[cls._MONITOR_KID_HEADER] = kid + return headers + + @classmethod + async def _send_error_response( + cls, + scope: Scope, + receive: Receive, + send: Send, + message: str, + crypto_context: dict[str, str | bytes | bool] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + """ + 发送错误响应,在存在AES会话密钥时优先返回加密错误响应 + + :param scope: 当前ASGI请求作用域 + :param receive: ASGI receive函数 + :param send: ASGI send函数 + :param message: 错误信息 + :param crypto_context: 可选的请求加密上下文 + :param headers: 需要追加的诊断响应头 + :return: None + """ + response_content = {'code': HttpStatusConstant.BAD_REQUEST, 'msg': message, 'success': False} + response = JSONResponse(status_code=HttpStatusConstant.BAD_REQUEST, content=response_content) + if crypto_context: + encrypted_body = TransportCryptoUtil.encrypt_response_body( + aes_key=crypto_context['aes_key'], + payload=json.dumps(response_content, ensure_ascii=False).encode('utf-8'), + kid=str(crypto_context['kid']), + method=str(scope.get('method', '')), + path=cls._normalize_path(str(scope.get('path', ''))), + ) + response.body = encrypted_body + response.init_headers() + response.headers[cls._ENCRYPT_RESPONSE_HEADER] = '1' + response.headers[cls._ENCRYPT_ALG_HEADER] = TransportCryptoUtil.get_response_envelope_algorithm() + response.headers[cls._ENCRYPT_KID_HEADER] = str(crypto_context['kid']) + if headers: + response.headers.update(headers) + await response(scope, receive, send) + + @staticmethod + def _classify_failure_reason(message: str) -> str: + """ + 根据异常信息归类传输层失败原因 + + :param message: 原始异常信息 + :return: 失败原因分类编码 + """ + if not message or message == '加密请求解析失败': + return 'decrypt_failed' + failure_reason_mapping = ( + ('method/path与当前接口不匹配', 'aad_mismatch'), + ('缺少合法的aad', 'aad_invalid'), + ('已过期', 'timestamp_expired'), + ('缺少必要字段', 'envelope_fields_missing'), + ('协议版本不受支持', 'protocol_version_invalid'), + ('算法不受支持', 'algorithm_invalid'), + ('未找到可解密的请求载荷', 'envelope_missing'), + ('密钥版本', 'kid_mismatch'), + ) + for reason_keyword, reason_code in failure_reason_mapping: + if reason_keyword in message: + return reason_code + if '重复请求' in message or '重放' in message: + return 'replay_detected' + return 'decrypt_failed' + + @classmethod + def _is_excluded_path(cls, path: str) -> bool: + """ + 判断当前路径是否在传输加密排除列表内 + + :param path: 当前请求路径 + :return: 是否命中排除列表 + """ + excluded_paths = [ + excluded_path.strip() + for excluded_path in TransportCryptoConfig.transport_crypto_exclude_paths.split(',') + if excluded_path.strip() + ] + return any(path == excluded_path or path.startswith(f'{excluded_path}/') for excluded_path in excluded_paths) + + @classmethod + def _is_required_path(cls, path: str) -> bool: + """ + 判断当前路径是否在强制加密列表内 + + :param path: 当前请求路径 + :return: 是否命中强制加密列表 + """ + required_paths = [ + required_path.strip() + for required_path in TransportCryptoConfig.transport_crypto_required_paths.split(',') + if required_path.strip() + ] + if not required_paths: + return False + return any(path == required_path or path.startswith(f'{required_path}/') for required_path in required_paths) + + @classmethod + def _is_enabled_path(cls, path: str) -> bool: + """ + 判断当前路径是否在启用传输加密的列表内 + + :param path: 当前请求路径 + :return: 当前路径是否启用传输加密 + """ + enabled_paths = [ + enabled_path.strip() + for enabled_path in TransportCryptoConfig.transport_crypto_enabled_paths.split(',') + if enabled_path.strip() + ] + if not enabled_paths: + return True + return any(path == enabled_path or path.startswith(f'{enabled_path}/') for enabled_path in enabled_paths) + + +def add_transport_crypto_middleware(app: ASGIApp) -> None: + """ + 添加传输层加解密中间件 + + :param app: FastAPI/Starlette应用对象 + :return: None + """ + app.add_middleware(TransportCryptoMiddleware) diff --git a/shierfeng-fastapi-backend/module_admin/controller/cache_controller.py b/shierfeng-fastapi-backend/module_admin/controller/cache_controller.py new file mode 100644 index 0000000..8cb11d7 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/cache_controller.py @@ -0,0 +1,129 @@ +from typing import Annotated + +from fastapi import Path, Request, Response + +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.router import APIRouterPro +from common.vo import DataResponseModel, ResponseBaseModel +from module_admin.entity.vo.cache_vo import CacheInfoModel, CacheMonitorModel +from module_admin.service.cache_service import CacheService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +cache_controller = APIRouterPro( + prefix='/monitor/cache', order_num=15, tags=['系统监控-缓存监控'], dependencies=[PreAuthDependency()] +) + + +@cache_controller.get( + '', + summary='获取缓存监控信息接口', + description='用于获取缓存监控信息', + response_model=DataResponseModel[CacheMonitorModel], + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +async def get_monitor_cache_info(request: Request) -> Response: + # 获取全量数据 + cache_info_query_result = await CacheService.get_cache_monitor_statistical_info_services(request) + logger.info('获取成功') + + return ResponseUtil.success(data=cache_info_query_result) + + +@cache_controller.get( + '/getNames', + summary='获取缓存名称列表接口', + description='用于获取缓存名称列表', + response_model=DataResponseModel[list[CacheInfoModel]], + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +async def get_monitor_cache_name(request: Request) -> Response: + # 获取全量数据 + cache_name_list_result = await CacheService.get_cache_monitor_cache_name_services() + logger.info('获取成功') + + return ResponseUtil.success(data=cache_name_list_result) + + +@cache_controller.get( + '/getKeys/{cache_name}', + summary='获取缓存键列表接口', + description='用于获取指定缓存名称下的所有缓存键列表', + response_model=DataResponseModel[list[str]], + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +async def get_monitor_cache_key(request: Request, cache_name: Annotated[str, Path(description='缓存名称')]) -> Response: + # 获取全量数据 + cache_key_list_result = await CacheService.get_cache_monitor_cache_key_services(request, cache_name) + logger.info('获取成功') + + return ResponseUtil.success(data=cache_key_list_result) + + +@cache_controller.get( + '/getValue/{cache_name}/{cache_key}', + summary='获取缓存值接口', + description='用于获取指定缓存名称下的指定缓存键对应的值', + response_model=DataResponseModel[CacheInfoModel], + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +async def get_monitor_cache_value( + request: Request, + cache_name: Annotated[str, Path(description='缓存名称')], + cache_key: Annotated[str, Path(description='缓存键')], +) -> Response: + # 获取全量数据 + cache_value_list_result = await CacheService.get_cache_monitor_cache_value_services(request, cache_name, cache_key) + logger.info('获取成功') + + return ResponseUtil.success(data=cache_value_list_result) + + +@cache_controller.delete( + '/clearCacheName/{cache_name}', + summary='清除缓存名称接口', + description='用于清除指定缓存名称下的所有缓存键值对', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_CACHE_CLEAR_NAME, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +async def clear_monitor_cache_name( + request: Request, cache_name: Annotated[str, Path(description='缓存名称')] +) -> Response: + clear_cache_name_result = await CacheService.clear_cache_monitor_cache_name_services(request, cache_name) + logger.info(clear_cache_name_result.message) + + return ResponseUtil.success(msg=clear_cache_name_result.message) + + +@cache_controller.delete( + '/clearCacheKey/{cache_key}', + summary='清除缓存键接口', + description='用于清除指定缓存键对应的值', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_CACHE_CLEAR_KEY, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +async def clear_monitor_cache_key(request: Request, cache_key: Annotated[str, Path(description='缓存键')]) -> Response: + clear_cache_key_result = await CacheService.clear_cache_monitor_cache_key_services(request, cache_key) + logger.info(clear_cache_key_result.message) + + return ResponseUtil.success(msg=clear_cache_key_result.message) + + +@cache_controller.delete( + '/clearCacheAll', + summary='清除所有缓存接口', + description='用于清除所有缓存键值对', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:cache:list')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_CACHE_CLEAR_ALL, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +async def clear_monitor_cache_all(request: Request) -> Response: + clear_cache_all_result = await CacheService.clear_cache_monitor_all_services(request) + logger.info(clear_cache_all_result.message) + + return ResponseUtil.success(msg=clear_cache_all_result.message) diff --git a/shierfeng-fastapi-backend/module_admin/controller/captcha_controller.py b/shierfeng-fastapi-backend/module_admin/controller/captcha_controller.py new file mode 100644 index 0000000..5e3f28a --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/captcha_controller.py @@ -0,0 +1,46 @@ +import uuid +from datetime import timedelta + +from fastapi import Request, Response + +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.constant import ApiNamespace +from common.enums import RedisInitKeyConfig +from common.router import APIRouterPro +from common.vo import DynamicResponseModel +from module_admin.entity.vo.login_vo import CaptchaCode +from module_admin.service.captcha_service import CaptchaService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +captcha_controller = APIRouterPro(order_num=2, tags=['验证码模块']) + + +@captcha_controller.get( + '/captchaImage', + summary='获取图片验证码接口', + description='用于获取图片验证码', + response_model=DynamicResponseModel[CaptchaCode], +) +@ApiRateLimit(namespace=ApiNamespace.CAPTCHA_IMAGE, preset=ApiRateLimitPreset.ANON_AUTH_CAPTCHA) +async def get_captcha_image(request: Request) -> Response: + captcha_enabled = ( + await request.app.state.redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.captchaEnabled') == 'true' + ) + register_enabled = ( + await request.app.state.redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.registerUser') == 'true' + ) + session_id = str(uuid.uuid4()) + captcha_result = await CaptchaService.create_captcha_image_service() + image = captcha_result[0] + computed_result = captcha_result[1] + await request.app.state.redis.set( + f'{RedisInitKeyConfig.CAPTCHA_CODES.key}:{session_id}', computed_result, ex=timedelta(minutes=2) + ) + logger.info(f'编号为{session_id}的会话获取图片验证码成功') + + return ResponseUtil.success( + model_content=CaptchaCode( + captchaEnabled=captcha_enabled, registerEnabled=register_enabled, img=image, uuid=session_id + ) + ) diff --git a/shierfeng-fastapi-backend/module_admin/controller/common_controller.py b/shierfeng-fastapi-backend/module_admin/controller/common_controller.py new file mode 100644 index 0000000..bfa4282 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/common_controller.py @@ -0,0 +1,77 @@ +from typing import Annotated + +from fastapi import BackgroundTasks, File, Query, Request, Response, UploadFile +from fastapi.responses import StreamingResponse + +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.router import APIRouterPro +from common.vo import DynamicResponseModel +from module_admin.entity.vo.common_vo import UploadResponseModel +from module_admin.service.common_service import CommonService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +common_controller = APIRouterPro(prefix='/common', order_num=16, tags=['通用模块'], dependencies=[PreAuthDependency()]) + + +@common_controller.post( + '/upload', + summary='通用文件上传接口', + description='用于上传文件', + response_model=DynamicResponseModel[UploadResponseModel], +) +@ApiRateLimit(namespace=ApiNamespace.COMMON_UPLOAD, preset=ApiRateLimitPreset.COMMON_UPLOAD) +async def common_upload(request: Request, file: Annotated[UploadFile, File(...)]) -> Response: + upload_result = await CommonService.upload_service(request, file) + logger.info('上传成功') + + return ResponseUtil.success(model_content=upload_result.result) + + +@common_controller.get( + '/download', + summary='通用文件下载接口', + description='用于下载下载目录中的文件', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, +) +async def common_download( + request: Request, + background_tasks: BackgroundTasks, + file_name: Annotated[str, Query(alias='fileName')], + delete: Annotated[bool, Query()], +) -> Response: + download_result = await CommonService.download_services(background_tasks, file_name, delete) + logger.info(download_result.message) + + return ResponseUtil.streaming(data=download_result.result) + + +@common_controller.get( + '/download/resource', + summary='通用资源文件下载接口', + description='用于下载上传目录中的资源文件', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, +) +async def common_download_resource(request: Request, resource: Annotated[str, Query()]) -> Response: + download_resource_result = await CommonService.download_resource_services(resource) + logger.info(download_resource_result.message) + + return ResponseUtil.streaming(data=download_resource_result.result) diff --git a/shierfeng-fastapi-backend/module_admin/controller/config_controller.py b/shierfeng-fastapi-backend/module_admin/controller/config_controller.py new file mode 100644 index 0000000..bbe6cf2 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/config_controller.py @@ -0,0 +1,201 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.config_vo import ConfigModel, ConfigPageQueryModel, DeleteConfigModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.config_service import ConfigService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +config_controller = APIRouterPro( + prefix='/system/config', order_num=9, tags=['系统管理-参数管理'], dependencies=[PreAuthDependency()] +) + + +@config_controller.get( + '/list', + summary='获取参数分页列表接口', + description='用于获取参数分页列表', + response_model=PageResponseModel[ConfigModel], + dependencies=[UserInterfaceAuthDependency('system:config:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_CONFIG_LIST) +async def get_system_config_list( + request: Request, + config_page_query: Annotated[ConfigPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + config_page_query_result = await ConfigService.get_config_list_services(query_db, config_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content=config_page_query_result) + + +@config_controller.post( + '', + summary='新增参数接口', + description='用于新增参数', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:config:add')], +) +@ValidateFields(validate_model='add_config') +@ApiCacheEvict(namespaces=ApiGroup.CONFIG_MUTATION) +@Log(title='参数管理', business_type=BusinessType.INSERT) +async def add_system_config( + request: Request, + add_config: ConfigModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_config.create_by = current_user.user.user_name + add_config.create_time = datetime.now() + add_config.update_by = current_user.user.user_name + add_config.update_time = datetime.now() + add_config_result = await ConfigService.add_config_services(request, query_db, add_config) + logger.info(add_config_result.message) + + return ResponseUtil.success(msg=add_config_result.message) + + +@config_controller.put( + '', + summary='编辑参数接口', + description='用于编辑参数', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:config:edit')], +) +@ValidateFields(validate_model='edit_config') +@ApiCacheEvict(namespaces=ApiGroup.CONFIG_MUTATION) +@Log(title='参数管理', business_type=BusinessType.UPDATE) +async def edit_system_config( + request: Request, + edit_config: ConfigModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_config.update_by = current_user.user.user_name + edit_config.update_time = datetime.now() + edit_config_result = await ConfigService.edit_config_services(request, query_db, edit_config) + logger.info(edit_config_result.message) + + return ResponseUtil.success(msg=edit_config_result.message) + + +@config_controller.delete( + '/refreshCache', + summary='刷新参数缓存接口', + description='用于刷新参数缓存', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:config:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_CONFIG_REFRESH_CACHE, preset=ApiRateLimitPreset.USER_COMMON_MUTATION) +@Log(title='参数管理', business_type=BusinessType.UPDATE) +async def refresh_system_config( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + refresh_config_result = await ConfigService.refresh_sys_config_services(request, query_db) + logger.info(refresh_config_result.message) + + return ResponseUtil.success(msg=refresh_config_result.message) + + +@config_controller.delete( + '/{config_ids}', + summary='删除参数接口', + description='用于删除参数', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:config:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.CONFIG_MUTATION) +@Log(title='参数管理', business_type=BusinessType.DELETE) +async def delete_system_config( + request: Request, + config_ids: Annotated[str, Path(description='需要删除的参数主键')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_config = DeleteConfigModel(configIds=config_ids) + delete_config_result = await ConfigService.delete_config_services(request, query_db, delete_config) + logger.info(delete_config_result.message) + + return ResponseUtil.success(msg=delete_config_result.message) + + +@config_controller.get( + '/{config_id}', + summary='获取参数详情接口', + description='用于获取指定参数的详细信息', + response_model=DataResponseModel[ConfigModel], + dependencies=[UserInterfaceAuthDependency('system:config:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_CONFIG_DETAIL) +async def query_detail_system_config( + request: Request, + config_id: Annotated[int, Path(description='参数主键')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + config_detail_result = await ConfigService.config_detail_services(query_db, config_id) + logger.info(f'获取config_id为{config_id}的信息成功') + + return ResponseUtil.success(data=config_detail_result) + + +@config_controller.get( + '/configKey/{config_key}', + summary='根据参数键查询参数值接口', + description='用于根据参数键从缓存中查询参数值', + response_model=ResponseBaseModel, +) +async def query_system_config(request: Request, config_key: str) -> Response: + # 获取全量数据 + config_query_result = await ConfigService.query_config_list_from_cache_services(request.app.state.redis, config_key) + logger.info('获取成功') + + return ResponseUtil.success(msg=config_query_result) + + +@config_controller.post( + '/export', + summary='导出参数列表接口', + description='用于导出当前符合查询条件的参数列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回参数列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:config:export')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_CONFIG_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='参数管理', business_type=BusinessType.EXPORT) +async def export_system_config_list( + request: Request, + config_page_query: Annotated[ConfigPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + config_query_result = await ConfigService.get_config_list_services(query_db, config_page_query, is_page=False) + config_export_result = await ConfigService.export_config_list_services(config_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(config_export_result)) diff --git a/shierfeng-fastapi-backend/module_admin/controller/dept_controller.py b/shierfeng-fastapi-backend/module_admin/controller/dept_controller.py new file mode 100644 index 0000000..d40d946 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/dept_controller.py @@ -0,0 +1,175 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Path, Query, Request, Response +from pydantic_validation_decorator import ValidateFields +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.aspect.data_scope import DataScopeDependency +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, ResponseBaseModel +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.vo.dept_vo import DeleteDeptModel, DeptModel, DeptQueryModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.dept_service import DeptService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +dept_controller = APIRouterPro( + prefix='/system/dept', order_num=6, tags=['系统管理-部门管理'], dependencies=[PreAuthDependency()] +) + + +@dept_controller.get( + '/list/exclude/{dept_id}', + summary='获取编辑部门的下拉树接口', + description='用于获取部门下拉树,不包含指定部门及其子部门', + response_model=DataResponseModel[list[DeptModel]], + dependencies=[UserInterfaceAuthDependency('system:dept:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DEPT_EDIT_TREE) +async def get_system_dept_tree_for_edit_option( + request: Request, + dept_id: Annotated[int, Path(description='部门id')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + dept_query = DeptModel(deptId=dept_id) + dept_query_result = await DeptService.get_dept_for_edit_option_services(query_db, dept_query, data_scope_sql) + logger.info('获取成功') + + return ResponseUtil.success(data=dept_query_result) + + +@dept_controller.get( + '/list', + summary='获取部门列表接口', + description='用于获取部门列表', + response_model=DataResponseModel[list[DeptModel]], + dependencies=[UserInterfaceAuthDependency('system:dept:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DEPT_LIST) +async def get_system_dept_list( + request: Request, + dept_query: Annotated[DeptQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + dept_query_result = await DeptService.get_dept_list_services(query_db, dept_query, data_scope_sql) + logger.info('获取成功') + + return ResponseUtil.success(data=dept_query_result) + + +@dept_controller.post( + '', + summary='新增部门接口', + description='用于新增部门', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dept:add')], +) +@ValidateFields(validate_model='add_dept') +@ApiCacheEvict(namespaces=ApiGroup.DATA_SCOPE_MUTATION) +@Log(title='部门管理', business_type=BusinessType.INSERT) +async def add_system_dept( + request: Request, + add_dept: DeptModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_dept.create_by = current_user.user.user_name + add_dept.create_time = datetime.now() + add_dept.update_by = current_user.user.user_name + add_dept.update_time = datetime.now() + add_dept_result = await DeptService.add_dept_services(query_db, add_dept) + logger.info(add_dept_result.message) + + return ResponseUtil.success(msg=add_dept_result.message) + + +@dept_controller.put( + '', + summary='编辑部门接口', + description='用于编辑部门', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dept:edit')], +) +@ValidateFields(validate_model='edit_dept') +@ApiCacheEvict(namespaces=ApiGroup.DATA_SCOPE_MUTATION) +@Log(title='部门管理', business_type=BusinessType.UPDATE) +async def edit_system_dept( + request: Request, + edit_dept: DeptModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services(query_db, edit_dept.dept_id, data_scope_sql) + edit_dept.update_by = current_user.user.user_name + edit_dept.update_time = datetime.now() + edit_dept_result = await DeptService.edit_dept_services(query_db, edit_dept) + logger.info(edit_dept_result.message) + + return ResponseUtil.success(msg=edit_dept_result.message) + + +@dept_controller.delete( + '/{dept_ids}', + summary='删除部门接口', + description='用于删除部门', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dept:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.DATA_SCOPE_MUTATION) +@Log(title='部门管理', business_type=BusinessType.DELETE) +async def delete_system_dept( + request: Request, + dept_ids: Annotated[str, Path(description='需要删除的部门id')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + dept_id_list = dept_ids.split(',') if dept_ids else [] + if dept_id_list: + for dept_id in dept_id_list: + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services(query_db, int(dept_id), data_scope_sql) + delete_dept = DeleteDeptModel(deptIds=dept_ids) + delete_dept.update_by = current_user.user.user_name + delete_dept.update_time = datetime.now() + delete_dept_result = await DeptService.delete_dept_services(query_db, delete_dept) + logger.info(delete_dept_result.message) + + return ResponseUtil.success(msg=delete_dept_result.message) + + +@dept_controller.get( + '/{dept_id}', + summary='获取部门详情接口', + description='用于获取指定部门的详情信息', + response_model=DataResponseModel[DeptModel], + dependencies=[UserInterfaceAuthDependency('system:dept:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DEPT_DETAIL) +async def query_detail_system_dept( + request: Request, + dept_id: Annotated[int, Path(description='部门id')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services(query_db, dept_id, data_scope_sql) + detail_dept_result = await DeptService.dept_detail_services(query_db, dept_id) + logger.info(f'获取dept_id为{dept_id}的信息成功') + + return ResponseUtil.success(data=detail_dept_result) diff --git a/shierfeng-fastapi-backend/module_admin/controller/dict_controller.py b/shierfeng-fastapi-backend/module_admin/controller/dict_controller.py new file mode 100644 index 0000000..cd72983 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/dict_controller.py @@ -0,0 +1,377 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.dict_vo import ( + DeleteDictDataModel, + DeleteDictTypeModel, + DictDataModel, + DictDataPageQueryModel, + DictTypeModel, + DictTypePageQueryModel, +) +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.dict_service import DictDataService, DictTypeService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +dict_controller = APIRouterPro( + prefix='/system/dict', order_num=8, tags=['系统管理-字典管理'], dependencies=[PreAuthDependency()] +) + + +@dict_controller.get( + '/type/list', + summary='获取字典类型分页列表接口', + description='用于获取字典类型分页列表', + response_model=PageResponseModel[DictTypeModel], + dependencies=[UserInterfaceAuthDependency('system:dict:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DICT_TYPE_LIST) +async def get_system_dict_type_list( + request: Request, + dict_type_page_query: Annotated[DictTypePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + dict_type_page_query_result = await DictTypeService.get_dict_type_list_services( + query_db, dict_type_page_query, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=dict_type_page_query_result) + + +@dict_controller.post( + '/type', + summary='新增字典类型接口', + description='用于新增字典类型', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:add')], +) +@ValidateFields(validate_model='add_dict_type') +@ApiCacheEvict(namespaces=ApiGroup.DICT_TYPE_MUTATION) +@Log(title='字典类型', business_type=BusinessType.INSERT) +async def add_system_dict_type( + request: Request, + add_dict_type: DictTypeModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_dict_type.create_by = current_user.user.user_name + add_dict_type.create_time = datetime.now() + add_dict_type.update_by = current_user.user.user_name + add_dict_type.update_time = datetime.now() + add_dict_type_result = await DictTypeService.add_dict_type_services(request, query_db, add_dict_type) + logger.info(add_dict_type_result.message) + + return ResponseUtil.success(msg=add_dict_type_result.message) + + +@dict_controller.put( + '/type', + summary='编辑字典类型接口', + description='用于编辑字典类型', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:edit')], +) +@ValidateFields(validate_model='edit_dict_type') +@ApiCacheEvict(namespaces=ApiGroup.DICT_TYPE_MUTATION) +@Log(title='字典类型', business_type=BusinessType.UPDATE) +async def edit_system_dict_type( + request: Request, + edit_dict_type: DictTypeModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_dict_type.update_by = current_user.user.user_name + edit_dict_type.update_time = datetime.now() + edit_dict_type_result = await DictTypeService.edit_dict_type_services(request, query_db, edit_dict_type) + logger.info(edit_dict_type_result.message) + + return ResponseUtil.success(msg=edit_dict_type_result.message) + + +@dict_controller.delete( + '/type/refreshCache', + summary='刷新字典缓存接口', + description='用于刷新字典缓存', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_DICT_REFRESH_CACHE, preset=ApiRateLimitPreset.USER_COMMON_MUTATION) +@Log(title='字典类型', business_type=BusinessType.UPDATE) +async def refresh_system_dict(request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()]) -> Response: + refresh_dict_result = await DictTypeService.refresh_sys_dict_services(request, query_db) + logger.info(refresh_dict_result.message) + + return ResponseUtil.success(msg=refresh_dict_result.message) + + +@dict_controller.delete( + '/type/{dict_ids}', + summary='删除字典类型接口', + description='用于删除字典类型', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.DICT_TYPE_MUTATION) +@Log(title='字典类型', business_type=BusinessType.DELETE) +async def delete_system_dict_type( + request: Request, + dict_ids: Annotated[str, Path(description='需要删除的字典主键')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_dict_type = DeleteDictTypeModel(dictIds=dict_ids) + delete_dict_type_result = await DictTypeService.delete_dict_type_services(request, query_db, delete_dict_type) + logger.info(delete_dict_type_result.message) + + return ResponseUtil.success(msg=delete_dict_type_result.message) + + +@dict_controller.get( + '/type/optionselect', + summary='获取字典类型下拉列表接口', + description='用于获取字典类型下拉列表', + response_model=DataResponseModel[list[DictTypeModel]], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DICT_TYPE_OPTIONS) +async def query_system_dict_type_options( + request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()] +) -> Response: + dict_type_query_result = await DictTypeService.get_dict_type_list_services( + query_db, DictTypePageQueryModel(), is_page=False + ) + logger.info('获取成功') + + return ResponseUtil.success(data=dict_type_query_result) + + +@dict_controller.get( + '/type/{dict_id}', + summary='获取字典类型详情接口', + description='用于获取指定字典类型的详细信息', + response_model=DataResponseModel[DictTypeModel], + dependencies=[UserInterfaceAuthDependency('system:dict:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DICT_TYPE_DETAIL) +async def query_detail_system_dict_type( + request: Request, + dict_id: Annotated[int, Path(description='字典主键')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + dict_type_detail_result = await DictTypeService.dict_type_detail_services(query_db, dict_id) + logger.info(f'获取dict_id为{dict_id}的信息成功') + + return ResponseUtil.success(data=dict_type_detail_result) + + +@dict_controller.post( + '/type/export', + summary='导出字典类型列表接口', + description='用于导出当前符合查询条件的字典类型列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回字典类型列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:dict:export')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_DICT_TYPE_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='字典类型', business_type=BusinessType.EXPORT) +async def export_system_dict_type_list( + request: Request, + dict_type_page_query: Annotated[DictTypePageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + dict_type_query_result = await DictTypeService.get_dict_type_list_services( + query_db, dict_type_page_query, is_page=False + ) + dict_type_export_result = await DictTypeService.export_dict_type_list_services(dict_type_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(dict_type_export_result)) + + +@dict_controller.get( + '/data/type/{dict_type}', + summary='获取指定字典类型的数据列表接口', + description='用于从缓存中获取指定字典类型的所有数据项', + response_model=DataResponseModel[list[DictDataModel]], +) +async def query_system_dict_type_data( + request: Request, + dict_type: Annotated[str, Path(description='字典类型')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + dict_data_query_result = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type + ) + logger.info('获取成功') + + return ResponseUtil.success(data=dict_data_query_result) + + +@dict_controller.get( + '/data/list', + summary='获取字典数据分页列表接口', + description='用于获取字典数据分页列表', + response_model=PageResponseModel[DictDataModel], + dependencies=[UserInterfaceAuthDependency('system:dict:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DICT_DATA_LIST) +async def get_system_dict_data_list( + request: Request, + dict_data_page_query: Annotated[DictDataPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + dict_data_page_query_result = await DictDataService.get_dict_data_list_services( + query_db, dict_data_page_query, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=dict_data_page_query_result) + + +@dict_controller.post( + '/data', + summary='新增字典数据接口', + description='用于新增字典数据', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:add')], +) +@ValidateFields(validate_model='add_dict_data') +@ApiCacheEvict(namespaces=ApiGroup.DICT_DATA_MUTATION) +@Log(title='字典数据', business_type=BusinessType.INSERT) +async def add_system_dict_data( + request: Request, + add_dict_data: DictDataModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_dict_data.create_by = current_user.user.user_name + add_dict_data.create_time = datetime.now() + add_dict_data.update_by = current_user.user.user_name + add_dict_data.update_time = datetime.now() + add_dict_data_result = await DictDataService.add_dict_data_services(request, query_db, add_dict_data) + logger.info(add_dict_data_result.message) + + return ResponseUtil.success(msg=add_dict_data_result.message) + + +@dict_controller.put( + '/data', + summary='编辑字典数据接口', + description='用于编辑字典数据', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:edit')], +) +@ValidateFields(validate_model='edit_dict_data') +@ApiCacheEvict(namespaces=ApiGroup.DICT_DATA_MUTATION) +@Log(title='字典数据', business_type=BusinessType.UPDATE) +async def edit_system_dict_data( + request: Request, + edit_dict_data: DictDataModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_dict_data.update_by = current_user.user.user_name + edit_dict_data.update_time = datetime.now() + edit_dict_data_result = await DictDataService.edit_dict_data_services(request, query_db, edit_dict_data) + logger.info(edit_dict_data_result.message) + + return ResponseUtil.success(msg=edit_dict_data_result.message) + + +@dict_controller.delete( + '/data/{dict_codes}', + summary='删除字典数据接口', + description='用于删除字典数据', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:dict:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.DICT_DATA_MUTATION) +@Log(title='字典数据', business_type=BusinessType.DELETE) +async def delete_system_dict_data( + request: Request, + dict_codes: Annotated[str, Path(description='需要删除的字典编码')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_dict_data = DeleteDictDataModel(dictCodes=dict_codes) + delete_dict_data_result = await DictDataService.delete_dict_data_services(request, query_db, delete_dict_data) + logger.info(delete_dict_data_result.message) + + return ResponseUtil.success(msg=delete_dict_data_result.message) + + +@dict_controller.get( + '/data/{dict_code}', + summary='获取字典数据详情接口', + description='用于获取指定字典数据的详细信息', + response_model=DataResponseModel[DictDataModel], + dependencies=[UserInterfaceAuthDependency('system:dict:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_DICT_DATA_DETAIL) +async def query_detail_system_dict_data( + request: Request, + dict_code: Annotated[int, Path(description='字典编码')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + detail_dict_data_result = await DictDataService.dict_data_detail_services(query_db, dict_code) + logger.info(f'获取dict_code为{dict_code}的信息成功') + + return ResponseUtil.success(data=detail_dict_data_result) + + +@dict_controller.post( + '/data/export', + summary='导出字典数据列表接口', + description='用于导出当前符合查询条件的字典数据列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回字典数据列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:dict:export')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_DICT_DATA_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='字典数据', business_type=BusinessType.EXPORT) +async def export_system_dict_data_list( + request: Request, + dict_data_page_query: Annotated[DictDataPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + dict_data_query_result = await DictDataService.get_dict_data_list_services( + query_db, dict_data_page_query, is_page=False + ) + dict_data_export_result = await DictDataService.export_dict_data_list_services(dict_data_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(dict_data_export_result)) diff --git a/shierfeng-fastapi-backend/module_admin/controller/job_controller.py b/shierfeng-fastapi-backend/module_admin/controller/job_controller.py new file mode 100644 index 0000000..38db601 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/job_controller.py @@ -0,0 +1,318 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.job_vo import ( + DeleteJobLogModel, + DeleteJobModel, + EditJobModel, + JobLogModel, + JobLogPageQueryModel, + JobModel, + JobPageQueryModel, +) +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.job_log_service import JobLogService +from module_admin.service.job_service import JobService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +job_controller = APIRouterPro( + prefix='/monitor', order_num=13, tags=['系统监控-定时任务'], dependencies=[PreAuthDependency()] +) + + +@job_controller.get( + '/job/list', + summary='获取定时任务分页列表接口', + description='用于获取定时任务分页列表', + response_model=PageResponseModel[JobModel], + dependencies=[UserInterfaceAuthDependency('monitor:job:list')], +) +@ApiCache(namespace=ApiNamespace.MONITOR_JOB_LIST) +async def get_system_job_list( + request: Request, + job_page_query: Annotated[JobPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + job_page_query_result = await JobService.get_job_list_services(query_db, job_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content=job_page_query_result) + + +@job_controller.post( + '/job', + summary='新增定时任务接口', + description='用于新增定时任务', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:add')], +) +@ValidateFields(validate_model='add_job') +@ApiCacheEvict(namespaces=ApiGroup.JOB_MUTATION) +@Log(title='定时任务', business_type=BusinessType.INSERT) +async def add_system_job( + request: Request, + add_job: JobModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_job.create_by = current_user.user.user_name + add_job.create_time = datetime.now() + add_job.update_by = current_user.user.user_name + add_job.update_time = datetime.now() + add_job_result = await JobService.add_job_services(query_db, add_job) + logger.info(add_job_result.message) + + return ResponseUtil.success(msg=add_job_result.message) + + +@job_controller.put( + '/job', + summary='编辑定时任务接口', + description='用于编辑定时任务', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:edit')], +) +@ValidateFields(validate_model='edit_job') +@ApiCacheEvict(namespaces=ApiGroup.JOB_MUTATION) +@Log(title='定时任务', business_type=BusinessType.UPDATE) +async def edit_system_job( + request: Request, + edit_job: EditJobModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_job.update_by = current_user.user.user_name + edit_job.update_time = datetime.now() + edit_job_result = await JobService.edit_job_services(query_db, edit_job) + logger.info(edit_job_result.message) + + return ResponseUtil.success(msg=edit_job_result.message) + + +@job_controller.put( + '/job/changeStatus', + summary='修改定时任务状态接口', + description='用于修改定时任务状态', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:changeStatus')], +) +@ApiCacheEvict(namespaces=ApiGroup.JOB_MUTATION) +@Log(title='定时任务', business_type=BusinessType.UPDATE) +async def change_system_job_status( + request: Request, + change_job: EditJobModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_job = EditJobModel( + jobId=change_job.job_id, + status=change_job.status, + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + type='status', + ) + edit_job_result = await JobService.edit_job_services(query_db, edit_job) + logger.info(edit_job_result.message) + + return ResponseUtil.success(msg=edit_job_result.message) + + +@job_controller.put( + '/job/run', + summary='执行定时任务接口', + description='用于执行指定的定时任务', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:changeStatus')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_RUN, preset=ApiRateLimitPreset.USER_RESOURCE_EXECUTION) +@ApiCacheEvict(namespaces=ApiGroup.JOB_MUTATION) +@Log(title='定时任务', business_type=BusinessType.UPDATE) +async def execute_system_job( + request: Request, + execute_job: JobModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + execute_job_result = await JobService.execute_job_once_services(query_db, execute_job) + logger.info(execute_job_result.message) + + return ResponseUtil.success(msg=execute_job_result.message) + + +@job_controller.delete( + '/job/{job_ids}', + summary='删除定时任务接口', + description='用于删除定时任务', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_DELETE, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@ApiCacheEvict(namespaces=ApiGroup.JOB_MUTATION) +@Log(title='定时任务', business_type=BusinessType.DELETE) +async def delete_system_job( + request: Request, + job_ids: Annotated[str, Path(description='需要删除的定时任务ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_job = DeleteJobModel(jobIds=job_ids) + delete_job_result = await JobService.delete_job_services(query_db, delete_job) + logger.info(delete_job_result.message) + + return ResponseUtil.success(msg=delete_job_result.message) + + +@job_controller.get( + '/job/{job_id}', + summary='获取定时任务详情接口', + description='用于获取指定定时任务的详情信息', + response_model=DataResponseModel[JobModel], + dependencies=[UserInterfaceAuthDependency('monitor:job:query')], +) +@ApiCache(namespace=ApiNamespace.MONITOR_JOB_DETAIL) +async def query_detail_system_job( + request: Request, + job_id: Annotated[int, Path(description='任务ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + job_detail_result = await JobService.job_detail_services(query_db, job_id) + logger.info(f'获取job_id为{job_id}的信息成功') + + return ResponseUtil.success(data=job_detail_result) + + +@job_controller.post( + '/job/export', + summary='导出定时任务列表接口', + description='用于导出当前符合查询条件的定时任务列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回定时任务列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('monitor:job:export')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='定时任务', business_type=BusinessType.EXPORT) +async def export_system_job_list( + request: Request, + job_page_query: Annotated[JobPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + job_query_result = await JobService.get_job_list_services(query_db, job_page_query, is_page=False) + job_export_result = await JobService.export_job_list_services(request, job_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(job_export_result)) + + +@job_controller.get( + '/jobLog/list', + summary='获取定时任务调度日志分页列表接口', + description='用于获取定时任务调度日志分页列表', + response_model=PageResponseModel[JobLogModel], + dependencies=[UserInterfaceAuthDependency('monitor:job:list')], +) +async def get_system_job_log_list( + request: Request, + job_log_page_query: Annotated[JobLogPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + job_log_page_query_result = await JobLogService.get_job_log_list_services( + query_db, job_log_page_query, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=job_log_page_query_result) + + +@job_controller.delete( + '/jobLog/clean', + summary='清空定时任务调度日志接口', + description='用于清空所有定时任务调度日志', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_LOG_CLEAN, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@Log(title='定时任务调度日志', business_type=BusinessType.CLEAN) +async def clear_system_job_log( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + clear_job_log_result = await JobLogService.clear_job_log_services(query_db) + logger.info(clear_job_log_result.message) + + return ResponseUtil.success(msg=clear_job_log_result.message) + + +@job_controller.delete( + '/jobLog/{job_log_ids}', + summary='删除定时任务调度日志接口', + description='用于删除定时任务调度日志', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:job:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_LOG_DELETE, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@Log(title='定时任务调度日志', business_type=BusinessType.DELETE) +async def delete_system_job_log( + request: Request, + job_log_ids: Annotated[str, Path(description='需要删除的定时任务日志ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_job_log = DeleteJobLogModel(jobLogIds=job_log_ids) + delete_job_log_result = await JobLogService.delete_job_log_services(query_db, delete_job_log) + logger.info(delete_job_log_result.message) + + return ResponseUtil.success(msg=delete_job_log_result.message) + + +@job_controller.post( + '/jobLog/export', + summary='导出定时任务调度日志列表接口', + description='用于导出当前符合查询条件的定时任务调度日志列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回定时任务日志列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('monitor:job:export')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_LOG_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='定时任务调度日志', business_type=BusinessType.EXPORT) +async def export_system_job_log_list( + request: Request, + job_log_page_query: Annotated[JobLogPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + job_log_query_result = await JobLogService.get_job_log_list_services(query_db, job_log_page_query, is_page=False) + job_log_export_result = await JobLogService.export_job_log_list_services(request, job_log_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(job_log_export_result)) diff --git a/shierfeng-fastapi-backend/module_admin/controller/log_controller.py b/shierfeng-fastapi-backend/module_admin/controller/log_controller.py new file mode 100644 index 0000000..e84d0f8 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/log_controller.py @@ -0,0 +1,241 @@ +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.log_vo import ( + DeleteLoginLogModel, + DeleteOperLogModel, + LogininforModel, + LoginLogPageQueryModel, + OperLogModel, + OperLogPageQueryModel, + UnlockUser, +) +from module_admin.service.log_service import LoginLogService, OperationLogService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +log_controller = APIRouterPro( + prefix='/monitor', order_num=11, tags=['系统管理-日志管理'], dependencies=[PreAuthDependency()] +) + + +@log_controller.get( + '/operlog/list', + summary='获取操作日志分页列表接口', + description='用于获取操作日志分页列表', + response_model=PageResponseModel[OperLogModel], + dependencies=[UserInterfaceAuthDependency('monitor:operlog:list')], +) +async def get_system_operation_log_list( + request: Request, + operation_log_page_query: Annotated[OperLogPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + operation_log_page_query_result = await OperationLogService.get_operation_log_list_services( + query_db, operation_log_page_query, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=operation_log_page_query_result) + + +@log_controller.delete( + '/operlog/clean', + summary='清空操作日志接口', + description='用于清空所有操作日志', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:operlog:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_OPERLOG_CLEAN, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@Log(title='操作日志', business_type=BusinessType.CLEAN) +async def clear_system_operation_log( + request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()] +) -> Response: + clear_operation_log_result = await OperationLogService.clear_operation_log_services(query_db) + logger.info(clear_operation_log_result.message) + + return ResponseUtil.success(msg=clear_operation_log_result.message) + + +@log_controller.delete( + '/operlog/{oper_ids}', + summary='删除操作日志接口', + description='用于删除操作日志', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:operlog:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_OPERLOG_DELETE, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@Log(title='操作日志', business_type=BusinessType.DELETE) +async def delete_system_operation_log( + request: Request, + oper_ids: Annotated[str, Path(description='需要删除的日志主键')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_operation_log = DeleteOperLogModel(operIds=oper_ids) + delete_operation_log_result = await OperationLogService.delete_operation_log_services( + query_db, delete_operation_log + ) + logger.info(delete_operation_log_result.message) + + return ResponseUtil.success(msg=delete_operation_log_result.message) + + +@log_controller.post( + '/operlog/export', + summary='导出操作日志接口', + description='用于导出当前符合查询条件的操作日志数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回操作日志列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('monitor:operlog:export')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_OPERLOG_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='操作日志', business_type=BusinessType.EXPORT) +async def export_system_operation_log_list( + request: Request, + operation_log_page_query: Annotated[OperLogPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + operation_log_query_result = await OperationLogService.get_operation_log_list_services( + query_db, operation_log_page_query, is_page=False + ) + operation_log_export_result = await OperationLogService.export_operation_log_list_services( + request, operation_log_query_result + ) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(operation_log_export_result)) + + +@log_controller.get( + '/logininfor/list', + summary='获取登录日志分页列表接口', + description='用于获取登录日志分页列表', + response_model=PageResponseModel[LogininforModel], + dependencies=[UserInterfaceAuthDependency('monitor:logininfor:list')], +) +async def get_system_login_log_list( + request: Request, + login_log_page_query: Annotated[LoginLogPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + login_log_page_query_result = await LoginLogService.get_login_log_list_services( + query_db, login_log_page_query, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=login_log_page_query_result) + + +@log_controller.delete( + '/logininfor/clean', + summary='清空登录日志接口', + description='用于清空所有登录日志', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:logininfor:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_LOGININFO_CLEAN, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@Log(title='登录日志', business_type=BusinessType.CLEAN) +async def clear_system_login_log( + request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()] +) -> Response: + clear_login_log_result = await LoginLogService.clear_login_log_services(query_db) + logger.info(clear_login_log_result.message) + + return ResponseUtil.success(msg=clear_login_log_result.message) + + +@log_controller.delete( + '/logininfor/{info_ids}', + summary='删除登录日志接口', + description='用于删除登录日志', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:logininfor:remove')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_LOGININFO_DELETE, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) +@Log(title='登录日志', business_type=BusinessType.DELETE) +async def delete_system_login_log( + request: Request, + info_ids: Annotated[str, Path(description='需要删除的访问ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_login_log = DeleteLoginLogModel(infoIds=info_ids) + delete_login_log_result = await LoginLogService.delete_login_log_services(query_db, delete_login_log) + logger.info(delete_login_log_result.message) + + return ResponseUtil.success(msg=delete_login_log_result.message) + + +@log_controller.get( + '/logininfor/unlock/{user_name}', + summary='解锁账户接口', + description='用于解锁指定用户账户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:logininfor:unlock')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_LOGININFO_UNLOCK, preset=ApiRateLimitPreset.USER_SECURITY_MUTATION) +@Log(title='账户解锁', business_type=BusinessType.OTHER) +async def unlock_system_user( + request: Request, + user_name: Annotated[str, Path(description='用户名称')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + unlock_user = UnlockUser(userName=user_name) + unlock_user_result = await LoginLogService.unlock_user_services(request, unlock_user) + logger.info(unlock_user_result.message) + + return ResponseUtil.success(msg=unlock_user_result.message) + + +@log_controller.post( + '/logininfor/export', + summary='导出登录日志接口', + description='用于导出当前符合查询条件的登录日志数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回登录日志列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('monitor:logininfor:export')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_LOGININFO_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='登录日志', business_type=BusinessType.EXPORT) +async def export_system_login_log_list( + request: Request, + login_log_page_query: Annotated[LoginLogPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + login_log_query_result = await LoginLogService.get_login_log_list_services( + query_db, login_log_page_query, is_page=False + ) + login_log_export_result = await LoginLogService.export_login_log_list_services(login_log_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(login_log_export_result)) diff --git a/shierfeng-fastapi-backend/module_admin/controller/login_controller.py b/shierfeng-fastapi-backend/module_admin/controller/login_controller.py new file mode 100644 index 0000000..4eadc4a --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/login_controller.py @@ -0,0 +1,192 @@ +import uuid +from datetime import datetime, timedelta +from typing import Annotated + +import jwt +from fastapi import Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.pre_auth import CurrentUserDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType, RedisInitKeyConfig +from common.router import APIRouterPro +from common.vo import CrudResponseModel, DataResponseModel, DynamicResponseModel, ResponseBaseModel +from config.env import AppConfig, JwtConfig +from module_admin.entity.vo.login_vo import LoginToken, RouterModel, Token, UserLogin, UserRegister +from module_admin.entity.vo.user_vo import CurrentUserModel, EditUserModel +from module_admin.service.login_service import CustomOAuth2PasswordRequestForm, LoginService, oauth2_scheme +from module_admin.service.user_service import UserService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +login_controller = APIRouterPro(order_num=1, tags=['登录模块']) + + +@login_controller.post( + '/login', + summary='登录接口', + description='用于用户登录', + response_model=DynamicResponseModel[LoginToken] | Token, +) +@ApiRateLimit(namespace=ApiNamespace.LOGIN, preset=ApiRateLimitPreset.ANON_AUTH_LOGIN) +@ApiCacheEvict(namespaces=ApiGroup.LOGIN_SUCCESS_MUTATION) +@Log(title='用户登录', business_type=BusinessType.OTHER, log_type='login') +async def login( + request: Request, + form_data: Annotated[CustomOAuth2PasswordRequestForm, Depends()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + captcha_enabled = ( + await request.app.state.redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.captchaEnabled') == 'true' + ) + user = UserLogin( + userName=form_data.username, + password=form_data.password, + code=form_data.code, + uuid=form_data.uuid, + loginInfo=form_data.login_info, + captchaEnabled=captcha_enabled, + ) + result = await LoginService.authenticate_user(request, query_db, user) + access_token_expires = timedelta(minutes=JwtConfig.jwt_expire_minutes) + session_id = str(uuid.uuid4()) + access_token = await LoginService.create_access_token( + data={ + 'user_id': str(result[0].user_id), + 'user_name': result[0].user_name, + 'dept_name': result[1].dept_name if result[1] else None, + 'session_id': session_id, + 'login_info': user.login_info, + }, + expires_delta=access_token_expires, + ) + if AppConfig.app_same_time_login: + await request.app.state.redis.set( + f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}', + access_token, + ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), + ) + else: + # 此方法可实现同一账号同一时间只能登录一次 + await request.app.state.redis.set( + f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{result[0].user_id}', + access_token, + ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), + ) + await UserService.edit_user_services( + query_db, EditUserModel(userId=result[0].user_id, loginDate=datetime.now(), type='status') + ) + logger.info('登录成功') + # 判断请求是否来自于api文档,如果是返回指定格式的结果,用于修复api文档认证成功后token显示undefined的bug + request_from_swagger = request.headers.get('referer').endswith('docs') if request.headers.get('referer') else False + request_from_redoc = request.headers.get('referer').endswith('redoc') if request.headers.get('referer') else False + if request_from_swagger or request_from_redoc: + return {'access_token': access_token, 'token_type': 'Bearer'} + return ResponseUtil.success(msg='登录成功', dict_content={'token': access_token}) + + +@login_controller.get( + '/getInfo', + summary='获取用户信息接口', + description='用于获取当前登录用户的信息', + response_model=DynamicResponseModel[CurrentUserModel], +) +@ApiCache(namespace=ApiNamespace.LOGIN_USER_INFO) +async def get_login_user_info( + request: Request, current_user: Annotated[CurrentUserModel, CurrentUserDependency()] +) -> Response: + logger.info('获取成功') + + return ResponseUtil.success(model_content=current_user) + + +@login_controller.get( + '/getRouters', + summary='获取用户路由接口', + description='用于获取当前登录用户的路由信息', + response_model=DataResponseModel[list[RouterModel]], +) +@ApiCache(namespace=ApiNamespace.LOGIN_USER_ROUTERS) +async def get_login_user_routers( + request: Request, + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + logger.info('获取成功') + user_routers = await LoginService.get_current_user_routers(current_user.user.user_id, query_db) + + return ResponseUtil.success(data=user_routers) + + +@login_controller.post( + '/register', + summary='注册接口', + description='用于用户注册', + response_model=DataResponseModel[CrudResponseModel], +) +@ApiRateLimit(namespace=ApiNamespace.REGISTER, preset=ApiRateLimitPreset.ANON_AUTH_REGISTER) +@ApiCacheEvict(namespaces=ApiGroup.USER_ENTITY_MUTATION) +async def register_user( + request: Request, + user_register: UserRegister, + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + user_register_result = await LoginService.register_user_services(request, query_db, user_register) + logger.info(user_register_result.message) + + return ResponseUtil.success(data=user_register_result, msg=user_register_result.message) + + +# @login_controller.post("/getSmsCode", response_model=SmsCode) +# async def get_sms_code(request: Request, user: ResetUserModel, query_db: AsyncSession = DBSessionDependency()): +# try: +# sms_result = await LoginService.get_sms_code_services(request, query_db, user) +# if sms_result.is_success: +# logger.info('获取成功') +# return ResponseUtil.success(data=sms_result) +# else: +# logger.warning(sms_result.message) +# return ResponseUtil.failure(msg=sms_result.message) +# except Exception as e: +# logger.exception(e) +# return ResponseUtil.error(msg=str(e)) +# +# +# @login_controller.post("/forgetPwd", response_model=CrudResponseModel) +# async def forget_user_pwd(request: Request, forget_user: ResetUserModel, query_db: AsyncSession = DBSessionDependency()): +# try: +# forget_user_result = await LoginService.forget_user_services(request, query_db, forget_user) +# if forget_user_result.is_success: +# logger.info(forget_user_result.message) +# return ResponseUtil.success(data=forget_user_result, msg=forget_user_result.message) +# else: +# logger.warning(forget_user_result.message) +# return ResponseUtil.failure(msg=forget_user_result.message) +# except Exception as e: +# logger.exception(e) +# return ResponseUtil.error(msg=str(e)) + + +@login_controller.post( + '/logout', + summary='退出登录接口', + description='用于用户退出登录', + response_model=ResponseBaseModel, +) +@ApiCacheEvict(namespaces=ApiGroup.LOGOUT_MUTATION) +async def logout(request: Request, token: Annotated[str | None, Depends(oauth2_scheme)]) -> Response: + payload = jwt.decode( + token, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm], options={'verify_exp': False} + ) + if AppConfig.app_same_time_login: + token_id: str = payload.get('session_id') + else: + token_id: str = payload.get('user_id') + await LoginService.logout_services(request, token_id) + logger.info('退出成功') + + return ResponseUtil.success(msg='退出成功') diff --git a/shierfeng-fastapi-backend/module_admin/controller/menu_controller.py b/shierfeng-fastapi-backend/module_admin/controller/menu_controller.py new file mode 100644 index 0000000..5747ee8 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/menu_controller.py @@ -0,0 +1,173 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Path, Query, Request, Response +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, DynamicResponseModel, ResponseBaseModel +from module_admin.entity.vo.menu_vo import DeleteMenuModel, MenuModel, MenuQueryModel, MenuTreeModel +from module_admin.entity.vo.role_vo import RoleMenuQueryModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.menu_service import MenuService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +menu_controller = APIRouterPro( + prefix='/system/menu', order_num=5, tags=['系统管理-菜单管理'], dependencies=[PreAuthDependency()] +) + + +@menu_controller.get( + '/treeselect', + summary='获取菜单树接口', + description='用于获取当前用户可见的菜单树', + response_model=DataResponseModel[list[MenuTreeModel]], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_MENU_TREE) +async def get_system_menu_tree( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + menu_query_result = await MenuService.get_menu_tree_services(query_db, current_user) + logger.info('获取成功') + + return ResponseUtil.success(data=menu_query_result) + + +@menu_controller.get( + '/roleMenuTreeselect/{role_id}', + summary='获取角色菜单树接口', + description='用于获取指定角色可见的菜单树', + response_model=DynamicResponseModel[RoleMenuQueryModel], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_MENU_ROLE_TREE) +async def get_system_role_menu_tree( + request: Request, + role_id: Annotated[int, Path(description='角色ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + role_menu_query_result = await MenuService.get_role_menu_tree_services(query_db, role_id, current_user) + logger.info('获取成功') + + return ResponseUtil.success(model_content=role_menu_query_result) + + +@menu_controller.get( + '/list', + summary='获取菜单列表接口', + description='用于获取当前用户可见的菜单列表', + response_model=DataResponseModel[list[MenuModel]], + dependencies=[UserInterfaceAuthDependency('system:menu:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_MENU_LIST) +async def get_system_menu_list( + request: Request, + menu_query: Annotated[MenuQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + menu_query_result = await MenuService.get_menu_list_services(query_db, menu_query, current_user) + logger.info('获取成功') + + return ResponseUtil.success(data=menu_query_result) + + +@menu_controller.post( + '', + summary='新增菜单接口', + description='用于新增菜单', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:menu:add')], +) +@ValidateFields(validate_model='add_menu') +@ApiCacheEvict(namespaces=ApiGroup.MENU_MUTATION) +@Log(title='菜单管理', business_type=BusinessType.INSERT) +async def add_system_menu( + request: Request, + add_menu: MenuModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_menu.create_by = current_user.user.user_name + add_menu.create_time = datetime.now() + add_menu.update_by = current_user.user.user_name + add_menu.update_time = datetime.now() + add_menu_result = await MenuService.add_menu_services(query_db, add_menu) + logger.info(add_menu_result.message) + + return ResponseUtil.success(msg=add_menu_result.message) + + +@menu_controller.put( + '', + summary='编辑菜单接口', + description='用于编辑菜单', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:menu:edit')], +) +@ValidateFields(validate_model='edit_menu') +@ApiCacheEvict(namespaces=ApiGroup.MENU_MUTATION) +@Log(title='菜单管理', business_type=BusinessType.UPDATE) +async def edit_system_menu( + request: Request, + edit_menu: MenuModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_menu.update_by = current_user.user.user_name + edit_menu.update_time = datetime.now() + edit_menu_result = await MenuService.edit_menu_services(query_db, edit_menu) + logger.info(edit_menu_result.message) + + return ResponseUtil.success(msg=edit_menu_result.message) + + +@menu_controller.delete( + '/{menu_ids}', + summary='删除菜单接口', + description='用于删除菜单', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:menu:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.MENU_MUTATION) +@Log(title='菜单管理', business_type=BusinessType.DELETE) +async def delete_system_menu( + request: Request, + menu_ids: Annotated[str, Path(description='需要删除的菜单ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_menu = DeleteMenuModel(menuIds=menu_ids) + delete_menu_result = await MenuService.delete_menu_services(query_db, delete_menu) + logger.info(delete_menu_result.message) + + return ResponseUtil.success(msg=delete_menu_result.message) + + +@menu_controller.get( + '/{menu_id}', + summary='获取菜单详情接口', + description='用于获取指定菜单的详情信息', + response_model=DataResponseModel[MenuModel], + dependencies=[UserInterfaceAuthDependency('system:menu:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_MENU_DETAIL) +async def query_detail_system_menu( + request: Request, + menu_id: Annotated[int, Path(description='菜单ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + menu_detail_result = await MenuService.menu_detail_services(query_db, menu_id) + logger.info(f'获取menu_id为{menu_id}的信息成功') + + return ResponseUtil.success(data=menu_detail_result) diff --git a/shierfeng-fastapi-backend/module_admin/controller/notice_controller.py b/shierfeng-fastapi-backend/module_admin/controller/notice_controller.py new file mode 100644 index 0000000..c78d787 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/notice_controller.py @@ -0,0 +1,135 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Path, Query, Request, Response +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.notice_vo import DeleteNoticeModel, NoticeModel, NoticePageQueryModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.notice_service import NoticeService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +notice_controller = APIRouterPro( + prefix='/system/notice', order_num=10, tags=['系统管理-通知公告管理'], dependencies=[PreAuthDependency()] +) + + +@notice_controller.get( + '/list', + summary='获取通知公告分页列表接口', + description='用于获取通知公告分页列表', + response_model=PageResponseModel[NoticeModel], + dependencies=[UserInterfaceAuthDependency('system:notice:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_NOTICE_LIST) +async def get_system_notice_list( + request: Request, + notice_page_query: Annotated[NoticePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + notice_page_query_result = await NoticeService.get_notice_list_services(query_db, notice_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content=notice_page_query_result) + + +@notice_controller.post( + '', + summary='新增通知公告接口', + description='用于新增通知公告', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:notice:add')], +) +@ValidateFields(validate_model='add_notice') +@ApiCacheEvict(namespaces=ApiGroup.NOTICE_MUTATION) +@Log(title='通知公告', business_type=BusinessType.INSERT) +async def add_system_notice( + request: Request, + add_notice: NoticeModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_notice.create_by = current_user.user.user_name + add_notice.create_time = datetime.now() + add_notice.update_by = current_user.user.user_name + add_notice.update_time = datetime.now() + add_notice_result = await NoticeService.add_notice_services(query_db, add_notice) + logger.info(add_notice_result.message) + + return ResponseUtil.success(msg=add_notice_result.message) + + +@notice_controller.put( + '', + summary='编辑通知公告接口', + description='用于编辑通知公告', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:notice:edit')], +) +@ValidateFields(validate_model='edit_notice') +@ApiCacheEvict(namespaces=ApiGroup.NOTICE_MUTATION) +@Log(title='通知公告', business_type=BusinessType.UPDATE) +async def edit_system_notice( + request: Request, + edit_notice: NoticeModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_notice.update_by = current_user.user.user_name + edit_notice.update_time = datetime.now() + edit_notice_result = await NoticeService.edit_notice_services(query_db, edit_notice) + logger.info(edit_notice_result.message) + + return ResponseUtil.success(msg=edit_notice_result.message) + + +@notice_controller.delete( + '/{notice_ids}', + summary='删除通知公告接口', + description='用于删除通知公告', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:notice:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.NOTICE_MUTATION) +@Log(title='通知公告', business_type=BusinessType.DELETE) +async def delete_system_notice( + request: Request, + notice_ids: Annotated[str, Path(description='需要删除的公告ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_notice = DeleteNoticeModel(noticeIds=notice_ids) + delete_notice_result = await NoticeService.delete_notice_services(query_db, delete_notice) + logger.info(delete_notice_result.message) + + return ResponseUtil.success(msg=delete_notice_result.message) + + +@notice_controller.get( + '/{notice_id}', + summary='获取通知公告详情接口', + description='用于获取指定通知公告的详细信息', + response_model=DataResponseModel[NoticeModel], + dependencies=[UserInterfaceAuthDependency('system:notice:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_NOTICE_DETAIL) +async def query_detail_system_post( + request: Request, + notice_id: Annotated[int, Path(description='公告ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + notice_detail_result = await NoticeService.notice_detail_services(query_db, notice_id) + logger.info(f'获取notice_id为{notice_id}的信息成功') + + return ResponseUtil.success(data=notice_detail_result) diff --git a/shierfeng-fastapi-backend/module_admin/controller/online_controller.py b/shierfeng-fastapi-backend/module_admin/controller/online_controller.py new file mode 100644 index 0000000..ff152bb --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/online_controller.py @@ -0,0 +1,63 @@ +from typing import Annotated + +from fastapi import Path, Query, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import ResponseBaseModel +from module_admin.entity.vo.online_vo import DeleteOnlineModel, OnlinePageResponseModel, OnlineQueryModel +from module_admin.service.online_service import OnlineService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +online_controller = APIRouterPro( + prefix='/monitor/online', order_num=12, tags=['系统监控-在线用户'], dependencies=[PreAuthDependency()] +) + + +@online_controller.get( + '/list', + summary='获取在线用户分页列表接口', + description='用于获取在线用户分页列表', + response_model=OnlinePageResponseModel, + dependencies=[UserInterfaceAuthDependency('monitor:online:list')], +) +async def get_monitor_online_list( + request: Request, + online_page_query: Annotated[OnlineQueryModel, Query()], +) -> Response: + # 获取全量数据 + online_query_result = await OnlineService.get_online_list_services(request, online_page_query) + logger.info('获取成功') + + return ResponseUtil.success( + model_content=OnlinePageResponseModel(rows=online_query_result, total=len(online_query_result)) + ) + + +@online_controller.delete( + '/{token_ids}', + summary='强退在线用户接口', + description='用于强退指定会话编号的在线用户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('monitor:online:forceLogout')], +) +@ApiRateLimit(namespace=ApiNamespace.MONITOR_ONLINE_FORCE_LOGOUT, preset=ApiRateLimitPreset.USER_SECURITY_MUTATION) +@Log(title='在线用户', business_type=BusinessType.FORCE) +async def delete_monitor_online( + request: Request, + token_ids: Annotated[str, Path(description='需要强退的会话编号')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_online = DeleteOnlineModel(tokenIds=token_ids) + delete_online_result = await OnlineService.delete_online_services(request, delete_online) + logger.info(delete_online_result.message) + + return ResponseUtil.success(msg=delete_online_result.message) diff --git a/shierfeng-fastapi-backend/module_admin/controller/post_controller.py b/shierfeng-fastapi-backend/module_admin/controller/post_controller.py new file mode 100644 index 0000000..ff84f96 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/post_controller.py @@ -0,0 +1,168 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.post_vo import DeletePostModel, PostModel, PostPageQueryModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.post_service import PostService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +post_controller = APIRouterPro( + prefix='/system/post', order_num=7, tags=['系统管理-岗位管理'], dependencies=[PreAuthDependency()] +) + + +@post_controller.get( + '/list', + summary='获取岗位分页列表接口', + description='用于获取岗位分页列表', + response_model=PageResponseModel[PostModel], + dependencies=[UserInterfaceAuthDependency('system:post:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_POST_LIST) +async def get_system_post_list( + request: Request, + post_page_query: Annotated[PostPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + post_page_query_result = await PostService.get_post_list_services(query_db, post_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content=post_page_query_result) + + +@post_controller.post( + '', + summary='新增岗位接口', + description='用于新增岗位', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:post:add')], +) +@ValidateFields(validate_model='add_post') +@ApiCacheEvict(namespaces=ApiGroup.POST_MUTATION) +@Log(title='岗位管理', business_type=BusinessType.INSERT) +async def add_system_post( + request: Request, + add_post: PostModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_post.create_by = current_user.user.user_name + add_post.create_time = datetime.now() + add_post.update_by = current_user.user.user_name + add_post.update_time = datetime.now() + add_post_result = await PostService.add_post_services(query_db, add_post) + logger.info(add_post_result.message) + + return ResponseUtil.success(msg=add_post_result.message) + + +@post_controller.put( + '', + summary='编辑岗位接口', + description='用于编辑岗位', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:post:edit')], +) +@ValidateFields(validate_model='edit_post') +@ApiCacheEvict(namespaces=ApiGroup.POST_MUTATION) +@Log(title='岗位管理', business_type=BusinessType.UPDATE) +async def edit_system_post( + request: Request, + edit_post: PostModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_post.update_by = current_user.user.user_name + edit_post.update_time = datetime.now() + edit_post_result = await PostService.edit_post_services(query_db, edit_post) + logger.info(edit_post_result.message) + + return ResponseUtil.success(msg=edit_post_result.message) + + +@post_controller.delete( + '/{post_ids}', + summary='删除岗位接口', + description='用于删除岗位', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:post:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.POST_MUTATION) +@Log(title='岗位管理', business_type=BusinessType.DELETE) +async def delete_system_post( + request: Request, + post_ids: Annotated[str, Path(description='需要删除的岗位ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_post = DeletePostModel(postIds=post_ids) + delete_post_result = await PostService.delete_post_services(query_db, delete_post) + logger.info(delete_post_result.message) + + return ResponseUtil.success(msg=delete_post_result.message) + + +@post_controller.get( + '/{post_id}', + summary='获取岗位详情接口', + description='用于获取指定岗位的详细信息', + response_model=DataResponseModel[PostModel], + dependencies=[UserInterfaceAuthDependency('system:post:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_POST_DETAIL) +async def query_detail_system_post( + request: Request, + post_id: Annotated[int, Path(description='岗位ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + post_detail_result = await PostService.post_detail_services(query_db, post_id) + logger.info(f'获取post_id为{post_id}的信息成功') + + return ResponseUtil.success(data=post_detail_result) + + +@post_controller.post( + '/export', + summary='导出岗位列表接口', + description='用于导出当前符合查询条件的岗位列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回岗位列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:post:export')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_POST_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='岗位管理', business_type=BusinessType.EXPORT) +async def export_system_post_list( + request: Request, + post_page_query: Annotated[PostPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + post_query_result = await PostService.get_post_list_services(query_db, post_page_query, is_page=False) + post_export_result = await PostService.export_post_list_services(post_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(post_export_result)) diff --git a/shierfeng-fastapi-backend/module_admin/controller/role_controller.py b/shierfeng-fastapi-backend/module_admin/controller/role_controller.py new file mode 100644 index 0000000..c86dc00 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/role_controller.py @@ -0,0 +1,400 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.data_scope import DataScopeDependency +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, DynamicResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.user_do import SysUser +from module_admin.entity.vo.dept_vo import DeptModel +from module_admin.entity.vo.role_vo import ( + AddRoleModel, + DeleteRoleModel, + RoleDeptQueryModel, + RoleModel, + RolePageQueryModel, +) +from module_admin.entity.vo.user_vo import CrudUserRoleModel, CurrentUserModel, UserInfoModel, UserRolePageQueryModel +from module_admin.service.dept_service import DeptService +from module_admin.service.role_service import RoleService +from module_admin.service.user_service import UserService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +role_controller = APIRouterPro( + prefix='/system/role', order_num=4, tags=['系统管理-角色管理'], dependencies=[PreAuthDependency()] +) + + +@role_controller.get( + '/deptTree/{role_id}', + summary='获取自定义数据权限时可见的部门树接口', + description='用于自定义数据权限时获取当前用户可见的部门树', + response_model=DynamicResponseModel[RoleDeptQueryModel], + dependencies=[UserInterfaceAuthDependency('system:role:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_ROLE_DEPT_TREE) +async def get_system_role_dept_tree( + request: Request, + role_id: Annotated[int, Path(description='角色ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + dept_query_result = await DeptService.get_dept_tree_services(query_db, DeptModel(), data_scope_sql) + role_dept_query_result = await RoleService.get_role_dept_tree_services(query_db, role_id) + role_dept_query_result.depts = dept_query_result + logger.info('获取成功') + + return ResponseUtil.success(model_content=role_dept_query_result) + + +@role_controller.get( + '/list', + summary='获取角色分页列表接口', + description='用于获取角色分页列表', + response_model=PageResponseModel[RoleModel], + dependencies=[UserInterfaceAuthDependency('system:role:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_ROLE_LIST) +async def get_system_role_list( + request: Request, + role_page_query: Annotated[RolePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + role_page_query_result = await RoleService.get_role_list_services( + query_db, role_page_query, data_scope_sql, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=role_page_query_result) + + +@role_controller.post( + '', + summary='新增角色接口', + description='用于新增角色', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:add')], +) +@ValidateFields(validate_model='add_role') +@ApiCacheEvict(namespaces=ApiGroup.ROLE_ENTITY_MUTATION) +@Log(title='角色管理', business_type=BusinessType.INSERT) +async def add_system_role( + request: Request, + add_role: AddRoleModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_role.create_by = current_user.user.user_name + add_role.create_time = datetime.now() + add_role.update_by = current_user.user.user_name + add_role.update_time = datetime.now() + add_role_result = await RoleService.add_role_services(query_db, add_role) + logger.info(add_role_result.message) + + return ResponseUtil.success(msg=add_role_result.message) + + +@role_controller.put( + '', + summary='编辑角色接口', + description='用于编辑角色', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:edit')], +) +@ValidateFields(validate_model='edit_role') +@ApiCacheEvict(namespaces=ApiGroup.ROLE_PERMISSION_MUTATION) +@Log(title='角色管理', business_type=BusinessType.UPDATE) +async def edit_system_role( + request: Request, + edit_role: AddRoleModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + await RoleService.check_role_allowed_services(edit_role) + if not current_user.user.admin: + await RoleService.check_role_data_scope_services(query_db, str(edit_role.role_id), data_scope_sql) + edit_role.update_by = current_user.user.user_name + edit_role.update_time = datetime.now() + edit_role_result = await RoleService.edit_role_services(query_db, edit_role) + logger.info(edit_role_result.message) + + return ResponseUtil.success(msg=edit_role_result.message) + + +@role_controller.put( + '/dataScope', + summary='编辑角色数据权限接口', + description='用于编辑角色数据权限', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:edit')], +) +@ApiCacheEvict(namespaces=ApiGroup.DATA_SCOPE_MUTATION) +@Log(title='角色管理', business_type=BusinessType.GRANT) +async def edit_system_role_datascope( + request: Request, + role_data_scope: AddRoleModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + await RoleService.check_role_allowed_services(role_data_scope) + if not current_user.user.admin: + await RoleService.check_role_data_scope_services(query_db, str(role_data_scope.role_id), data_scope_sql) + edit_role = AddRoleModel( + roleId=role_data_scope.role_id, + dataScope=role_data_scope.data_scope, + deptIds=role_data_scope.dept_ids, + deptCheckStrictly=role_data_scope.dept_check_strictly, + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + ) + role_data_scope_result = await RoleService.role_datascope_services(query_db, edit_role) + logger.info(role_data_scope_result.message) + + return ResponseUtil.success(msg=role_data_scope_result.message) + + +@role_controller.delete( + '/{role_ids}', + summary='删除角色接口', + description='用于删除角色', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.ROLE_ENTITY_MUTATION) +@Log(title='角色管理', business_type=BusinessType.DELETE) +async def delete_system_role( + request: Request, + role_ids: Annotated[str, Path(description='需要删除的角色ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + role_id_list = role_ids.split(',') if role_ids else [] + if role_id_list: + for role_id in role_id_list: + await RoleService.check_role_allowed_services(RoleModel(roleId=int(role_id))) + if not current_user.user.admin: + await RoleService.check_role_data_scope_services(query_db, role_id, data_scope_sql) + delete_role = DeleteRoleModel(roleIds=role_ids, updateBy=current_user.user.user_name, updateTime=datetime.now()) + delete_role_result = await RoleService.delete_role_services(query_db, delete_role) + logger.info(delete_role_result.message) + + return ResponseUtil.success(msg=delete_role_result.message) + + +@role_controller.get( + '/{role_id}', + summary='获取角色详情接口', + description='用于获取指定角色的详细信息', + response_model=DataResponseModel[RoleModel], + dependencies=[UserInterfaceAuthDependency('system:role:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_ROLE_DETAIL) +async def query_detail_system_role( + request: Request, + role_id: Annotated[int, Path(description='角色ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + if not current_user.user.admin: + await RoleService.check_role_data_scope_services(query_db, str(role_id), data_scope_sql) + role_detail_result = await RoleService.role_detail_services(query_db, role_id) + logger.info(f'获取role_id为{role_id}的信息成功') + + return ResponseUtil.success(data=role_detail_result.model_dump(by_alias=True)) + + +@role_controller.post( + '/export', + summary='导出角色列表接口', + description='用于导出当前符合查询条件的角色列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回角色列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:role:export')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_ROLE_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='角色管理', business_type=BusinessType.EXPORT) +async def export_system_role_list( + request: Request, + role_page_query: Annotated[RolePageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + # 获取全量数据 + role_query_result = await RoleService.get_role_list_services( + query_db, role_page_query, data_scope_sql, is_page=False + ) + role_export_result = await RoleService.export_role_list_services(role_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(role_export_result)) + + +@role_controller.put( + '/changeStatus', + summary='修改角色状态接口', + description='用于修改角色状态', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:edit')], +) +@ApiCacheEvict(namespaces=ApiGroup.ROLE_MUTATION) +@Log(title='角色管理', business_type=BusinessType.UPDATE) +async def reset_system_role_status( + request: Request, + change_role: AddRoleModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + await RoleService.check_role_allowed_services(change_role) + if not current_user.user.admin: + await RoleService.check_role_data_scope_services(query_db, str(change_role.role_id), data_scope_sql) + edit_role = AddRoleModel( + roleId=change_role.role_id, + status=change_role.status, + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + type='status', + ) + edit_role_result = await RoleService.edit_role_services(query_db, edit_role) + logger.info(edit_role_result.message) + + return ResponseUtil.success(msg=edit_role_result.message) + + +@role_controller.get( + '/authUser/allocatedList', + summary='获取已分配用户分页列表接口', + description='用于获取指定角色已分配的用户分页列表', + response_model=PageResponseModel[UserInfoModel], + dependencies=[UserInterfaceAuthDependency('system:role:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_ROLE_ALLOCATED_USER_LIST) +async def get_system_allocated_user_list( + request: Request, + user_role: Annotated[UserRolePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + role_user_allocated_page_query_result = await RoleService.get_role_user_allocated_list_services( + query_db, user_role, data_scope_sql, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=role_user_allocated_page_query_result) + + +@role_controller.get( + '/authUser/unallocatedList', + summary='获取未分配用户分页列表接口', + description='用于获取指定角色未分配的用户分页列表', + response_model=PageResponseModel[UserInfoModel], + dependencies=[UserInterfaceAuthDependency('system:role:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_ROLE_UNALLOCATED_USER_LIST) +async def get_system_unallocated_user_list( + request: Request, + user_role: Annotated[UserRolePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + role_user_unallocated_page_query_result = await RoleService.get_role_user_unallocated_list_services( + query_db, user_role, data_scope_sql, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=role_user_unallocated_page_query_result) + + +@role_controller.put( + '/authUser/selectAll', + summary='分配用户给角色接口', + description='用于给指定角色分配用户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:edit')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_ROLE_AUTH_USER_SELECT_ALL, preset=ApiRateLimitPreset.USER_SECURITY_MUTATION) +@ApiCacheEvict(namespaces=ApiGroup.ROLE_MUTATION) +@Log(title='角色管理', business_type=BusinessType.GRANT) +async def add_system_role_user( + request: Request, + add_role_user: Annotated[CrudUserRoleModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + if not current_user.user.admin: + await RoleService.check_role_data_scope_services(query_db, str(add_role_user.role_id), data_scope_sql) + add_role_user_result = await UserService.add_user_role_services(query_db, add_role_user) + logger.info(add_role_user_result.message) + + return ResponseUtil.success(msg=add_role_user_result.message) + + +@role_controller.put( + '/authUser/cancel', + summary='取消分配用户给角色接口', + description='用于取消指定用户分配给角色', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:edit')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_ROLE_AUTH_USER_CANCEL, preset=ApiRateLimitPreset.USER_SECURITY_MUTATION) +@ApiCacheEvict(namespaces=ApiGroup.ROLE_MUTATION) +@Log(title='角色管理', business_type=BusinessType.GRANT) +async def cancel_system_role_user( + request: Request, + cancel_user_role: CrudUserRoleModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + cancel_user_role_result = await UserService.delete_user_role_services(query_db, cancel_user_role) + logger.info(cancel_user_role_result.message) + + return ResponseUtil.success(msg=cancel_user_role_result.message) + + +@role_controller.put( + '/authUser/cancelAll', + summary='批量取消分配用户给角色接口', + description='用于批量取消用户分配给角色', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:role:edit')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_ROLE_AUTH_USER_CANCEL_ALL, preset=ApiRateLimitPreset.USER_SECURITY_MUTATION) +@ApiCacheEvict(namespaces=ApiGroup.ROLE_MUTATION) +@Log(title='角色管理', business_type=BusinessType.GRANT) +async def batch_cancel_system_role_user( + request: Request, + batch_cancel_user_role: Annotated[CrudUserRoleModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + batch_cancel_user_role_result = await UserService.delete_user_role_services(query_db, batch_cancel_user_role) + logger.info(batch_cancel_user_role_result.message) + + return ResponseUtil.success(msg=batch_cancel_user_role_result.message) diff --git a/shierfeng-fastapi-backend/module_admin/controller/server_controller.py b/shierfeng-fastapi-backend/module_admin/controller/server_controller.py new file mode 100644 index 0000000..a68f218 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/server_controller.py @@ -0,0 +1,32 @@ +from fastapi import Request, Response + +from common.annotation.cache_annotation import ApiCache +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.router import APIRouterPro +from common.vo import DataResponseModel +from module_admin.entity.vo.server_vo import ServerMonitorModel +from module_admin.service.server_service import ServerService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +server_controller = APIRouterPro( + prefix='/monitor/server', order_num=14, tags=['系统监控-服务监控'], dependencies=[PreAuthDependency()] +) + + +@server_controller.get( + '', + summary='获取服务器监控信息接口', + description='用于获取当前服务器的监控信息', + response_model=DataResponseModel[ServerMonitorModel], + dependencies=[UserInterfaceAuthDependency('monitor:server:list')], +) +@ApiCache(namespace=ApiNamespace.MONITOR_SERVER_INFO) +async def get_monitor_server_info(request: Request) -> Response: + # 获取全量数据 + server_info_query_result = await ServerService.get_server_monitor_info() + logger.info('获取成功') + + return ResponseUtil.success(data=server_info_query_result) diff --git a/shierfeng-fastapi-backend/module_admin/controller/transport_crypto_controller.py b/shierfeng-fastapi-backend/module_admin/controller/transport_crypto_controller.py new file mode 100644 index 0000000..781d21c --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/transport_crypto_controller.py @@ -0,0 +1,78 @@ +from fastapi import Request, Response + +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.router import APIRouterPro +from common.vo import DataResponseModel +from module_admin.entity.vo.transport_crypto_vo import ( + TransportCryptoFrontendConfigModel, + TransportCryptoMonitorModel, + TransportCryptoPublicKeyModel, +) +from module_admin.service.transport_crypto_service import TransportCryptoService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +transport_crypto_controller = APIRouterPro(prefix='/transport/crypto', order_num=15, tags=['传输加密模块']) + + +@transport_crypto_controller.get( + '/frontend-config', + summary='获取前端传输加密配置接口', + description='公开接口,用于向前端下发当前传输层加解密启用状态与运行模式,供前端统一跟随后端策略', + response_model=DataResponseModel[TransportCryptoFrontendConfigModel], +) +@ApiRateLimit(namespace=ApiNamespace.TRANSPORT_CRYPTO_FRONTEND_CONFIG, preset=ApiRateLimitPreset.ANON_PUBLIC_METADATA) +async def get_transport_frontend_config(request: Request) -> Response: + """ + 获取当前前端传输层加解密运行配置 + + :param request: 当前请求对象 + :return: 前端传输层加解密运行配置响应 + """ + transport_frontend_config = await TransportCryptoService.get_transport_frontend_config_services() + logger.info('获取成功') + + return ResponseUtil.success(data=transport_frontend_config) + + +@transport_crypto_controller.get( + '/public-key', + summary='获取传输加密公钥接口', + description='公开接口,用于向前端下发当前可用的传输层加密公钥,已配置匿名限流保护', + response_model=DataResponseModel[TransportCryptoPublicKeyModel], +) +@ApiRateLimit(namespace=ApiNamespace.TRANSPORT_CRYPTO_PUBLIC_KEY, preset=ApiRateLimitPreset.ANON_PUBLIC_METADATA) +async def get_transport_public_key(request: Request) -> Response: + """ + 获取当前传输层加密公钥 + + :param request: 当前请求对象 + :return: 公钥下发响应 + """ + transport_public_key = await TransportCryptoService.get_transport_public_key_services() + logger.info('获取成功') + + return ResponseUtil.success(data=transport_public_key) + + +@transport_crypto_controller.get( + '/monitor', + summary='获取传输层加解密监控信息接口', + description='用于获取基于Redis聚合的传输层加解密运行状态与统计信息', + response_model=DataResponseModel[TransportCryptoMonitorModel], + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('monitor:transportCrypto:list')], +) +async def get_transport_crypto_monitor_info(request: Request) -> Response: + """ + 获取基于Redis聚合的传输层加解密监控信息 + + :param request: 当前请求对象 + :return: 传输层加解密监控信息响应 + """ + transport_crypto_monitor_info = await TransportCryptoService.get_transport_crypto_monitor_info_services(request) + logger.info('获取成功') + + return ResponseUtil.success(data=transport_crypto_monitor_info) diff --git a/shierfeng-fastapi-backend/module_admin/controller/user_controller.py b/shierfeng-fastapi-backend/module_admin/controller/user_controller.py new file mode 100644 index 0000000..7ea969c --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/controller/user_controller.py @@ -0,0 +1,550 @@ +import os +from datetime import datetime +from typing import Annotated, Literal + +import aiofiles +from fastapi import File, Form, Path, Query, Request, Response, UploadFile +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitBypassConfig, ApiRateLimitPreset +from common.aspect.data_scope import DataScopeDependency +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, DynamicResponseModel, PageResponseModel, ResponseBaseModel +from config.env import UploadConfig +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.user_do import SysUser +from module_admin.entity.vo.dept_vo import DeptModel, DeptTreeModel +from module_admin.entity.vo.user_vo import ( + AddUserModel, + AvatarModel, + CrudUserRoleModel, + CurrentUserModel, + DeleteUserModel, + EditUserModel, + ResetPasswordModel, + ResetUserModel, + UserDetailModel, + UserInfoModel, + UserModel, + UserPageQueryModel, + UserProfileModel, + UserRoleQueryModel, + UserRoleResponseModel, + UserRowModel, +) +from module_admin.service.dept_service import DeptService +from module_admin.service.role_service import RoleService +from module_admin.service.user_service import UserService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.pwd_util import PwdUtil +from utils.response_util import ResponseUtil +from utils.upload_util import UploadUtil + +user_controller = APIRouterPro( + prefix='/system/user', order_num=3, tags=['系统管理-用户管理'], dependencies=[PreAuthDependency()] +) + + +@user_controller.get( + '/deptTree', + summary='获取部门树接口', + description='用于获取当前登录用户可见的部门树', + response_model=DataResponseModel[list[DeptTreeModel]], + dependencies=[UserInterfaceAuthDependency('system:user:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_USER_DEPT_TREE) +async def get_system_dept_tree( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + dept_query_result = await DeptService.get_dept_tree_services(query_db, DeptModel(), data_scope_sql) + logger.info('获取成功') + + return ResponseUtil.success(data=dept_query_result) + + +@user_controller.get( + '/list', + summary='获取用户分页列表接口', + description='用于获取用户分页列表', + response_model=PageResponseModel[UserRowModel], + dependencies=[UserInterfaceAuthDependency('system:user:list')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_USER_LIST) +async def get_system_user_list( + request: Request, + user_page_query: Annotated[UserPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + # 获取分页数据 + user_page_query_result = await UserService.get_user_list_services( + query_db, user_page_query, data_scope_sql, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=user_page_query_result) + + +@user_controller.post( + '', + summary='新增用户接口', + description='用于新增用户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:add')], +) +@ValidateFields(validate_model='add_user') +@ApiCacheEvict(namespaces=ApiGroup.USER_ENTITY_MUTATION) +@Log(title='用户管理', business_type=BusinessType.INSERT) +async def add_system_user( + request: Request, + add_user: AddUserModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], + role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services(query_db, add_user.dept_id, dept_data_scope_sql) + await RoleService.check_role_data_scope_services( + query_db, ','.join([str(item) for item in add_user.role_ids]), role_data_scope_sql + ) + add_user.password = PwdUtil.get_password_hash(add_user.password) + add_user.create_by = current_user.user.user_name + add_user.create_time = datetime.now() + add_user.update_by = current_user.user.user_name + add_user.update_time = datetime.now() + add_user_result = await UserService.add_user_services(query_db, add_user) + logger.info(add_user_result.message) + + return ResponseUtil.success(msg=add_user_result.message) + + +@user_controller.put( + '', + summary='编辑用户接口', + description='用于编辑用户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:edit')], +) +@ValidateFields(validate_model='edit_user') +@ApiCacheEvict(namespaces=ApiGroup.USER_PERMISSION_MUTATION) +@Log(title='用户管理', business_type=BusinessType.UPDATE) +async def edit_system_user( + request: Request, + edit_user: EditUserModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + user_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], + dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], + role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + await UserService.check_user_allowed_services(edit_user) + if not current_user.user.admin: + await UserService.check_user_data_scope_services(query_db, edit_user.user_id, user_data_scope_sql) + await DeptService.check_dept_data_scope_services(query_db, edit_user.dept_id, dept_data_scope_sql) + await RoleService.check_role_data_scope_services( + query_db, ','.join([str(item) for item in edit_user.role_ids]), role_data_scope_sql + ) + edit_user.update_by = current_user.user.user_name + edit_user.update_time = datetime.now() + edit_user_result = await UserService.edit_user_services(query_db, edit_user) + logger.info(edit_user_result.message) + + return ResponseUtil.success(msg=edit_user_result.message) + + +@user_controller.delete( + '/{user_ids}', + summary='删除用户接口', + description='用于删除用户', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.USER_ENTITY_MUTATION) +@Log(title='用户管理', business_type=BusinessType.DELETE) +async def delete_system_user( + request: Request, + user_ids: Annotated[str, Path(description='需要删除的用户ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + user_id_list = user_ids.split(',') if user_ids else [] + if user_id_list: + if current_user.user.user_id in list(map(int, user_id_list)): + logger.warning('当前登录用户不能删除') + + return ResponseUtil.failure(msg='当前登录用户不能删除') + for user_id in user_id_list: + await UserService.check_user_allowed_services(UserModel(userId=int(user_id))) + if not current_user.user.admin: + await UserService.check_user_data_scope_services(query_db, int(user_id), data_scope_sql) + delete_user = DeleteUserModel(userIds=user_ids, updateBy=current_user.user.user_name, updateTime=datetime.now()) + delete_user_result = await UserService.delete_user_services(query_db, delete_user) + logger.info(delete_user_result.message) + + return ResponseUtil.success(msg=delete_user_result.message) + + +@user_controller.put( + '/resetPwd', + summary='重置用户密码接口', + description='用于重置用户密码', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:resetPwd')], +) +@ApiCacheEvict(namespaces=ApiGroup.USER_INFO_MUTATION) +@Log(title='用户管理', business_type=BusinessType.UPDATE) +async def reset_system_user_pwd( + request: Request, + reset_user: EditUserModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + await UserService.check_user_allowed_services(reset_user) + if not current_user.user.admin: + await UserService.check_user_data_scope_services(query_db, reset_user.user_id, data_scope_sql) + edit_user = EditUserModel( + userId=reset_user.user_id, + password=PwdUtil.get_password_hash(reset_user.password), + pwdUpdateDate=datetime.now(), + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + type='pwd', + ) + edit_user_result = await UserService.edit_user_services(query_db, edit_user) + logger.info(edit_user_result.message) + + return ResponseUtil.success(msg=edit_user_result.message) + + +@user_controller.put( + '/changeStatus', + summary='修改用户状态接口', + description='用于修改用户状态', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:edit')], +) +@ApiCacheEvict(namespaces=ApiGroup.USER_INFO_MUTATION) +@Log(title='用户管理', business_type=BusinessType.UPDATE) +async def change_system_user_status( + request: Request, + change_user: EditUserModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + await UserService.check_user_allowed_services(change_user) + if not current_user.user.admin: + await UserService.check_user_data_scope_services(query_db, change_user.user_id, data_scope_sql) + edit_user = EditUserModel( + userId=change_user.user_id, + status=change_user.status, + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + type='status', + ) + edit_user_result = await UserService.edit_user_services(query_db, edit_user) + logger.info(edit_user_result.message) + + return ResponseUtil.success(msg=edit_user_result.message) + + +@user_controller.get( + '/profile', + summary='获取用户个人信息接口', + description='用于获取当前登录用户的个人信息', + response_model=DynamicResponseModel[UserProfileModel], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_USER_PROFILE) +async def query_detail_system_user_profile( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + profile_user_result = await UserService.user_profile_services(query_db, current_user.user.user_id) + logger.info(f'获取user_id为{current_user.user.user_id}的信息成功') + + return ResponseUtil.success(model_content=profile_user_result) + + +@user_controller.get( + '/{user_id}', + summary='获取用户详情接口', + description='用于获取指定用户的详情信息', + response_model=DynamicResponseModel[UserDetailModel], + dependencies=[UserInterfaceAuthDependency('system:user:query')], +) +@user_controller.get( + '/', + summary='获取用户岗位和角色列表接口', + description='用于获取当前登录用户可见的岗位和角色列表', + response_model=DynamicResponseModel[UserDetailModel], + dependencies=[UserInterfaceAuthDependency('system:user:query')], +) +@ApiCache(namespace=ApiNamespace.SYSTEM_USER_DETAIL) +async def query_detail_system_user( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], + user_id: int | Literal[''] | None = '', +) -> Response: + if user_id and not current_user.user.admin: + await UserService.check_user_data_scope_services(query_db, user_id, data_scope_sql) + detail_user_result = await UserService.user_detail_services(query_db, user_id) + logger.info(f'获取user_id为{user_id}的信息成功') + + return ResponseUtil.success(model_content=detail_user_result) + + +@user_controller.post( + '/profile/avatar', + summary='修改用户头像接口', + description='用于修改当前登录用户的头像', + response_model=DynamicResponseModel[AvatarModel], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_USER_PROFILE_AVATAR, preset=ApiRateLimitPreset.USER_RESOURCE_UPLOAD) +@ApiCacheEvict(namespaces=ApiGroup.USER_INFO_MUTATION) +@Log(title='个人信息', business_type=BusinessType.UPDATE) +async def change_system_user_profile_avatar( + request: Request, + avatarfile: Annotated[bytes, File()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + if avatarfile: + relative_path = ( + f'avatar/{datetime.now().strftime("%Y")}/{datetime.now().strftime("%m")}/{datetime.now().strftime("%d")}' + ) + dir_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path) + try: + os.makedirs(dir_path) + except FileExistsError: + pass + avatar_name = f'avatar_{datetime.now().strftime("%Y%m%d%H%M%S")}{UploadConfig.UPLOAD_MACHINE}{UploadUtil.generate_random_number()}.png' + avatar_path = os.path.join(dir_path, avatar_name) + async with aiofiles.open(avatar_path, 'wb') as f: + await f.write(avatarfile) + edit_user = EditUserModel( + userId=current_user.user.user_id, + avatar=f'{UploadConfig.UPLOAD_PREFIX}/{relative_path}/{avatar_name}', + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + type='avatar', + ) + edit_user_result = await UserService.edit_user_services(query_db, edit_user) + logger.info(edit_user_result.message) + + return ResponseUtil.success(model_content=AvatarModel(imgUrl=edit_user.avatar), msg=edit_user_result.message) + return ResponseUtil.failure(msg='上传图片异常,请联系管理员') + + +@user_controller.put( + '/profile', + summary='修改用户个人信息接口', + description='用于修改当前登录用户的个人信息', + response_model=ResponseBaseModel, +) +@ApiCacheEvict(namespaces=ApiGroup.USER_INFO_MUTATION) +@Log(title='个人信息', business_type=BusinessType.UPDATE) +async def change_system_user_profile_info( + request: Request, + user_info: UserInfoModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_user = EditUserModel( + **user_info.model_dump(exclude_unset=True, by_alias=True, exclude={'role_ids', 'post_ids'}), + userId=current_user.user.user_id, + userName=current_user.user.user_name, + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + roleIds=current_user.user.role_ids.split(',') if current_user.user.role_ids else [], + postIds=current_user.user.post_ids.split(',') if current_user.user.post_ids else [], + role=current_user.user.role, + ) + edit_user_result = await UserService.edit_user_services(query_db, edit_user) + logger.info(edit_user_result.message) + + return ResponseUtil.success(msg=edit_user_result.message) + + +@user_controller.put( + '/profile/updatePwd', + summary='修改用户密码接口', + description='用于修改当前登录用户的密码', + response_model=ResponseBaseModel, +) +@ApiCacheEvict(namespaces=ApiGroup.USER_INFO_MUTATION) +@Log(title='个人信息', business_type=BusinessType.UPDATE) +async def reset_system_user_password( + request: Request, + reset_password: ResetPasswordModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + reset_user = ResetUserModel( + userId=current_user.user.user_id, + oldPassword=reset_password.old_password, + password=reset_password.new_password, + pwdUpdateDate=datetime.now(), + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + ) + reset_user_result = await UserService.reset_user_services(query_db, reset_user) + logger.info(reset_user_result.message) + + return ResponseUtil.success(msg=reset_user_result.message) + + +@user_controller.post( + '/importData', + summary='批量导入用户接口', + description='用于批量导入用户数据', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:import')], +) +@ApiRateLimit( + namespace=ApiNamespace.SYSTEM_USER_IMPORT, + preset=ApiRateLimitPreset.USER_RESOURCE_IMPORT, + bypass=ApiRateLimitBypassConfig(roles=('admin',)), +) +@ApiCacheEvict(namespaces=ApiGroup.DATA_SCOPE_MUTATION) +@Log(title='用户管理', business_type=BusinessType.IMPORT) +async def batch_import_system_user( + request: Request, + file: Annotated[UploadFile, File(...)], + update_support: Annotated[bool, Query(alias='updateSupport')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + user_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], + dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + batch_import_result = await UserService.batch_import_user_services( + request, query_db, file, update_support, current_user, user_data_scope_sql, dept_data_scope_sql + ) + logger.info(batch_import_result.message) + + return ResponseUtil.success(msg=batch_import_result.message) + + +@user_controller.post( + '/importTemplate', + summary='获取用户导入模板接口', + description='用于获取用户导入模板excel文件', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回导入用户模板excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:user:import')], +) +async def export_system_user_template( + request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()] +) -> Response: + user_import_template_result = await UserService.get_user_import_template_services() + logger.info('获取成功') + + return ResponseUtil.streaming(data=bytes2file_response(user_import_template_result)) + + +@user_controller.post( + '/export', + summary='导出用户列表接口', + description='用于导出当前符合查询条件的用户列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回用户列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:user:export')], +) +@ApiRateLimit(namespace=ApiNamespace.SYSTEM_USER_EXPORT, preset=ApiRateLimitPreset.USER_RESOURCE_EXPORT) +@Log(title='用户管理', business_type=BusinessType.EXPORT) +async def export_system_user_list( + request: Request, + user_page_query: Annotated[UserPageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], +) -> Response: + # 获取全量数据 + user_query_result = await UserService.get_user_list_services( + query_db, user_page_query, data_scope_sql, is_page=False + ) + user_export_result = await UserService.export_user_list_services(user_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response(user_export_result)) + + +@user_controller.get( + '/authRole/{user_id}', + summary='获取用户已分配角色列表接口', + description='用于获取指定用户已分配的角色列表', + response_model=DynamicResponseModel[UserRoleResponseModel], + dependencies=[UserInterfaceAuthDependency('system:user:query')], +) +async def get_system_allocated_role_list( + request: Request, + user_id: Annotated[int, Path(description='用户ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + user_role_query = UserRoleQueryModel(userId=user_id) + user_role_allocated_query_result = await UserService.get_user_role_allocated_list_services( + query_db, user_role_query + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=user_role_allocated_query_result) + + +@user_controller.put( + '/authRole', + summary='给用户分配角色接口', + description='用于给指定用户分配角色', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('system:user:edit')], +) +@ApiCacheEvict(namespaces=ApiGroup.USER_PERMISSION_MUTATION) +@Log(title='用户管理', business_type=BusinessType.GRANT) +async def update_system_role_user( + request: Request, + user_id: Annotated[int, Query(alias='userId')], + role_ids: Annotated[str, Query(alias='roleIds')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + user_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)], + role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], +) -> Response: + if not current_user.user.admin: + await UserService.check_user_data_scope_services(query_db, user_id, user_data_scope_sql) + await RoleService.check_role_data_scope_services(query_db, role_ids, role_data_scope_sql) + add_user_role_result = await UserService.add_user_role_services( + query_db, CrudUserRoleModel(userId=user_id, roleIds=role_ids) + ) + logger.info(add_user_role_result.message) + + return ResponseUtil.success(msg=add_user_role_result.message) diff --git a/shierfeng-fastapi-backend/module_admin/dao/config_dao.py b/shierfeng-fastapi-backend/module_admin/dao/config_dao.py new file mode 100644 index 0000000..d130d58 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/config_dao.py @@ -0,0 +1,124 @@ +from datetime import datetime, time +from typing import Any + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.config_do import SysConfig +from module_admin.entity.vo.config_vo import ConfigModel, ConfigPageQueryModel +from utils.page_util import PageUtil + + +class ConfigDao: + """ + 参数配置管理模块数据库操作层 + """ + + @classmethod + async def get_config_detail_by_id(cls, db: AsyncSession, config_id: int) -> SysConfig | None: + """ + 根据参数配置id获取参数配置详细信息 + + :param db: orm对象 + :param config_id: 参数配置id + :return: 参数配置信息对象 + """ + config_info = (await db.execute(select(SysConfig).where(SysConfig.config_id == config_id))).scalars().first() + + return config_info + + @classmethod + async def get_config_detail_by_info(cls, db: AsyncSession, config: ConfigModel) -> SysConfig | None: + """ + 根据参数配置参数获取参数配置信息 + + :param db: orm对象 + :param config: 参数配置参数对象 + :return: 参数配置信息对象 + """ + config_info = ( + ( + await db.execute( + select(SysConfig).where( + SysConfig.config_key == config.config_key if config.config_key else True, + SysConfig.config_value == config.config_value if config.config_value else True, + ) + ) + ) + .scalars() + .first() + ) + + return config_info + + @classmethod + async def get_config_list( + cls, db: AsyncSession, query_object: ConfigPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取参数配置列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 参数配置列表信息对象 + """ + query = ( + select(SysConfig) + .where( + SysConfig.config_name.like(f'%{query_object.config_name}%') if query_object.config_name else True, + SysConfig.config_key.like(f'%{query_object.config_key}%') if query_object.config_key else True, + SysConfig.config_type == query_object.config_type if query_object.config_type else True, + SysConfig.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .order_by(SysConfig.config_id) + .distinct() + ) + config_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return config_list + + @classmethod + async def add_config_dao(cls, db: AsyncSession, config: ConfigModel) -> SysConfig: + """ + 新增参数配置数据库操作 + + :param db: orm对象 + :param config: 参数配置对象 + :return: + """ + db_config = SysConfig(**config.model_dump()) + db.add(db_config) + await db.flush() + + return db_config + + @classmethod + async def edit_config_dao(cls, db: AsyncSession, config: dict) -> None: + """ + 编辑参数配置数据库操作 + + :param db: orm对象 + :param config: 需要更新的参数配置字典 + :return: + """ + await db.execute(update(SysConfig), [config]) + + @classmethod + async def delete_config_dao(cls, db: AsyncSession, config: ConfigModel) -> None: + """ + 删除参数配置数据库操作 + + :param db: orm对象 + :param config: 参数配置对象 + :return: + """ + await db.execute(delete(SysConfig).where(SysConfig.config_id.in_([config.config_id]))) diff --git a/shierfeng-fastapi-backend/module_admin/dao/dept_dao.py b/shierfeng-fastapi-backend/module_admin/dao/dept_dao.py new file mode 100644 index 0000000..2325c39 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/dept_dao.py @@ -0,0 +1,315 @@ +from collections.abc import Sequence + +from sqlalchemy import ColumnElement, bindparam, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.util import immutabledict + +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.user_do import SysUser +from module_admin.entity.vo.dept_vo import DeptModel + + +class DeptDao: + """ + 部门管理模块数据库操作层 + """ + + @classmethod + async def get_dept_by_id(cls, db: AsyncSession, dept_id: int) -> SysDept | None: + """ + 根据部门id获取在用部门信息 + + :param db: orm对象 + :param dept_id: 部门id + :return: 在用部门信息对象 + """ + dept_info = (await db.execute(select(SysDept).where(SysDept.dept_id == dept_id))).scalars().first() + + return dept_info + + @classmethod + async def get_dept_detail_by_id(cls, db: AsyncSession, dept_id: int) -> SysDept | None: + """ + 根据部门id获取部门详细信息 + + :param db: orm对象 + :param dept_id: 部门id + :return: 部门信息对象 + """ + dept_info = ( + (await db.execute(select(SysDept).where(SysDept.dept_id == dept_id, SysDept.del_flag == '0'))) + .scalars() + .first() + ) + + return dept_info + + @classmethod + async def get_dept_detail_by_info(cls, db: AsyncSession, dept: DeptModel) -> SysDept | None: + """ + 根据部门参数获取部门信息 + + :param db: orm对象 + :param dept: 部门参数对象 + :return: 部门信息对象 + """ + dept_info = ( + ( + await db.execute( + select(SysDept).where( + SysDept.parent_id == dept.parent_id if dept.parent_id else True, + SysDept.dept_name == dept.dept_name if dept.dept_name else True, + SysDept.del_flag == '0', + ) + ) + ) + .scalars() + .first() + ) + + return dept_info + + @classmethod + async def get_dept_info_for_edit_option( + cls, db: AsyncSession, dept_info: DeptModel, data_scope_sql: ColumnElement + ) -> Sequence[SysDept]: + """ + 获取部门编辑对应的在用部门列表信息 + + :param db: orm对象 + :param dept_info: 部门对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 部门列表信息 + """ + dept_result = ( + ( + await db.execute( + select(SysDept) + .where( + SysDept.dept_id != dept_info.dept_id, + ~SysDept.dept_id.in_( + select(SysDept.dept_id).where(func.find_in_set(dept_info.dept_id, SysDept.ancestors)) + ), + SysDept.del_flag == '0', + SysDept.status == '0', + data_scope_sql, + ) + .order_by(SysDept.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + + return dept_result + + @classmethod + async def get_children_dept_dao(cls, db: AsyncSession, dept_id: int) -> Sequence[SysDept]: + """ + 根据部门id查询当前部门的子部门列表信息 + + :param db: orm对象 + :param dept_id: 部门id + :return: 子部门信息列表 + """ + dept_result = ( + (await db.execute(select(SysDept).where(func.find_in_set(dept_id, SysDept.ancestors)))).scalars().all() + ) + + return dept_result + + @classmethod + async def get_dept_list_for_tree( + cls, db: AsyncSession, dept_info: DeptModel, data_scope_sql: ColumnElement + ) -> Sequence[SysDept]: + """ + 获取所有在用部门列表信息 + + :param db: orm对象 + :param dept_info: 部门对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 在用部门列表信息 + """ + dept_result = ( + ( + await db.execute( + select(SysDept) + .where( + SysDept.status == '0', + SysDept.del_flag == '0', + SysDept.dept_name.like(f'%{dept_info.dept_name}%') if dept_info.dept_name else True, + data_scope_sql, + ) + .order_by(SysDept.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + + return dept_result + + @classmethod + async def get_dept_list( + cls, db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement + ) -> Sequence[SysDept]: + """ + 根据查询参数获取部门列表信息 + + :param db: orm对象 + :param page_object: 不分页查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 部门列表信息对象 + """ + dept_result = ( + ( + await db.execute( + select(SysDept) + .where( + SysDept.del_flag == '0', + SysDept.dept_id == page_object.dept_id if page_object.dept_id is not None else True, + SysDept.status == page_object.status if page_object.status else True, + SysDept.dept_name.like(f'%{page_object.dept_name}%') if page_object.dept_name else True, + data_scope_sql, + ) + .order_by(SysDept.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + + return dept_result + + @classmethod + async def add_dept_dao(cls, db: AsyncSession, dept: DeptModel) -> SysDept: + """ + 新增部门数据库操作 + + :param db: orm对象 + :param dept: 部门对象 + :return: 新增校验结果 + """ + db_dept = SysDept(**dept.model_dump()) + db.add(db_dept) + await db.flush() + + return db_dept + + @classmethod + async def edit_dept_dao(cls, db: AsyncSession, dept: dict) -> None: + """ + 编辑部门数据库操作 + + :param db: orm对象 + :param dept: 需要更新的部门字典 + :return: 编辑校验结果 + """ + await db.execute(update(SysDept), [dept]) + + @classmethod + async def update_dept_children_dao(cls, db: AsyncSession, update_dept: list) -> None: + """ + 更新子部门信息 + + :param db: orm对象 + :param update_dept: 需要更新的部门列表 + :return: + """ + await db.execute( + update(SysDept) + .where(SysDept.dept_id == bindparam('dept_id')) + .values( + { + 'dept_id': bindparam('dept_id'), + 'ancestors': bindparam('ancestors'), + } + ), + update_dept, + execution_options=immutabledict({'synchronize_session': None}), + ) + + @classmethod + async def update_dept_status_normal_dao(cls, db: AsyncSession, dept_id_list: list) -> None: + """ + 批量更新部门状态为正常 + + :param db: orm对象 + :param dept_id_list: 部门id列表 + :return: + """ + await db.execute(update(SysDept).where(SysDept.dept_id.in_(dept_id_list)).values(status='0')) + + @classmethod + async def delete_dept_dao(cls, db: AsyncSession, dept: DeptModel) -> None: + """ + 删除部门数据库操作 + + :param db: orm对象 + :param dept: 部门对象 + :return: + """ + await db.execute( + update(SysDept) + .where(SysDept.dept_id == dept.dept_id) + .values(del_flag='2', update_by=dept.update_by, update_time=dept.update_time) + ) + + @classmethod + async def count_normal_children_dept_dao(cls, db: AsyncSession, dept_id: int) -> int | None: + """ + 根据部门id查询查询所有子部门(正常状态)的数量 + + :param db: orm对象 + :param dept_id: 部门id + :return: 所有子部门(正常状态)的数量 + """ + normal_children_dept_count = ( + await db.execute( + select(func.count('*')) + .select_from(SysDept) + .where(SysDept.status == '0', SysDept.del_flag == '0', func.find_in_set(dept_id, SysDept.ancestors)) + ) + ).scalar() + + return normal_children_dept_count + + @classmethod + async def count_children_dept_dao(cls, db: AsyncSession, dept_id: int) -> int | None: + """ + 根据部门id查询查询所有子部门(所有状态)的数量 + + :param db: orm对象 + :param dept_id: 部门id + :return: 所有子部门(所有状态)的数量 + """ + children_dept_count = ( + await db.execute( + select(func.count('*')) + .select_from(SysDept) + .where(SysDept.del_flag == '0', SysDept.parent_id == dept_id) + .limit(1) + ) + ).scalar() + + return children_dept_count + + @classmethod + async def count_dept_user_dao(cls, db: AsyncSession, dept_id: int) -> int | None: + """ + 根据部门id查询查询部门下的用户数量 + + :param db: orm对象 + :param dept_id: 部门id + :return: 部门下的用户数量 + """ + dept_user_count = ( + await db.execute( + select(func.count('*')).select_from(SysUser).where(SysUser.dept_id == dept_id, SysUser.del_flag == '0') + ) + ).scalar() + + return dept_user_count diff --git a/shierfeng-fastapi-backend/module_admin/dao/dict_dao.py b/shierfeng-fastapi-backend/module_admin/dao/dict_dao.py new file mode 100644 index 0000000..e7b5530 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/dict_dao.py @@ -0,0 +1,294 @@ +from collections.abc import Sequence +from datetime import datetime, time +from typing import Any + +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.dict_do import SysDictData, SysDictType +from module_admin.entity.vo.dict_vo import DictDataModel, DictDataPageQueryModel, DictTypeModel, DictTypePageQueryModel +from utils.page_util import PageUtil +from utils.time_format_util import list_format_datetime + + +class DictTypeDao: + """ + 字典类型管理模块数据库操作层 + """ + + @classmethod + async def get_dict_type_detail_by_id(cls, db: AsyncSession, dict_id: int) -> SysDictType | None: + """ + 根据字典类型id获取字典类型详细信息 + + :param db: orm对象 + :param dict_id: 字典类型id + :return: 字典类型信息对象 + """ + dict_type_info = (await db.execute(select(SysDictType).where(SysDictType.dict_id == dict_id))).scalars().first() + + return dict_type_info + + @classmethod + async def get_dict_type_detail_by_info(cls, db: AsyncSession, dict_type: DictTypeModel) -> SysDictType | None: + """ + 根据字典类型参数获取字典类型信息 + + :param db: orm对象 + :param dict_type: 字典类型参数对象 + :return: 字典类型信息对象 + """ + dict_type_info = ( + ( + await db.execute( + select(SysDictType).where( + SysDictType.dict_type == dict_type.dict_type if dict_type.dict_type else True, + SysDictType.dict_name == dict_type.dict_name if dict_type.dict_name else True, + ) + ) + ) + .scalars() + .first() + ) + + return dict_type_info + + @classmethod + async def get_all_dict_type(cls, db: AsyncSession) -> list[Any]: + """ + 获取所有的字典类型信息 + + :param db: orm对象 + :return: 字典类型信息列表对象 + """ + dict_type_info = (await db.execute(select(SysDictType))).scalars().all() + + return list_format_datetime(dict_type_info) + + @classmethod + async def get_dict_type_list( + cls, db: AsyncSession, query_object: DictTypePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取字典类型列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 字典类型列表信息对象 + """ + query = ( + select(SysDictType) + .where( + SysDictType.dict_name.like(f'%{query_object.dict_name}%') if query_object.dict_name else True, + SysDictType.dict_type.like(f'%{query_object.dict_type}%') if query_object.dict_type else True, + SysDictType.status == query_object.status if query_object.status else True, + SysDictType.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .order_by(SysDictType.dict_id) + .distinct() + ) + dict_type_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return dict_type_list + + @classmethod + async def add_dict_type_dao(cls, db: AsyncSession, dict_type: DictTypeModel) -> SysDictType: + """ + 新增字典类型数据库操作 + + :param db: orm对象 + :param dict_type: 字典类型对象 + :return: + """ + db_dict_type = SysDictType(**dict_type.model_dump()) + db.add(db_dict_type) + await db.flush() + + return db_dict_type + + @classmethod + async def edit_dict_type_dao(cls, db: AsyncSession, dict_type: dict) -> None: + """ + 编辑字典类型数据库操作 + + :param db: orm对象 + :param dict_type: 需要更新的字典类型字典 + :return: + """ + await db.execute(update(SysDictType), [dict_type]) + + @classmethod + async def delete_dict_type_dao(cls, db: AsyncSession, dict_type: DictTypeModel) -> None: + """ + 删除字典类型数据库操作 + + :param db: orm对象 + :param dict_type: 字典类型对象 + :return: + """ + await db.execute(delete(SysDictType).where(SysDictType.dict_id.in_([dict_type.dict_id]))) + + +class DictDataDao: + """ + 字典数据管理模块数据库操作层 + """ + + @classmethod + async def get_dict_data_detail_by_id(cls, db: AsyncSession, dict_code: int) -> SysDictData | None: + """ + 根据字典数据id获取字典数据详细信息 + + :param db: orm对象 + :param dict_code: 字典数据id + :return: 字典数据信息对象 + """ + dict_data_info = ( + (await db.execute(select(SysDictData).where(SysDictData.dict_code == dict_code))).scalars().first() + ) + + return dict_data_info + + @classmethod + async def get_dict_data_detail_by_info(cls, db: AsyncSession, dict_data: DictDataModel) -> SysDictData | None: + """ + 根据字典数据参数获取字典数据信息 + + :param db: orm对象 + :param dict_data: 字典数据参数对象 + :return: 字典数据信息对象 + """ + dict_data_info = ( + ( + await db.execute( + select(SysDictData).where( + SysDictData.dict_type == dict_data.dict_type, + SysDictData.dict_label == dict_data.dict_label, + SysDictData.dict_value == dict_data.dict_value, + ) + ) + ) + .scalars() + .first() + ) + + return dict_data_info + + @classmethod + async def get_dict_data_list( + cls, db: AsyncSession, query_object: DictDataPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取字典数据列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 字典数据列表信息对象 + """ + query = ( + select(SysDictData) + .where( + SysDictData.dict_type == query_object.dict_type if query_object.dict_type else True, + SysDictData.dict_label.like(f'%{query_object.dict_label}%') if query_object.dict_label else True, + SysDictData.status == query_object.status if query_object.status else True, + ) + .order_by(SysDictData.dict_sort) + .distinct() + ) + dict_data_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return dict_data_list + + @classmethod + async def query_dict_data_list(cls, db: AsyncSession, dict_type: str) -> Sequence[SysDictData]: + """ + 根据查询参数获取字典数据列表信息 + + :param db: orm对象 + :param dict_type: 字典类型 + :return: 字典数据列表信息对象 + """ + dict_data_list = ( + ( + await db.execute( + select(SysDictData) + .select_from(SysDictType) + .where(SysDictType.dict_type == dict_type if dict_type else True, SysDictType.status == '0') + .join( + SysDictData, + and_(SysDictType.dict_type == SysDictData.dict_type, SysDictData.status == '0'), + isouter=True, + ) + .order_by(SysDictData.dict_sort) + .distinct() + ) + ) + .scalars() + .all() + ) + + return dict_data_list + + @classmethod + async def add_dict_data_dao(cls, db: AsyncSession, dict_data: DictDataModel) -> SysDictData: + """ + 新增字典数据数据库操作 + + :param db: orm对象 + :param dict_data: 字典数据对象 + :return: + """ + db_data_type = SysDictData(**dict_data.model_dump()) + db.add(db_data_type) + await db.flush() + + return db_data_type + + @classmethod + async def edit_dict_data_dao(cls, db: AsyncSession, dict_data: dict) -> None: + """ + 编辑字典数据数据库操作 + + :param db: orm对象 + :param dict_data: 需要更新的字典数据字典 + :return: + """ + await db.execute(update(SysDictData), [dict_data]) + + @classmethod + async def delete_dict_data_dao(cls, db: AsyncSession, dict_data: DictDataModel) -> None: + """ + 删除字典数据数据库操作 + + :param db: orm对象 + :param dict_data: 字典数据对象 + :return: + """ + await db.execute(delete(SysDictData).where(SysDictData.dict_code.in_([dict_data.dict_code]))) + + @classmethod + async def count_dict_data_dao(cls, db: AsyncSession, dict_type: str) -> int | None: + """ + 根据字典类型查询字典类型关联的字典数据数量 + + :param db: orm对象 + :param dict_type: 字典类型 + :return: 字典类型关联的字典数据数量 + """ + dict_data_count = ( + await db.execute(select(func.count('*')).select_from(SysDictData).where(SysDictData.dict_type == dict_type)) + ).scalar() + + return dict_data_count diff --git a/shierfeng-fastapi-backend/module_admin/dao/job_dao.py b/shierfeng-fastapi-backend/module_admin/dao/job_dao.py new file mode 100644 index 0000000..52df8e3 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/job_dao.py @@ -0,0 +1,156 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.job_do import SysJob +from module_admin.entity.vo.job_vo import JobModel, JobPageQueryModel +from utils.page_util import PageUtil + + +class JobDao: + """ + 定时任务管理模块数据库操作层 + """ + + @classmethod + async def get_job_detail_by_id(cls, db: AsyncSession, job_id: int) -> SysJob | None: + """ + 根据定时任务id获取定时任务详细信息 + + :param db: orm对象 + :param job_id: 定时任务id + :return: 定时任务信息对象 + """ + job_info = (await db.execute(select(SysJob).where(SysJob.job_id == job_id))).scalars().first() + + return job_info + + @classmethod + async def get_job_detail_by_info(cls, db: AsyncSession, job: JobModel) -> SysJob | None: + """ + 根据定时任务参数获取定时任务信息 + + :param db: orm对象 + :param job: 定时任务参数对象 + :return: 定时任务信息对象 + """ + job_info = ( + ( + await db.execute( + select(SysJob).where( + SysJob.job_name == job.job_name, + SysJob.job_group == job.job_group, + SysJob.job_executor == job.job_executor, + SysJob.invoke_target == job.invoke_target, + SysJob.job_args == job.job_args, + SysJob.job_kwargs == job.job_kwargs, + SysJob.cron_expression == job.cron_expression, + ) + ) + ) + .scalars() + .first() + ) + + return job_info + + @classmethod + async def get_job_list( + cls, db: AsyncSession, query_object: JobPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取定时任务列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 定时任务列表信息对象 + """ + query = ( + select(SysJob) + .where( + SysJob.job_name.like(f'%{query_object.job_name}%') if query_object.job_name else True, + SysJob.job_group == query_object.job_group if query_object.job_group else True, + SysJob.status == query_object.status if query_object.status else True, + ) + .order_by(SysJob.job_id) + .distinct() + ) + job_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return job_list + + @classmethod + async def get_job_list_for_scheduler(cls, db: AsyncSession) -> Sequence[SysJob]: + """ + 获取定时任务列表信息 + + :param db: orm对象 + :return: 定时任务列表信息对象 + """ + job_list = (await db.execute(select(SysJob).where(SysJob.status == '0').distinct())).scalars().all() + + return job_list + + @classmethod + async def get_all_job_list_for_scheduler(cls, db: AsyncSession) -> Sequence[SysJob]: + """ + 获取全部定时任务列表信息 + + :param db: orm对象 + :return: 定时任务列表信息对象 + """ + job_list = (await db.execute(select(SysJob).distinct())).scalars().all() + + return job_list + + @classmethod + async def add_job_dao(cls, db: AsyncSession, job: JobModel) -> SysJob: + """ + 新增定时任务数据库操作 + + :param db: orm对象 + :param job: 定时任务对象 + :return: + """ + db_job = SysJob(**job.model_dump()) + db.add(db_job) + await db.flush() + + return db_job + + @classmethod + async def edit_job_dao(cls, db: AsyncSession, job: dict, old_job: JobModel) -> None: + """ + 编辑定时任务数据库操作 + + :param db: orm对象 + :param job: 需要更新的定时任务字典 + :param old_job: 原定时任务对象 + :return: + """ + await db.execute( + update(SysJob) + .where( + SysJob.job_id == old_job.job_id, + SysJob.job_name == old_job.job_name, + SysJob.job_group == old_job.job_group, + ) + .values(**job) + ) + + @classmethod + async def delete_job_dao(cls, db: AsyncSession, job: JobModel) -> None: + """ + 删除定时任务数据库操作 + + :param db: orm对象 + :param job: 定时任务对象 + :return: + """ + await db.execute(delete(SysJob).where(SysJob.job_id.in_([job.job_id]))) diff --git a/shierfeng-fastapi-backend/module_admin/dao/job_log_dao.py b/shierfeng-fastapi-backend/module_admin/dao/job_log_dao.py new file mode 100644 index 0000000..4269aaf --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/job_log_dao.py @@ -0,0 +1,87 @@ +from datetime import datetime, time +from typing import Any + +from sqlalchemy import delete, desc, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from common.vo import PageModel +from module_admin.entity.do.job_do import SysJobLog +from module_admin.entity.vo.job_vo import JobLogModel, JobLogPageQueryModel +from utils.page_util import PageUtil + + +class JobLogDao: + """ + 定时任务日志管理模块数据库操作层 + """ + + @classmethod + async def get_job_log_list( + cls, db: AsyncSession, query_object: JobLogPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取定时任务日志列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 定时任务日志列表信息对象 + """ + query = ( + select(SysJobLog) + .where( + SysJobLog.job_name.like(f'%{query_object.job_name}%') if query_object.job_name else True, + SysJobLog.job_group == query_object.job_group if query_object.job_group else True, + SysJobLog.status == query_object.status if query_object.status else True, + SysJobLog.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .order_by(desc(SysJobLog.create_time)) + .distinct() + ) + job_log_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return job_log_list + + @classmethod + def add_job_log_dao(cls, db: Session, job_log: JobLogModel) -> SysJobLog: + """ + 新增定时任务日志数据库操作 + + :param db: orm对象 + :param job_log: 定时任务日志对象 + :return: + """ + db_job_log = SysJobLog(**job_log.model_dump()) + db.add(db_job_log) + db.flush() + + return db_job_log + + @classmethod + async def delete_job_log_dao(cls, db: AsyncSession, job_log: JobLogModel) -> None: + """ + 删除定时任务日志数据库操作 + + :param db: orm对象 + :param job_log: 定时任务日志对象 + :return: + """ + await db.execute(delete(SysJobLog).where(SysJobLog.job_log_id.in_([job_log.job_log_id]))) + + @classmethod + async def clear_job_log_dao(cls, db: AsyncSession) -> None: + """ + 清除定时任务日志数据库操作 + + :param db: orm对象 + :return: + """ + await db.execute(delete(SysJobLog)) diff --git a/shierfeng-fastapi-backend/module_admin/dao/log_dao.py b/shierfeng-fastapi-backend/module_admin/dao/log_dao.py new file mode 100644 index 0000000..5ee3078 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/log_dao.py @@ -0,0 +1,183 @@ +from datetime import datetime, time +from typing import Any + +from sqlalchemy import asc, delete, desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.log_do import SysLogininfor, SysOperLog +from module_admin.entity.vo.log_vo import LogininforModel, LoginLogPageQueryModel, OperLogModel, OperLogPageQueryModel +from utils.common_util import SnakeCaseUtil +from utils.page_util import PageUtil +from utils.time_format_util import TimeFormatUtil + + +class OperationLogDao: + """ + 操作日志管理模块数据库操作层 + """ + + @classmethod + async def get_operation_log_list( + cls, db: AsyncSession, query_object: OperLogPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取操作日志列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 操作日志列表信息对象 + """ + if query_object.is_asc == 'ascending': + order_by_column = asc(getattr(SysOperLog, SnakeCaseUtil.camel_to_snake(query_object.order_by_column), None)) + elif query_object.is_asc == 'descending': + order_by_column = desc( + getattr(SysOperLog, SnakeCaseUtil.camel_to_snake(query_object.order_by_column), None) + ) + else: + order_by_column = desc(SysOperLog.oper_time) + query = ( + select(SysOperLog) + .where( + SysOperLog.title.like(f'%{query_object.title}%') if query_object.title else True, + SysOperLog.oper_name.like(f'%{query_object.oper_name}%') if query_object.oper_name else True, + SysOperLog.business_type == query_object.business_type if query_object.business_type else True, + SysOperLog.status == query_object.status if query_object.status else True, + SysOperLog.oper_time.between( + datetime.combine(TimeFormatUtil.parse_date(query_object.begin_time), time(00, 00, 00)), + datetime.combine(TimeFormatUtil.parse_date(query_object.end_time), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .distinct() + .order_by(order_by_column) + ) + operation_log_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return operation_log_list + + @classmethod + async def add_operation_log_dao(cls, db: AsyncSession, operation_log: OperLogModel) -> SysOperLog: + """ + 新增操作日志数据库操作 + + :param db: orm对象 + :param operation_log: 操作日志对象 + :return: 新增校验结果 + """ + db_operation_log = SysOperLog(**operation_log.model_dump()) + db.add(db_operation_log) + await db.flush() + + return db_operation_log + + @classmethod + async def delete_operation_log_dao(cls, db: AsyncSession, operation_log: OperLogModel) -> None: + """ + 删除操作日志数据库操作 + + :param db: orm对象 + :param operation_log: 操作日志对象 + :return: + """ + await db.execute(delete(SysOperLog).where(SysOperLog.oper_id.in_([operation_log.oper_id]))) + + @classmethod + async def clear_operation_log_dao(cls, db: AsyncSession) -> None: + """ + 清除操作日志数据库操作 + + :param db: orm对象 + :return: + """ + await db.execute(delete(SysOperLog)) + + +class LoginLogDao: + """ + 登录日志管理模块数据库操作层 + """ + + @classmethod + async def get_login_log_list( + cls, db: AsyncSession, query_object: LoginLogPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取登录日志列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 登录日志列表信息对象 + """ + if query_object.is_asc == 'ascending': + order_by_column = asc( + getattr(SysLogininfor, SnakeCaseUtil.camel_to_snake(query_object.order_by_column), None) + ) + elif query_object.is_asc == 'descending': + order_by_column = desc( + getattr(SysLogininfor, SnakeCaseUtil.camel_to_snake(query_object.order_by_column), None) + ) + else: + order_by_column = desc(SysLogininfor.login_time) + query = ( + select(SysLogininfor) + .where( + SysLogininfor.ipaddr.like(f'%{query_object.ipaddr}%') if query_object.ipaddr else True, + SysLogininfor.user_name.like(f'%{query_object.user_name}%') if query_object.user_name else True, + SysLogininfor.status == query_object.status if query_object.status else True, + SysLogininfor.login_time.between( + datetime.combine(TimeFormatUtil.parse_date(query_object.begin_time), time(00, 00, 00)), + datetime.combine(TimeFormatUtil.parse_date(query_object.end_time), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .distinct() + .order_by(order_by_column) + ) + login_log_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return login_log_list + + @classmethod + async def add_login_log_dao(cls, db: AsyncSession, login_log: LogininforModel) -> SysLogininfor: + """ + 新增登录日志数据库操作 + + :param db: orm对象 + :param login_log: 登录日志对象 + :return: 新增校验结果 + """ + db_login_log = SysLogininfor(**login_log.model_dump()) + db.add(db_login_log) + await db.flush() + + return db_login_log + + @classmethod + async def delete_login_log_dao(cls, db: AsyncSession, login_log: LogininforModel) -> None: + """ + 删除登录日志数据库操作 + + :param db: orm对象 + :param login_log: 登录日志对象 + :return: + """ + await db.execute(delete(SysLogininfor).where(SysLogininfor.info_id.in_([login_log.info_id]))) + + @classmethod + async def clear_login_log_dao(cls, db: AsyncSession) -> None: + """ + 清除登录日志数据库操作 + + :param db: orm对象 + :return: + """ + await db.execute(delete(SysLogininfor)) diff --git a/shierfeng-fastapi-backend/module_admin/dao/login_dao.py b/shierfeng-fastapi-backend/module_admin/dao/login_dao.py new file mode 100644 index 0000000..fe68bc2 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/login_dao.py @@ -0,0 +1,29 @@ +from sqlalchemy import Row, and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.user_do import SysUser + + +async def login_by_account(db: AsyncSession, user_name: str) -> Row[tuple[SysUser, SysDept]] | None: + """ + 根据用户名查询用户信息 + + :param db: orm对象 + :param user_name: 用户名 + :return: 用户对象 + """ + user = ( + await db.execute( + select(SysUser, SysDept) + .where(SysUser.user_name == user_name, SysUser.del_flag == '0') + .join( + SysDept, + and_(SysUser.dept_id == SysDept.dept_id, SysDept.status == '0', SysDept.del_flag == '0'), + isouter=True, + ) + .distinct() + ) + ).first() + + return user diff --git a/shierfeng-fastapi-backend/module_admin/dao/menu_dao.py b/shierfeng-fastapi-backend/module_admin/dao/menu_dao.py new file mode 100644 index 0000000..b827e42 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/menu_dao.py @@ -0,0 +1,228 @@ +from collections.abc import Sequence + +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.role_do import SysRole, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserRole +from module_admin.entity.vo.menu_vo import MenuModel, MenuQueryModel + + +class MenuDao: + """ + 菜单管理模块数据库操作层 + """ + + @classmethod + async def get_menu_detail_by_id(cls, db: AsyncSession, menu_id: int) -> SysMenu | None: + """ + 根据菜单id获取菜单详细信息 + + :param db: orm对象 + :param menu_id: 菜单id + :return: 菜单信息对象 + """ + menu_info = (await db.execute(select(SysMenu).where(SysMenu.menu_id == menu_id))).scalars().first() + + return menu_info + + @classmethod + async def get_menu_detail_by_info(cls, db: AsyncSession, menu: MenuModel) -> SysMenu | None: + """ + 根据菜单参数获取菜单信息 + + :param db: orm对象 + :param menu: 菜单参数对象 + :return: 菜单信息对象 + """ + menu_info = ( + ( + await db.execute( + select(SysMenu).where( + SysMenu.parent_id == menu.parent_id if menu.parent_id else True, + SysMenu.menu_name == menu.menu_name if menu.menu_name else True, + SysMenu.menu_type == menu.menu_type if menu.menu_type else True, + ) + ) + ) + .scalars() + .first() + ) + + return menu_info + + @classmethod + async def get_menu_list_for_tree(cls, db: AsyncSession, user_id: int, role: list) -> Sequence[SysMenu]: + """ + 根据角色信息获取所有在用菜单列表信息 + + :param db: orm对象 + :param user_id: 用户id + :param role: 用户角色列表信息 + :return: 菜单列表信息 + """ + role_id_list = [item.role_id for item in role] + if 1 in role_id_list: + menu_query_all = ( + (await db.execute(select(SysMenu).where(SysMenu.status == '0').order_by(SysMenu.order_num).distinct())) + .scalars() + .all() + ) + else: + menu_query_all = ( + ( + await db.execute( + select(SysMenu) + .select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_( + SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0' + ), + isouter=True, + ) + .join(SysRoleMenu, SysRole.role_id == SysRoleMenu.role_id, isouter=True) + .join(SysMenu, and_(SysRoleMenu.menu_id == SysMenu.menu_id, SysMenu.status == '0')) + .order_by(SysMenu.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + + return menu_query_all + + @classmethod + async def get_menu_list( + cls, db: AsyncSession, page_object: MenuQueryModel, user_id: int, role: list + ) -> Sequence[SysMenu]: + """ + 根据查询参数获取菜单列表信息 + + :param db: orm对象 + :param page_object: 不分页查询参数对象 + :param user_id: 用户id + :param role: 用户角色列表 + :return: 菜单列表信息对象 + """ + role_id_list = [item.role_id for item in role] + if 1 in role_id_list: + menu_query_all = ( + ( + await db.execute( + select(SysMenu) + .where( + SysMenu.status == page_object.status if page_object.status else True, + SysMenu.menu_name.like(f'%{page_object.menu_name}%') if page_object.menu_name else True, + ) + .order_by(SysMenu.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + else: + menu_query_all = ( + ( + await db.execute( + select(SysMenu) + .select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_( + SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0' + ), + isouter=True, + ) + .join(SysRoleMenu, SysRole.role_id == SysRoleMenu.role_id, isouter=True) + .join( + SysMenu, + and_( + SysRoleMenu.menu_id == SysMenu.menu_id, + SysMenu.status == page_object.status if page_object.status else True, + SysMenu.menu_name.like(f'%{page_object.menu_name}%') if page_object.menu_name else True, + ), + ) + .order_by(SysMenu.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + + return menu_query_all + + @classmethod + async def add_menu_dao(cls, db: AsyncSession, menu: MenuModel) -> SysMenu: + """ + 新增菜单数据库操作 + + :param db: orm对象 + :param menu: 菜单对象 + :return: + """ + db_menu = SysMenu(**menu.model_dump()) + db.add(db_menu) + await db.flush() + + return db_menu + + @classmethod + async def edit_menu_dao(cls, db: AsyncSession, menu: dict) -> None: + """ + 编辑菜单数据库操作 + + :param db: orm对象 + :param menu: 需要更新的菜单字典 + :return: + """ + await db.execute(update(SysMenu), [menu]) + + @classmethod + async def delete_menu_dao(cls, db: AsyncSession, menu: MenuModel) -> None: + """ + 删除菜单数据库操作 + + :param db: orm对象 + :param menu: 菜单对象 + :return: + """ + await db.execute(delete(SysMenu).where(SysMenu.menu_id.in_([menu.menu_id]))) + + @classmethod + async def has_child_by_menu_id_dao(cls, db: AsyncSession, menu_id: int) -> int | None: + """ + 根据菜单id查询菜单关联子菜单的数量 + + :param db: orm对象 + :param menu_id: 菜单id + :return: 菜单关联子菜单的数量 + """ + menu_count = ( + await db.execute(select(func.count('*')).select_from(SysMenu).where(SysMenu.parent_id == menu_id)) + ).scalar() + + return menu_count + + @classmethod + async def check_menu_exist_role_dao(cls, db: AsyncSession, menu_id: int) -> int | None: + """ + 根据菜单id查询菜单关联角色数量 + + :param db: orm对象 + :param menu_id: 菜单id + :return: 菜单关联角色数量 + """ + role_count = ( + await db.execute(select(func.count('*')).select_from(SysRoleMenu).where(SysRoleMenu.menu_id == menu_id)) + ).scalar() + + return role_count diff --git a/shierfeng-fastapi-backend/module_admin/dao/notice_dao.py b/shierfeng-fastapi-backend/module_admin/dao/notice_dao.py new file mode 100644 index 0000000..942736c --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/notice_dao.py @@ -0,0 +1,125 @@ +from datetime import datetime, time +from typing import Any + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.notice_do import SysNotice +from module_admin.entity.vo.notice_vo import NoticeModel, NoticePageQueryModel +from utils.page_util import PageUtil + + +class NoticeDao: + """ + 通知公告管理模块数据库操作层 + """ + + @classmethod + async def get_notice_detail_by_id(cls, db: AsyncSession, notice_id: int) -> SysNotice | None: + """ + 根据通知公告id获取通知公告详细信息 + + :param db: orm对象 + :param notice_id: 通知公告id + :return: 通知公告信息对象 + """ + notice_info = (await db.execute(select(SysNotice).where(SysNotice.notice_id == notice_id))).scalars().first() + + return notice_info + + @classmethod + async def get_notice_detail_by_info(cls, db: AsyncSession, notice: NoticeModel) -> SysNotice | None: + """ + 根据通知公告参数获取通知公告信息 + + :param db: orm对象 + :param notice: 通知公告参数对象 + :return: 通知公告信息对象 + """ + notice_info = ( + ( + await db.execute( + select(SysNotice).where( + SysNotice.notice_title == notice.notice_title, + SysNotice.notice_type == notice.notice_type, + SysNotice.notice_content == notice.notice_content, + ) + ) + ) + .scalars() + .first() + ) + + return notice_info + + @classmethod + async def get_notice_list( + cls, db: AsyncSession, query_object: NoticePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取通知公告列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 通知公告列表信息对象 + """ + query = ( + select(SysNotice) + .where( + SysNotice.notice_title.like(f'%{query_object.notice_title}%') if query_object.notice_title else True, + SysNotice.create_by.like(f'%{query_object.create_by}%') if query_object.create_by else True, + SysNotice.notice_type == query_object.notice_type if query_object.notice_type else True, + SysNotice.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .order_by(SysNotice.notice_id) + .distinct() + ) + notice_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return notice_list + + @classmethod + async def add_notice_dao(cls, db: AsyncSession, notice: NoticeModel) -> SysNotice: + """ + 新增通知公告数据库操作 + + :param db: orm对象 + :param notice: 通知公告对象 + :return: + """ + db_notice = SysNotice(**notice.model_dump()) + db.add(db_notice) + await db.flush() + + return db_notice + + @classmethod + async def edit_notice_dao(cls, db: AsyncSession, notice: dict) -> None: + """ + 编辑通知公告数据库操作 + + :param db: orm对象 + :param notice: 需要更新的通知公告字典 + :return: + """ + await db.execute(update(SysNotice), [notice]) + + @classmethod + async def delete_notice_dao(cls, db: AsyncSession, notice: NoticeModel) -> None: + """ + 删除通知公告数据库操作 + + :param db: orm对象 + :param notice: 通知公告对象 + :return: + """ + await db.execute(delete(SysNotice).where(SysNotice.notice_id.in_([notice.notice_id]))) diff --git a/shierfeng-fastapi-backend/module_admin/dao/post_dao.py b/shierfeng-fastapi-backend/module_admin/dao/post_dao.py new file mode 100644 index 0000000..da67123 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/post_dao.py @@ -0,0 +1,151 @@ +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.post_do import SysPost +from module_admin.entity.do.user_do import SysUserPost +from module_admin.entity.vo.post_vo import PostModel, PostPageQueryModel +from utils.page_util import PageUtil + + +class PostDao: + """ + 岗位管理模块数据库操作层 + """ + + @classmethod + async def get_post_by_id(cls, db: AsyncSession, post_id: int) -> SysPost | None: + """ + 根据岗位id获取在用岗位详细信息 + + :param db: orm对象 + :param post_id: 岗位id + :return: 在用岗位信息对象 + """ + post_info = ( + (await db.execute(select(SysPost).where(SysPost.post_id == post_id, SysPost.status == '0'))) + .scalars() + .first() + ) + + return post_info + + @classmethod + async def get_post_detail_by_id(cls, db: AsyncSession, post_id: int) -> SysPost | None: + """ + 根据岗位id获取岗位详细信息 + + :param db: orm对象 + :param post_id: 岗位id + :return: 岗位信息对象 + """ + post_info = (await db.execute(select(SysPost).where(SysPost.post_id == post_id))).scalars().first() + + return post_info + + @classmethod + async def get_post_detail_by_info(cls, db: AsyncSession, post: PostModel) -> SysPost | None: + """ + 根据岗位参数获取岗位信息 + + :param db: orm对象 + :param post: 岗位参数对象 + :return: 岗位信息对象 + """ + post_info = ( + ( + await db.execute( + select(SysPost).where( + SysPost.post_name == post.post_name if post.post_name else True, + SysPost.post_code == post.post_code if post.post_code else True, + SysPost.post_sort == post.post_sort if post.post_sort else True, + ) + ) + ) + .scalars() + .first() + ) + + return post_info + + @classmethod + async def get_post_list( + cls, db: AsyncSession, query_object: PostPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取岗位列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 岗位列表信息对象 + """ + query = ( + select(SysPost) + .where( + SysPost.post_code.like(f'%{query_object.post_code}%') if query_object.post_code else True, + SysPost.post_name.like(f'%{query_object.post_name}%') if query_object.post_name else True, + SysPost.status == query_object.status if query_object.status else True, + ) + .order_by(SysPost.post_sort) + .distinct() + ) + post_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return post_list + + @classmethod + async def add_post_dao(cls, db: AsyncSession, post: PostModel) -> SysPost: + """ + 新增岗位数据库操作 + + :param db: orm对象 + :param post: 岗位对象 + :return: + """ + db_post = SysPost(**post.model_dump()) + db.add(db_post) + await db.flush() + + return db_post + + @classmethod + async def edit_post_dao(cls, db: AsyncSession, post: dict) -> None: + """ + 编辑岗位数据库操作 + + :param db: orm对象 + :param post: 需要更新的岗位字典 + :return: + """ + await db.execute(update(SysPost), [post]) + + @classmethod + async def delete_post_dao(cls, db: AsyncSession, post: PostModel) -> None: + """ + 删除岗位数据库操作 + + :param db: orm对象 + :param post: 岗位对象 + :return: + """ + await db.execute(delete(SysPost).where(SysPost.post_id.in_([post.post_id]))) + + @classmethod + async def count_user_post_dao(cls, db: AsyncSession, post_id: int) -> int | None: + """ + 根据岗位id查询岗位关联的用户数量 + + :param db: orm对象 + :param post_id: 岗位id + :return: 岗位关联的用户数量 + """ + user_post_count = ( + await db.execute(select(func.count('*')).select_from(SysUserPost).where(SysUserPost.post_id == post_id)) + ).scalar() + + return user_post_count diff --git a/shierfeng-fastapi-backend/module_admin/dao/role_dao.py b/shierfeng-fastapi-backend/module_admin/dao/role_dao.py new file mode 100644 index 0000000..c4e295f --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/role_dao.py @@ -0,0 +1,345 @@ +from collections.abc import Sequence +from datetime import datetime, time +from typing import Any + +from sqlalchemy import ColumnElement, and_, delete, desc, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.role_do import SysRole, SysRoleDept, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserRole +from module_admin.entity.vo.role_vo import RoleDeptModel, RoleMenuModel, RoleModel, RolePageQueryModel +from utils.page_util import PageUtil + + +class RoleDao: + """ + 角色管理模块数据库操作层 + """ + + @classmethod + async def get_role_by_name(cls, db: AsyncSession, role_name: str) -> SysRole | None: + """ + 根据角色名获取在用角色信息 + + :param db: orm对象 + :param role_name: 角色名 + :return: 当前角色名的角色信息对象 + """ + query_role_info = ( + ( + await db.execute( + select(SysRole) + .where(SysRole.status == '0', SysRole.del_flag == '0', SysRole.role_name == role_name) + .order_by(desc(SysRole.create_time)) + .distinct() + ) + ) + .scalars() + .first() + ) + + return query_role_info + + @classmethod + async def get_role_by_info(cls, db: AsyncSession, role: RoleModel) -> SysRole | None: + """ + 根据角色参数获取角色信息 + + :param db: orm对象 + :param role: 角色参数 + :return: 当前角色参数的角色信息对象 + """ + query_role_info = ( + ( + await db.execute( + select(SysRole) + .where( + SysRole.del_flag == '0', + SysRole.role_name == role.role_name if role.role_name else True, + SysRole.role_key == role.role_key if role.role_key else True, + ) + .order_by(desc(SysRole.create_time)) + .distinct() + ) + ) + .scalars() + .first() + ) + + return query_role_info + + @classmethod + async def get_role_by_id(cls, db: AsyncSession, role_id: int) -> SysRole | None: + """ + 根据角色id获取在用角色信息 + + :param db: orm对象 + :param role_id: 角色id + :return: 当前角色id的角色信息对象 + """ + role_info = ( + ( + await db.execute( + select(SysRole).where(SysRole.role_id == role_id, SysRole.status == '0', SysRole.del_flag == '0') + ) + ) + .scalars() + .first() + ) + + return role_info + + @classmethod + async def get_role_detail_by_id(cls, db: AsyncSession, role_id: int) -> SysRole | None: + """ + 根据role_id获取角色详细信息 + + :param db: orm对象 + :param role_id: 角色id + :return: 当前role_id的角色信息对象 + """ + query_role_info = ( + (await db.execute(select(SysRole).where(SysRole.del_flag == '0', SysRole.role_id == role_id).distinct())) + .scalars() + .first() + ) + + return query_role_info + + @classmethod + async def get_role_select_option_dao(cls, db: AsyncSession) -> Sequence[SysRole]: + """ + 获取编辑页面对应的在用角色列表信息 + + :param db: orm对象 + :return: 角色列表信息 + """ + role_info = ( + ( + await db.execute( + select(SysRole).where(SysRole.role_id != 1, SysRole.status == '0', SysRole.del_flag == '0') + ) + ) + .scalars() + .all() + ) + + return role_info + + @classmethod + async def get_role_list( + cls, db: AsyncSession, query_object: RolePageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取角色列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 角色列表信息对象 + """ + query = ( + select(SysRole) + .join(SysUserRole, SysUserRole.role_id == SysRole.role_id, isouter=True) + .join(SysUser, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join(SysDept, SysDept.dept_id == SysUser.dept_id, isouter=True) + .where( + SysRole.del_flag == '0', + SysRole.role_id == query_object.role_id if query_object.role_id is not None else True, + SysRole.role_name.like(f'%{query_object.role_name}%') if query_object.role_name else True, + SysRole.role_key.like(f'%{query_object.role_key}%') if query_object.role_key else True, + SysRole.status == query_object.status if query_object.status else True, + SysRole.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + data_scope_sql, + ) + .order_by(SysRole.role_sort) + .distinct() + ) + role_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return role_list + + @classmethod + async def add_role_dao(cls, db: AsyncSession, role: RoleModel) -> SysRole: + """ + 新增角色数据库操作 + + :param db: orm对象 + :param role: 角色对象 + :return: + """ + db_role = SysRole(**role.model_dump(exclude={'admin'})) + db.add(db_role) + await db.flush() + + return db_role + + @classmethod + async def edit_role_dao(cls, db: AsyncSession, role: dict) -> None: + """ + 编辑角色数据库操作 + + :param db: orm对象 + :param role: 需要更新的角色字典 + :return: + """ + await db.execute(update(SysRole), [role]) + + @classmethod + async def delete_role_dao(cls, db: AsyncSession, role: RoleModel) -> None: + """ + 删除角色数据库操作 + + :param db: orm对象 + :param role: 角色对象 + :return: + """ + await db.execute( + update(SysRole) + .where(SysRole.role_id == role.role_id) + .values(del_flag='2', update_by=role.update_by, update_time=role.update_time) + ) + + @classmethod + async def get_role_menu_dao(cls, db: AsyncSession, role: RoleModel) -> Sequence[SysMenu]: + """ + 根据角色id获取角色菜单关联列表信息 + + :param db: orm对象 + :param role: 角色对象 + :return: 角色菜单关联列表信息 + """ + role_menu_query_all = ( + ( + await db.execute( + select(SysMenu) + .join(SysRoleMenu, SysRoleMenu.menu_id == SysMenu.menu_id) + .where( + SysRoleMenu.role_id == role.role_id, + ~SysMenu.menu_id.in_( + select(SysMenu.parent_id) + .select_from(SysMenu) + .join( + SysRoleMenu, + and_(SysRoleMenu.menu_id == SysMenu.menu_id, SysRoleMenu.role_id == role.role_id), + ) + ) + if role.menu_check_strictly + else True, + ) + .order_by(SysMenu.parent_id, SysMenu.order_num) + ) + ) + .scalars() + .all() + ) + + return role_menu_query_all + + @classmethod + async def add_role_menu_dao(cls, db: AsyncSession, role_menu: RoleMenuModel) -> None: + """ + 新增角色菜单关联信息数据库操作 + + :param db: orm对象 + :param role_menu: 用户角色菜单关联对象 + :return: + """ + db_role_menu = SysRoleMenu(**role_menu.model_dump()) + db.add(db_role_menu) + + @classmethod + async def delete_role_menu_dao(cls, db: AsyncSession, role_menu: RoleMenuModel) -> None: + """ + 删除角色菜单关联信息数据库操作 + + :param db: orm对象 + :param role_menu: 角色菜单关联对象 + :return: + """ + await db.execute(delete(SysRoleMenu).where(SysRoleMenu.role_id.in_([role_menu.role_id]))) + + @classmethod + async def get_role_dept_dao(cls, db: AsyncSession, role: RoleModel) -> Sequence[SysDept]: + """ + 根据角色id获取角色部门关联列表信息 + + :param db: orm对象 + :param role: 角色对象 + :return: 角色部门关联列表信息 + """ + role_dept_query_all = ( + ( + await db.execute( + select(SysDept) + .join(SysRoleDept, SysRoleDept.dept_id == SysDept.dept_id) + .where( + SysRoleDept.role_id == role.role_id, + ~SysDept.dept_id.in_( + select(SysDept.parent_id) + .select_from(SysDept) + .join( + SysRoleDept, + and_(SysRoleDept.dept_id == SysDept.dept_id, SysRoleDept.role_id == role.role_id), + ) + ) + if role.dept_check_strictly + else True, + ) + .order_by(SysDept.parent_id, SysDept.order_num) + ) + ) + .scalars() + .all() + ) + + return role_dept_query_all + + @classmethod + async def add_role_dept_dao(cls, db: AsyncSession, role_dept: RoleDeptModel) -> None: + """ + 新增角色部门关联信息数据库操作 + + :param db: orm对象 + :param role_dept: 用户角色部门关联对象 + :return: + """ + db_role_dept = SysRoleDept(**role_dept.dict()) + db.add(db_role_dept) + + @classmethod + async def delete_role_dept_dao(cls, db: AsyncSession, role_dept: RoleDeptModel) -> None: + """ + 删除角色部门关联信息数据库操作 + + :param db: orm对象 + :param role_dept: 角色部门关联对象 + :return: + """ + await db.execute(delete(SysRoleDept).where(SysRoleDept.role_id.in_([role_dept.role_id]))) + + @classmethod + async def count_user_role_dao(cls, db: AsyncSession, role_id: int) -> int | None: + """ + 根据角色id查询角色关联用户数量 + + :param db: orm对象 + :param role_id: 角色id + :return: 角色关联用户数量 + """ + user_count = ( + await db.execute(select(func.count('*')).select_from(SysUserRole).where(SysUserRole.role_id == role_id)) + ).scalar() + + return user_count diff --git a/shierfeng-fastapi-backend/module_admin/dao/user_dao.py b/shierfeng-fastapi-backend/module_admin/dao/user_dao.py new file mode 100644 index 0000000..71b7c4b --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/dao/user_dao.py @@ -0,0 +1,587 @@ +from collections.abc import Sequence +from datetime import datetime, time +from typing import Any + +from sqlalchemy import ColumnElement, and_, delete, desc, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.post_do import SysPost +from module_admin.entity.do.role_do import SysRole, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserPost, SysUserRole +from module_admin.entity.vo.user_vo import ( + UserModel, + UserPageQueryModel, + UserPostModel, + UserRoleModel, + UserRolePageQueryModel, + UserRoleQueryModel, +) +from utils.page_util import PageUtil + + +class UserDao: + """ + 用户管理模块数据库操作层 + """ + + @classmethod + async def get_user_by_name(cls, db: AsyncSession, user_name: str) -> SysUser | None: + """ + 根据用户名获取用户信息 + + :param db: orm对象 + :param user_name: 用户名 + :return: 当前用户名的用户信息对象 + """ + query_user_info = ( + ( + await db.execute( + select(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_name == user_name) + .order_by(desc(SysUser.create_time)) + .distinct() + ) + ) + .scalars() + .first() + ) + + return query_user_info + + @classmethod + async def get_user_by_info(cls, db: AsyncSession, user: UserModel) -> SysUser | None: + """ + 根据用户参数获取用户信息 + + :param db: orm对象 + :param user: 用户参数 + :return: 当前用户参数的用户信息对象 + """ + query_user_info = ( + ( + await db.execute( + select(SysUser) + .where( + SysUser.del_flag == '0', + SysUser.user_name == user.user_name if user.user_name else True, + SysUser.phonenumber == user.phonenumber if user.phonenumber else True, + SysUser.email == user.email if user.email else True, + ) + .order_by(desc(SysUser.create_time)) + .distinct() + ) + ) + .scalars() + .first() + ) + + return query_user_info + + @classmethod + async def get_user_by_id(cls, db: AsyncSession, user_id: int) -> dict[str, Any]: + """ + 根据user_id获取用户信息 + + :param db: orm对象 + :param user_id: 用户id + :return: 当前user_id的用户信息对象 + """ + query_user_basic_info = ( + ( + await db.execute( + select(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .distinct() + ) + ) + .scalars() + .first() + ) + query_user_dept_info = ( + ( + await db.execute( + select(SysDept) + .select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join( + SysDept, + and_(SysUser.dept_id == SysDept.dept_id, SysDept.status == '0', SysDept.del_flag == '0'), + ) + .distinct() + ) + ) + .scalars() + .first() + ) + query_user_role_info = ( + ( + await db.execute( + select(SysRole) + .select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_(SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0'), + ) + .distinct() + ) + ) + .scalars() + .all() + ) + query_user_post_info = ( + ( + await db.execute( + select(SysPost) + .select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserPost, SysUser.user_id == SysUserPost.user_id, isouter=True) + .join(SysPost, and_(SysUserPost.post_id == SysPost.post_id, SysPost.status == '0')) + .distinct() + ) + ) + .scalars() + .all() + ) + role_id_list = [item.role_id for item in query_user_role_info] + if 1 in role_id_list: + query_user_menu_info = ( + (await db.execute(select(SysMenu).where(SysMenu.status == '0').distinct())).scalars().all() + ) + else: + query_user_menu_info = ( + ( + await db.execute( + select(SysMenu) + .select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_( + SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0' + ), + isouter=True, + ) + .join(SysRoleMenu, SysRole.role_id == SysRoleMenu.role_id, isouter=True) + .join(SysMenu, and_(SysRoleMenu.menu_id == SysMenu.menu_id, SysMenu.status == '0')) + .order_by(SysMenu.order_num) + .distinct() + ) + ) + .scalars() + .all() + ) + + results = { + 'user_basic_info': query_user_basic_info, + 'user_dept_info': query_user_dept_info, + 'user_role_info': query_user_role_info, + 'user_post_info': query_user_post_info, + 'user_menu_info': query_user_menu_info, + } + + return results + + @classmethod + async def get_user_detail_by_id(cls, db: AsyncSession, user_id: int) -> dict[str, Any]: + """ + 根据user_id获取用户详细信息 + + :param db: orm对象 + :param user_id: 用户id + :return: 当前user_id的用户信息对象 + """ + query_user_basic_info = ( + (await db.execute(select(SysUser).where(SysUser.del_flag == '0', SysUser.user_id == user_id).distinct())) + .scalars() + .first() + ) + query_user_dept_info = ( + ( + await db.execute( + select(SysDept) + .select_from(SysUser) + .where(SysUser.del_flag == '0', SysUser.user_id == user_id) + .join( + SysDept, + and_(SysUser.dept_id == SysDept.dept_id, SysDept.status == '0', SysDept.del_flag == '0'), + ) + .distinct() + ) + ) + .scalars() + .first() + ) + query_user_role_info = ( + ( + await db.execute( + select(SysRole) + .select_from(SysUser) + .where(SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_(SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0'), + ) + .distinct() + ) + ) + .scalars() + .all() + ) + query_user_post_info = ( + ( + await db.execute( + select(SysPost) + .select_from(SysUser) + .where(SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserPost, SysUser.user_id == SysUserPost.user_id, isouter=True) + .join(SysPost, and_(SysUserPost.post_id == SysPost.post_id, SysPost.status == '0')) + .distinct() + ) + ) + .scalars() + .all() + ) + query_user_menu_info = ( + ( + await db.execute( + select(SysMenu) + .select_from(SysUser) + .where(SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_(SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0'), + isouter=True, + ) + .join(SysRoleMenu, SysRole.role_id == SysRoleMenu.role_id, isouter=True) + .join(SysMenu, and_(SysRoleMenu.menu_id == SysMenu.menu_id, SysMenu.status == '0')) + .distinct() + ) + ) + .scalars() + .all() + ) + results = { + 'user_basic_info': query_user_basic_info, + 'user_dept_info': query_user_dept_info, + 'user_role_info': query_user_role_info, + 'user_post_info': query_user_post_info, + 'user_menu_info': query_user_menu_info, + } + + return results + + @classmethod + async def get_user_list( + cls, db: AsyncSession, query_object: UserPageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False + ) -> PageModel | list[list[dict[str, Any]]]: + """ + 根据查询参数获取用户列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 用户列表信息对象 + """ + query = ( + select(SysUser, SysDept) + .where( + SysUser.del_flag == '0', + or_( + SysUser.dept_id == query_object.dept_id, + SysUser.dept_id.in_( + select(SysDept.dept_id).where(func.find_in_set(query_object.dept_id, SysDept.ancestors)) + ), + ) + if query_object.dept_id + else True, + SysUser.user_id == query_object.user_id if query_object.user_id is not None else True, + SysUser.user_name.like(f'%{query_object.user_name}%') if query_object.user_name else True, + SysUser.nick_name.like(f'%{query_object.nick_name}%') if query_object.nick_name else True, + SysUser.email.like(f'%{query_object.email}%') if query_object.email else True, + SysUser.phonenumber.like(f'%{query_object.phonenumber}%') if query_object.phonenumber else True, + SysUser.status == query_object.status if query_object.status else True, + SysUser.sex == query_object.sex if query_object.sex else True, + SysUser.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + data_scope_sql, + ) + .join( + SysDept, + and_(SysUser.dept_id == SysDept.dept_id, SysDept.status == '0', SysDept.del_flag == '0'), + isouter=True, + ) + .order_by(SysUser.user_id) + .distinct() + ) + user_list: PageModel | list[list[dict[str, Any]]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return user_list + + @classmethod + async def add_user_dao(cls, db: AsyncSession, user: UserModel) -> SysUser: + """ + 新增用户数据库操作 + + :param db: orm对象 + :param user: 用户对象 + :return: 新增校验结果 + """ + db_user = SysUser(**user.model_dump(exclude={'admin'})) + db.add(db_user) + await db.flush() + + return db_user + + @classmethod + async def edit_user_dao(cls, db: AsyncSession, user: dict) -> None: + """ + 编辑用户数据库操作 + + :param db: orm对象 + :param user: 需要更新的用户字典 + :return: 编辑校验结果 + """ + await db.execute(update(SysUser), [user]) + + @classmethod + async def delete_user_dao(cls, db: AsyncSession, user: UserModel) -> None: + """ + 删除用户数据库操作 + + :param db: orm对象 + :param user: 用户对象 + :return: + """ + await db.execute( + update(SysUser) + .where(SysUser.user_id == user.user_id) + .values(del_flag='2', update_by=user.update_by, update_time=user.update_time) + ) + + @classmethod + async def get_user_role_allocated_list_by_user_id( + cls, db: AsyncSession, query_object: UserRoleQueryModel + ) -> Sequence[SysRole]: + """ + 根据用户id获取用户已分配的角色列表信息数据库操作 + + :param db: orm对象 + :param query_object: 用户角色查询对象 + :return: 用户已分配的角色列表信息 + """ + allocated_role_list = ( + ( + await db.execute( + select(SysRole) + .where( + SysRole.del_flag == '0', + SysRole.role_id != 1, + SysRole.role_name == query_object.role_name if query_object.role_name else True, + SysRole.role_key == query_object.role_key if query_object.role_key else True, + SysRole.role_id.in_( + select(SysUserRole.role_id).where(SysUserRole.user_id == query_object.user_id) + ), + ) + .distinct() + ) + ) + .scalars() + .all() + ) + + return allocated_role_list + + @classmethod + async def get_user_role_allocated_list_by_role_id( + cls, + db: AsyncSession, + query_object: UserRolePageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel | list[dict[str, Any]]: + """ + 根据角色id获取已分配的用户列表信息 + + :param db: orm对象 + :param query_object: 用户角色查询对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 角色已分配的用户列表信息 + """ + query = ( + select(SysUser) + .join(SysDept, SysDept.dept_id == SysUser.dept_id, isouter=True) + .join(SysUserRole, SysUserRole.user_id == SysUser.user_id, isouter=True) + .join(SysRole, SysRole.role_id == SysUserRole.role_id, isouter=True) + .where( + SysUser.del_flag == '0', + SysUser.user_name == query_object.user_name if query_object.user_name else True, + SysUser.phonenumber == query_object.phonenumber if query_object.phonenumber else True, + SysRole.role_id == query_object.role_id, + data_scope_sql, + ) + .distinct() + ) + allocated_user_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return allocated_user_list + + @classmethod + async def get_user_role_unallocated_list_by_role_id( + cls, + db: AsyncSession, + query_object: UserRolePageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel | list[dict[str, Any]]: + """ + 根据角色id获取未分配的用户列表信息 + + :param db: orm对象 + :param query_object: 用户角色查询对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 角色未分配的用户列表信息 + """ + query = ( + select(SysUser) + .join(SysDept, SysDept.dept_id == SysUser.dept_id, isouter=True) + .join(SysUserRole, SysUserRole.user_id == SysUser.user_id, isouter=True) + .join(SysRole, SysRole.role_id == SysUserRole.role_id, isouter=True) + .where( + SysUser.del_flag == '0', + SysUser.user_name == query_object.user_name if query_object.user_name else True, + SysUser.phonenumber == query_object.phonenumber if query_object.phonenumber else True, + or_(SysRole.role_id != query_object.role_id, SysRole.role_id.is_(None)), + ~SysUser.user_id.in_( + select(SysUser.user_id) + .select_from(SysUser) + .join( + SysUserRole, + and_(SysUserRole.user_id == SysUser.user_id, SysUserRole.role_id == query_object.role_id), + ) + ), + data_scope_sql, + ) + .distinct() + ) + unallocated_user_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return unallocated_user_list + + @classmethod + async def add_user_role_dao(cls, db: AsyncSession, user_role: UserRoleModel) -> None: + """ + 新增用户角色关联信息数据库操作 + + :param db: orm对象 + :param user_role: 用户角色关联对象 + :return: + """ + db_user_role = SysUserRole(**user_role.model_dump()) + db.add(db_user_role) + + @classmethod + async def delete_user_role_dao(cls, db: AsyncSession, user_role: UserRoleModel) -> None: + """ + 删除用户角色关联信息数据库操作 + + :param db: orm对象 + :param user_role: 用户角色关联对象 + :return: + """ + await db.execute(delete(SysUserRole).where(SysUserRole.user_id.in_([user_role.user_id]))) + + @classmethod + async def delete_user_role_by_user_and_role_dao(cls, db: AsyncSession, user_role: UserRoleModel) -> None: + """ + 根据用户id及角色id删除用户角色关联信息数据库操作 + + :param db: orm对象 + :param user_role: 用户角色关联对象 + :return: + """ + await db.execute( + delete(SysUserRole).where( + SysUserRole.user_id == user_role.user_id if user_role.user_id else True, + SysUserRole.role_id == user_role.role_id if user_role.role_id else True, + ) + ) + + @classmethod + async def get_user_role_detail(cls, db: AsyncSession, user_role: UserRoleModel) -> SysUserRole | None: + """ + 根据用户角色关联获取用户角色关联详细信息 + + :param db: orm对象 + :param user_role: 用户角色关联对象 + :return: 用户角色关联信息 + """ + user_role_info = ( + ( + await db.execute( + select(SysUserRole) + .where(SysUserRole.user_id == user_role.user_id, SysUserRole.role_id == user_role.role_id) + .distinct() + ) + ) + .scalars() + .first() + ) + + return user_role_info + + @classmethod + async def add_user_post_dao(cls, db: AsyncSession, user_post: UserPostModel) -> None: + """ + 新增用户岗位关联信息数据库操作 + + :param db: orm对象 + :param user_post: 用户岗位关联对象 + :return: + """ + db_user_post = SysUserPost(**user_post.model_dump()) + db.add(db_user_post) + + @classmethod + async def delete_user_post_dao(cls, db: AsyncSession, user_post: UserPostModel) -> None: + """ + 删除用户岗位关联信息数据库操作 + + :param db: orm对象 + :param user_post: 用户岗位关联对象 + :return: + """ + await db.execute(delete(SysUserPost).where(SysUserPost.user_id.in_([user_post.user_id]))) + + @classmethod + async def get_user_dept_info(cls, db: AsyncSession, dept_id: int) -> SysDept | None: + dept_basic_info = ( + ( + await db.execute( + select(SysDept).where(SysDept.dept_id == dept_id, SysDept.status == '0', SysDept.del_flag == '0') + ) + ) + .scalars() + .first() + ) + return dept_basic_info diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/config_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/config_do.py new file mode 100644 index 0000000..0c40b8d --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/config_do.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from sqlalchemy import CHAR, Column, DateTime, Integer, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysConfig(Base): + """ + 参数配置表 + """ + + __tablename__ = 'sys_config' + __table_args__ = {'comment': '参数配置表'} + + config_id = Column(Integer, primary_key=True, nullable=False, autoincrement=True, comment='参数主键') + config_name = Column(String(100), nullable=True, server_default="''", comment='参数名称') + config_key = Column(String(100), nullable=True, server_default="''", comment='参数键名') + config_value = Column(String(500), nullable=True, server_default="''", comment='参数键值') + config_type = Column(CHAR(1), nullable=True, server_default='N', comment='系统内置(Y是 N否)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/dept_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/dept_do.py new file mode 100644 index 0000000..c745b56 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/dept_do.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysDept(Base): + """ + 部门表 + """ + + __tablename__ = 'sys_dept' + __table_args__ = {'comment': '部门表'} + + dept_id = Column(BigInteger, primary_key=True, autoincrement=True, comment='部门id') + parent_id = Column(BigInteger, server_default='0', comment='父部门id') + ancestors = Column(String(50), nullable=True, server_default="''", comment='祖级列表') + dept_name = Column(String(30), nullable=True, server_default="''", comment='部门名称') + order_num = Column(Integer, server_default='0', comment='显示顺序') + leader = Column( + String(20), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='负责人', + ) + phone = Column( + String(11), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='联系电话', + ) + email = Column( + String(50), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='邮箱', + ) + status = Column(CHAR(1), nullable=True, server_default='0', comment='部门状态(0正常 1停用)') + del_flag = Column(CHAR(1), nullable=True, server_default='0', comment='删除标志(0代表存在 2代表删除)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/dict_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/dict_do.py new file mode 100644 index 0000000..37b2b7b --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/dict_do.py @@ -0,0 +1,70 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysDictType(Base): + """ + 字典类型表 + """ + + __tablename__ = 'sys_dict_type' + __table_args__ = {'comment': '字典类型表'} + + dict_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='字典主键') + dict_name = Column(String(100), nullable=True, server_default="''", comment='字典名称') + dict_type = Column(String(100), unique=True, nullable=True, server_default="''", comment='字典类型') + status = Column(CHAR(1), nullable=True, server_default='0', comment='状态(0正常 1停用)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) + + +class SysDictData(Base): + """ + 字典数据表 + """ + + __tablename__ = 'sys_dict_data' + __table_args__ = {'comment': '字典数据表'} + + dict_code = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='字典编码') + dict_sort = Column(Integer, nullable=True, server_default='0', comment='字典排序') + dict_label = Column(String(100), nullable=True, server_default="''", comment='字典标签') + dict_value = Column(String(100), nullable=True, server_default="''", comment='字典键值') + dict_type = Column(String(100), nullable=True, server_default="''", comment='字典类型') + css_class = Column( + String(100), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='样式属性(其他样式扩展)', + ) + list_class = Column( + String(100), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='表格回显样式', + ) + is_default = Column(CHAR(1), nullable=True, server_default='N', comment='是否默认(Y是 N否)') + status = Column(CHAR(1), nullable=True, server_default='0', comment='状态(0正常 1停用)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/job_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/job_do.py new file mode 100644 index 0000000..d6850f9 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/job_do.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, String + +from config.database import Base + + +class SysJob(Base): + """ + 定时任务调度表 + """ + + __tablename__ = 'sys_job' + __table_args__ = {'comment': '定时任务调度表'} + + job_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='任务ID') + job_name = Column(String(64), primary_key=True, nullable=False, server_default="''", comment='任务名称') + job_group = Column(String(64), primary_key=True, nullable=False, server_default='default', comment='任务组名') + job_executor = Column(String(64), nullable=True, server_default='default', comment='任务执行器') + invoke_target = Column(String(500), nullable=False, comment='调用目标字符串') + job_args = Column(String(255), nullable=True, server_default="''", comment='位置参数') + job_kwargs = Column(String(255), nullable=True, server_default="''", comment='关键字参数') + cron_expression = Column(String(255), nullable=True, server_default="''", comment='cron执行表达式') + misfire_policy = Column( + String(20), + nullable=True, + server_default='3', + comment='计划执行错误策略(1立即执行 2执行一次 3放弃执行)', + ) + concurrent = Column(CHAR(1), nullable=True, server_default='1', comment='是否并发执行(0允许 1禁止)') + status = Column(CHAR(1), nullable=True, server_default='0', comment='状态(0正常 1暂停)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column(String(500), nullable=True, server_default="''", comment='备注信息') + + +class SysJobLog(Base): + """ + 定时任务调度日志表 + """ + + __tablename__ = 'sys_job_log' + __table_args__ = {'comment': '定时任务调度日志表'} + + job_log_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='任务日志ID') + job_name = Column(String(64), nullable=False, comment='任务名称') + job_group = Column(String(64), nullable=False, comment='任务组名') + job_executor = Column(String(64), nullable=False, comment='任务执行器') + invoke_target = Column(String(500), nullable=False, comment='调用目标字符串') + job_args = Column(String(255), nullable=True, server_default="''", comment='位置参数') + job_kwargs = Column(String(255), nullable=True, server_default="''", comment='关键字参数') + job_trigger = Column(String(255), nullable=True, server_default="''", comment='任务触发器') + job_message = Column(String(500), nullable=True, comment='日志信息') + status = Column(CHAR(1), nullable=True, server_default='0', comment='执行状态(0正常 1失败)') + exception_info = Column(String(2000), nullable=True, server_default="''", comment='异常信息') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/log_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/log_do.py new file mode 100644 index 0000000..298921c --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/log_do.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Index, Integer, String + +from config.database import Base + + +class SysLogininfor(Base): + """ + 系统访问记录 + """ + + __tablename__ = 'sys_logininfor' + __table_args__ = {'comment': '系统访问记录'} + + info_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='访问ID') + user_name = Column(String(50), nullable=True, server_default="''", comment='用户账号') + ipaddr = Column(String(128), nullable=True, server_default="''", comment='登录IP地址') + login_location = Column(String(255), nullable=True, server_default="''", comment='登录地点') + browser = Column(String(50), nullable=True, server_default="''", comment='浏览器类型') + os = Column(String(50), nullable=True, server_default="''", comment='操作系统') + status = Column(CHAR(1), nullable=True, server_default='0', comment='登录状态(0成功 1失败)') + msg = Column(String(255), nullable=True, server_default="''", comment='提示消息') + login_time = Column(DateTime, nullable=True, default=datetime.now(), comment='访问时间') + + idx_sys_logininfor_s = Index('idx_sys_logininfor_s', status) + idx_sys_logininfor_lt = Index('idx_sys_logininfor_lt', login_time) + + +class SysOperLog(Base): + """ + 操作日志记录 + """ + + __tablename__ = 'sys_oper_log' + __table_args__ = {'comment': '操作日志记录'} + + oper_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='日志主键') + title = Column(String(50), nullable=True, server_default="''", comment='模块标题') + business_type = Column(Integer, nullable=True, server_default='0', comment='业务类型(0其它 1新增 2修改 3删除)') + method = Column(String(100), nullable=True, server_default="''", comment='方法名称') + request_method = Column(String(10), nullable=True, server_default="''", comment='请求方式') + operator_type = Column( + Integer, nullable=True, server_default='0', comment='操作类别(0其它 1后台用户 2手机端用户)' + ) + oper_name = Column(String(50), nullable=True, server_default="''", comment='操作人员') + dept_name = Column(String(50), nullable=True, server_default="''", comment='部门名称') + oper_url = Column(String(255), nullable=True, server_default="''", comment='请求URL') + oper_ip = Column(String(128), nullable=True, server_default="''", comment='主机地址') + oper_location = Column(String(255), nullable=True, server_default="''", comment='操作地点') + oper_param = Column(String(2000), nullable=True, server_default="''", comment='请求参数') + json_result = Column(String(2000), nullable=True, server_default="''", comment='返回参数') + status = Column(Integer, nullable=True, server_default='0', comment='操作状态(0正常 1异常)') + error_msg = Column(String(2000), nullable=True, server_default="''", comment='错误消息') + oper_time = Column(DateTime, nullable=True, default=datetime.now(), comment='操作时间') + cost_time = Column(BigInteger, nullable=True, server_default='0', comment='消耗时间') + + idx_sys_oper_log_bt = Index('idx_sys_oper_log_bt', business_type) + idx_sys_oper_log_s = Index('idx_sys_oper_log_s', status) + idx_sys_oper_log_ot = Index('idx_sys_oper_log_ot', oper_time) diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/menu_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/menu_do.py new file mode 100644 index 0000000..b7aa4c7 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/menu_do.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysMenu(Base): + """ + 菜单权限表 + """ + + __tablename__ = 'sys_menu' + __table_args__ = {'comment': '菜单权限表'} + + menu_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='菜单ID') + menu_name = Column(String(50), nullable=False, comment='菜单名称') + parent_id = Column(BigInteger, nullable=True, server_default='0', comment='父菜单ID') + order_num = Column(Integer, server_default='0', comment='显示顺序') + path = Column(String(200), nullable=True, server_default="''", comment='路由地址') + component = Column( + String(255), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='组件路径', + ) + query = Column( + String(255), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='路由参数', + ) + route_name = Column(String(50), nullable=True, server_default="''", comment='路由名称') + is_frame = Column(Integer, nullable=True, server_default='1', comment='是否为外链(0是 1否)') + is_cache = Column(Integer, nullable=True, server_default='0', comment='是否缓存(0缓存 1不缓存)') + menu_type = Column(CHAR(1), nullable=True, server_default="''", comment='菜单类型(M目录 C菜单 F按钮)') + visible = Column(CHAR(1), nullable=True, server_default='0', comment='菜单状态(0显示 1隐藏)') + status = Column(CHAR(1), nullable=True, server_default='0', comment='菜单状态(0正常 1停用)') + perms = Column( + String(100), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='权限标识', + ) + icon = Column(String(100), nullable=True, server_default='#', comment='菜单图标') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column(String(500), nullable=True, server_default="''", comment='备注') diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/notice_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/notice_do.py new file mode 100644 index 0000000..468f599 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/notice_do.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import CHAR, Column, DateTime, Integer, LargeBinary, String +from sqlalchemy.dialects import mysql + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysNotice(Base): + """ + 通知公告表 + """ + + __tablename__ = 'sys_notice' + __table_args__ = {'comment': '通知公告表'} + + notice_id = Column(Integer, primary_key=True, nullable=False, autoincrement=True, comment='公告ID') + notice_title = Column(String(50), nullable=False, comment='公告标题') + notice_type = Column(CHAR(1), nullable=False, comment='公告类型(1通知 2公告)') + notice_content = Column( + mysql.LONGBLOB if DataBaseConfig.db_type == 'mysql' else LargeBinary, + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type, False), + comment='公告内容', + ) + status = Column(CHAR(1), nullable=True, server_default='0', comment='公告状态(0正常 1关闭)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, comment='创建时间', default=datetime.now()) + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, comment='更新时间', default=datetime.now()) + remark = Column( + String(255), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/post_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/post_do.py new file mode 100644 index 0000000..5b18596 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/post_do.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysPost(Base): + """ + 岗位信息表 + """ + + __tablename__ = 'sys_post' + __table_args__ = {'comment': '岗位信息表'} + + post_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='岗位ID') + post_code = Column(String(64), nullable=False, comment='岗位编码') + post_name = Column(String(50), nullable=False, comment='岗位名称') + post_sort = Column(Integer, nullable=False, comment='显示顺序') + status = Column(CHAR(1), nullable=False, comment='状态(0正常 1停用)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/role_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/role_do.py new file mode 100644 index 0000000..39b0866 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/role_do.py @@ -0,0 +1,76 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, SmallInteger, String +from sqlalchemy.dialects import mysql + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysRole(Base): + """ + 角色信息表 + """ + + __tablename__ = 'sys_role' + __table_args__ = {'comment': '角色信息表'} + + role_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='角色ID') + role_name = Column(String(30), nullable=False, comment='角色名称') + role_key = Column(String(100), nullable=False, comment='角色权限字符串') + role_sort = Column(Integer, nullable=False, comment='显示顺序') + data_scope = Column( + CHAR(1), + nullable=True, + server_default='1', + comment='数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)', + ) + menu_check_strictly = Column( + mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger, + nullable=True, + server_default='1', + comment='菜单树选择项是否关联显示', + ) + dept_check_strictly = Column( + mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger, + nullable=True, + server_default='1', + comment='部门树选择项是否关联显示', + ) + status = Column(CHAR(1), nullable=False, comment='角色状态(0正常 1停用)') + del_flag = Column(CHAR(1), nullable=True, server_default='0', comment='删除标志(0代表存在 2代表删除)') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) + + +class SysRoleDept(Base): + """ + 角色和部门关联表 + """ + + __tablename__ = 'sys_role_dept' + __table_args__ = {'comment': '角色和部门关联表'} + + role_id = Column(BigInteger, primary_key=True, nullable=False, comment='角色ID') + dept_id = Column(BigInteger, primary_key=True, nullable=False, comment='部门ID') + + +class SysRoleMenu(Base): + """ + 角色和菜单关联表 + """ + + __tablename__ = 'sys_role_menu' + __table_args__ = {'comment': '角色和菜单关联表'} + + role_id = Column(BigInteger, primary_key=True, nullable=False, comment='角色ID') + menu_id = Column(BigInteger, primary_key=True, nullable=False, comment='菜单ID') diff --git a/shierfeng-fastapi-backend/module_admin/entity/do/user_do.py b/shierfeng-fastapi-backend/module_admin/entity/do/user_do.py new file mode 100644 index 0000000..4d1cfe1 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/do/user_do.py @@ -0,0 +1,71 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysUser(Base): + """ + 用户信息表 + """ + + __tablename__ = 'sys_user' + __table_args__ = {'comment': '用户信息表'} + + user_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='用户ID') + dept_id = Column( + BigInteger, + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type, False), + comment='部门ID', + ) + user_name = Column(String(30), nullable=False, comment='用户账号') + nick_name = Column(String(30), nullable=False, comment='用户昵称') + user_type = Column(String(2), nullable=True, server_default='00', comment='用户类型(00系统用户)') + email = Column(String(50), nullable=True, server_default="''", comment='用户邮箱') + phonenumber = Column(String(11), nullable=True, server_default="''", comment='手机号码') + sex = Column(CHAR(1), nullable=True, server_default='0', comment='用户性别(0男 1女 2未知)') + avatar = Column(String(100), nullable=True, server_default="''", comment='头像地址') + password = Column(String(100), nullable=True, server_default="''", comment='密码') + status = Column(CHAR(1), nullable=True, server_default='0', comment='帐号状态(0正常 1停用)') + del_flag = Column(CHAR(1), nullable=True, server_default='0', comment='删除标志(0代表存在 2代表删除)') + login_ip = Column(String(128), nullable=True, server_default="''", comment='最后登录IP') + login_date = Column(DateTime, nullable=True, comment='最后登录时间') + pwd_update_date = Column(DateTime, nullable=True, comment='密码最后更新时间') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, comment='创建时间', default=datetime.now()) + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, comment='更新时间', default=datetime.now()) + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) + + +class SysUserRole(Base): + """ + 用户和角色关联表 + """ + + __tablename__ = 'sys_user_role' + __table_args__ = {'comment': '用户和角色关联表'} + + user_id = Column(BigInteger, primary_key=True, nullable=False, comment='用户ID') + role_id = Column(BigInteger, primary_key=True, nullable=False, comment='角色ID') + + +class SysUserPost(Base): + """ + 用户与岗位关联表 + """ + + __tablename__ = 'sys_user_post' + __table_args__ = {'comment': '用户与岗位关联表'} + + user_id = Column(BigInteger, primary_key=True, nullable=False, comment='用户ID') + post_id = Column(BigInteger, primary_key=True, nullable=False, comment='岗位ID') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/cache_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/cache_vo.py new file mode 100644 index 0000000..079f93c --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/cache_vo.py @@ -0,0 +1,29 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class CacheMonitorModel(BaseModel): + """ + 缓存监控信息对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + command_stats: list | None = Field(default=[], description='命令统计') + db_size: int | None = Field(default=None, description='Key数量') + info: dict | None = Field(default={}, description='Redis信息') + + +class CacheInfoModel(BaseModel): + """ + 缓存监控对象对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + cache_key: str | None = Field(default=None, description='缓存键名') + cache_name: str | None = Field(default=None, description='缓存名称') + cache_value: Any | None = Field(default=None, description='缓存内容') + remark: str | None = Field(default=None, description='备注') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/common_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/common_vo.py new file mode 100644 index 0000000..8538055 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/common_vo.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class UploadResponseModel(BaseModel): + """ + 上传响应模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + file_name: str | None = Field(default=None, description='新文件映射路径') + new_file_name: str | None = Field(default=None, description='新文件名称') + original_filename: str | None = Field(default=None, description='原文件名称') + url: str | None = Field(default=None, description='新文件url') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/config_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/config_vo.py new file mode 100644 index 0000000..e6a62ae --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/config_vo.py @@ -0,0 +1,73 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size + + +class ConfigModel(BaseModel): + """ + 参数配置表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + config_id: int | None = Field(default=None, description='参数主键') + config_name: str | None = Field(default=None, description='参数名称') + config_key: str | None = Field(default=None, description='参数键名') + config_value: str | None = Field(default=None, description='参数键值') + config_type: Literal['Y', 'N'] | None = Field(default=None, description='系统内置(Y是 N否)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='config_key', message='参数名称不能为空') + @Size(field_name='config_key', min_length=0, max_length=100, message='参数名称长度不能超过100个字符') + def get_config_key(self) -> str | None: + return self.config_key + + @NotBlank(field_name='config_name', message='参数键名不能为空') + @Size(field_name='config_name', min_length=0, max_length=100, message='参数键名长度不能超过100个字符') + def get_config_name(self) -> str | None: + return self.config_name + + @NotBlank(field_name='config_value', message='参数键值不能为空') + @Size(field_name='config_value', min_length=0, max_length=500, message='参数键值长度不能超过500个字符') + def get_config_value(self) -> str | None: + return self.config_value + + def validate_fields(self) -> None: + self.get_config_key() + self.get_config_name() + self.get_config_value() + + +class ConfigQueryModel(ConfigModel): + """ + 参数配置管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class ConfigPageQueryModel(ConfigQueryModel): + """ + 参数配置管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteConfigModel(BaseModel): + """ + 删除参数配置模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + config_ids: str = Field(description='需要删除的参数主键') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/dept_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/dept_vo.py new file mode 100644 index 0000000..3222270 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/dept_vo.py @@ -0,0 +1,87 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import Network, NotBlank, Size + + +class DeptModel(BaseModel): + """ + 部门表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + dept_id: int | None = Field(default=None, description='部门id') + parent_id: int | None = Field(default=None, description='父部门id') + ancestors: str | None = Field(default=None, description='祖级列表') + dept_name: str | None = Field(default=None, description='部门名称') + order_num: int | None = Field(default=None, description='显示顺序') + leader: str | None = Field(default=None, description='负责人') + phone: str | None = Field(default=None, description='联系电话') + email: str | None = Field(default=None, description='邮箱') + status: Literal['0', '1'] | None = Field(default=None, description='部门状态(0正常 1停用)') + del_flag: Literal['0', '2'] | None = Field(default=None, description='删除标志(0代表存在 2代表删除)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + + @NotBlank(field_name='dept_name', message='部门名称不能为空') + @Size(field_name='dept_name', min_length=0, max_length=30, message='部门名称长度不能超过30个字符') + def get_dept_name(self) -> str | None: + return self.dept_name + + @NotBlank(field_name='order_num', message='显示顺序不能为空') + def get_order_num(self) -> int | None: + return self.order_num + + @Size(field_name='phone', min_length=0, max_length=11, message='联系电话长度不能超过11个字符') + def get_phone(self) -> str | None: + return self.phone + + @Network(field_name='email', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='email', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self) -> str | None: + return self.email + + def validate_fields(self) -> None: + self.get_dept_name() + self.get_order_num() + self.get_phone() + self.get_email() + + +class DeptQueryModel(DeptModel): + """ + 部门管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class DeptTreeModel(BaseModel): + """ + 部门树模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + id: int = Field(description='部门id') + label: str = Field(description='部门名称') + parent_id: int = Field(description='父部门id') + children: list['DeptTreeModel'] | None = Field(default=None, description='子部门树') + + +class DeleteDeptModel(BaseModel): + """ + 删除部门模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + dept_ids: str = Field(default=None, description='需要删除的部门id') + update_by: str | None = Field(default=None, description='更新者') + update_time: str | None = Field(default=None, description='更新时间') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/dict_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/dict_vo.py new file mode 100644 index 0000000..787e00f --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/dict_vo.py @@ -0,0 +1,147 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Pattern, Size + + +class DictTypeModel(BaseModel): + """ + 字典类型表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + dict_id: int | None = Field(default=None, description='字典主键') + dict_name: str | None = Field(default=None, description='字典名称') + dict_type: str | None = Field(default=None, description='字典类型') + status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='dict_name', message='字典名称不能为空') + @Size(field_name='dict_name', min_length=0, max_length=100, message='字典类型名称长度不能超过100个字符') + def get_dict_name(self) -> str | None: + return self.dict_name + + @NotBlank(field_name='dict_type', message='字典类型不能为空') + @Size(field_name='dict_type', min_length=0, max_length=100, message='字典类型类型长度不能超过100个字符') + @Pattern( + field_name='dict_type', + regexp='^[a-z][a-z0-9_]*$', + message='字典类型必须以字母开头,且只能为(小写字母,数字,下滑线)', + ) + def get_dict_type(self) -> str | None: + return self.dict_type + + def validate_fields(self) -> None: + self.get_dict_name() + self.get_dict_type() + + +class DictDataModel(BaseModel): + """ + 字典数据表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + dict_code: int | None = Field(default=None, description='字典编码') + dict_sort: int | None = Field(default=None, description='字典排序') + dict_label: str | None = Field(default=None, description='字典标签') + dict_value: str | None = Field(default=None, description='字典键值') + dict_type: str | None = Field(default=None, description='字典类型') + css_class: str | None = Field(default=None, description='样式属性(其他样式扩展)') + list_class: str | None = Field(default=None, description='表格回显样式') + is_default: Literal['Y', 'N'] | None = Field(default=None, description='是否默认(Y是 N否)') + status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='dict_label', message='字典标签不能为空') + @Size(field_name='dict_label', min_length=0, max_length=100, message='字典标签长度不能超过100个字符') + def get_dict_label(self) -> str | None: + return self.dict_label + + @NotBlank(field_name='dict_value', message='字典键值不能为空') + @Size(field_name='dict_value', min_length=0, max_length=100, message='字典键值长度不能超过100个字符') + def get_dict_value(self) -> str | None: + return self.dict_value + + @NotBlank(field_name='dict_type', message='字典类型不能为空') + @Size(field_name='dict_type', min_length=0, max_length=100, message='字典类型长度不能超过100个字符') + def get_dict_type(self) -> str | None: + return self.dict_type + + @Size(field_name='css_class', min_length=0, max_length=100, message='样式属性长度不能超过100个字符') + def get_css_class(self) -> str | None: + return self.css_class + + def validate_fields(self) -> None: + self.get_dict_label() + self.get_dict_value() + self.get_dict_type() + self.get_css_class() + + +class DictTypeQueryModel(DictTypeModel): + """ + 字典类型管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class DictTypePageQueryModel(DictTypeQueryModel): + """ + 字典类型管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteDictTypeModel(BaseModel): + """ + 删除字典类型模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + dict_ids: str = Field(description='需要删除的字典主键') + + +class DictDataQueryModel(DictDataModel): + """ + 字典数据管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class DictDataPageQueryModel(DictDataQueryModel): + """ + 字典数据管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteDictDataModel(BaseModel): + """ + 删除字典数据模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + dict_codes: str = Field(description='需要删除的字典编码') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/job_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/job_vo.py new file mode 100644 index 0000000..84a541b --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/job_vo.py @@ -0,0 +1,132 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size + + +class JobModel(BaseModel): + """ + 定时任务调度表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + job_id: int | None = Field(default=None, description='任务ID') + job_name: str | None = Field(default=None, description='任务名称') + job_group: str | None = Field(default=None, description='任务组名') + job_executor: str | None = Field(default=None, description='任务执行器') + invoke_target: str | None = Field(default=None, description='调用目标字符串') + job_args: str | None = Field(default=None, description='位置参数') + job_kwargs: str | None = Field(default=None, description='关键字参数') + cron_expression: str | None = Field(default=None, description='cron执行表达式') + misfire_policy: Literal['1', '2', '3'] | None = Field( + default=None, description='计划执行错误策略(1立即执行 2执行一次 3放弃执行)' + ) + concurrent: Literal['0', '1'] | None = Field(default=None, description='是否并发执行(0允许 1禁止)') + status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1暂停)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注信息') + + @NotBlank(field_name='invoke_target', message='调用目标字符串不能为空') + @Size(field_name='invoke_target', min_length=0, max_length=500, message='调用目标字符串长度不能超过500个字符') + def get_invoke_target(self) -> str | None: + return self.invoke_target + + @NotBlank(field_name='cron_expression', message='Cron执行表达式不能为空') + @Size(field_name='cron_expression', min_length=0, max_length=255, message='Cron执行表达式不能超过255个字符') + def get_cron_expression(self) -> str | None: + return self.cron_expression + + def validate_fields(self) -> None: + self.get_invoke_target() + self.get_cron_expression() + + +class JobLogModel(BaseModel): + """ + 定时任务调度日志表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + job_log_id: int | None = Field(default=None, description='任务日志ID') + job_name: str | None = Field(default=None, description='任务名称') + job_group: str | None = Field(default=None, description='任务组名') + job_executor: str | None = Field(default=None, description='任务执行器') + invoke_target: str | None = Field(default=None, description='调用目标字符串') + job_args: str | None = Field(default=None, description='位置参数') + job_kwargs: str | None = Field(default=None, description='关键字参数') + job_trigger: str | None = Field(default=None, description='任务触发器') + job_message: str | None = Field(default=None, description='日志信息') + status: Literal['0', '1'] | None = Field(default=None, description='执行状态(0正常 1失败)') + exception_info: str | None = Field(default=None, description='异常信息') + create_time: datetime | None = Field(default=None, description='创建时间') + + +class JobQueryModel(JobModel): + """ + 定时任务管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class JobPageQueryModel(JobQueryModel): + """ + 定时任务管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class EditJobModel(JobModel): + """ + 编辑定时任务模型 + """ + + type: str | None = Field(default=None, description='操作类型') + + +class DeleteJobModel(BaseModel): + """ + 删除定时任务模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + job_ids: str = Field(description='需要删除的定时任务ID') + + +class JobLogQueryModel(JobLogModel): + """ + 定时任务日志不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class JobLogPageQueryModel(JobLogQueryModel): + """ + 定时任务日志管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteJobLogModel(BaseModel): + """ + 删除定时任务日志模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + job_log_ids: str = Field(description='需要删除的定时任务日志ID') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/log_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/log_vo.py new file mode 100644 index 0000000..b09b4de --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/log_vo.py @@ -0,0 +1,127 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class OperLogModel(BaseModel): + """ + 操作日志表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + oper_id: int | None = Field(default=None, description='日志主键') + title: str | None = Field(default=None, description='模块标题') + business_type: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] | None = ( + Field( + default=None, description='业务类型(0其它 1新增 2修改 3删除 4授权 5导出 6导入 7强退 8生成代码 9清空数据)' + ) + ) + method: str | None = Field(default=None, description='方法名称') + request_method: str | None = Field(default=None, description='请求方式') + operator_type: Literal[0, 1, 2] | None = Field(default=None, description='操作类别(0其它 1后台用户 2手机端用户)') + oper_name: str | None = Field(default=None, description='操作人员') + dept_name: str | None = Field(default=None, description='部门名称') + oper_url: str | None = Field(default=None, description='请求URL') + oper_ip: str | None = Field(default=None, description='主机地址') + oper_location: str | None = Field(default=None, description='操作地点') + oper_param: str | None = Field(default=None, description='请求参数') + json_result: str | None = Field(default=None, description='返回参数') + status: Literal[0, 1, '0', '1'] | None = Field(default=None, description='操作状态(0正常 1异常)') + error_msg: str | None = Field(default=None, description='错误消息') + oper_time: datetime | None = Field(default=None, description='操作时间') + cost_time: int | None = Field(default=None, description='消耗时间') + + +class LogininforModel(BaseModel): + """ + 登录日志表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + info_id: int | None = Field(default=None, description='访问ID') + user_name: str | None = Field(default=None, description='用户账号') + ipaddr: str | None = Field(default=None, description='登录IP地址') + login_location: str | None = Field(default=None, description='登录地点') + browser: str | None = Field(default=None, description='浏览器类型') + os: str | None = Field(default=None, description='操作系统') + status: Literal['0', '1'] | None = Field(default=None, description='登录状态(0成功 1失败)') + msg: str | None = Field(default=None, description='提示消息') + login_time: datetime | None = Field(default=None, description='访问时间') + + +class OperLogQueryModel(OperLogModel): + """ + 操作日志管理不分页查询模型 + """ + + order_by_column: str | None = Field(default=None, description='排序的字段名称') + is_asc: Literal['ascending', 'descending'] | None = Field( + default=None, description='排序方式(ascending升序 descending降序)' + ) + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class OperLogPageQueryModel(OperLogQueryModel): + """ + 操作日志管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteOperLogModel(BaseModel): + """ + 删除操作日志模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + oper_ids: str = Field(description='需要删除的日志主键') + + +class LoginLogQueryModel(LogininforModel): + """ + 登录日志管理不分页查询模型 + """ + + order_by_column: str | None = Field(default=None, description='排序的字段名称') + is_asc: Literal['ascending', 'descending'] | None = Field( + default=None, description='排序方式(ascending升序 descending降序)' + ) + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class LoginLogPageQueryModel(LoginLogQueryModel): + """ + 登录日志管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteLoginLogModel(BaseModel): + """ + 删除登录日志模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + info_ids: str = Field(description='需要删除的访问ID') + + +class UnlockUser(BaseModel): + """ + 解锁用户模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + user_name: str = Field(description='用户名称') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/login_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/login_vo.py new file mode 100644 index 0000000..50bbc77 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/login_vo.py @@ -0,0 +1,93 @@ +import re + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic.alias_generators import to_camel + +from exceptions.exception import ModelValidatorException +from module_admin.entity.vo.menu_vo import MenuModel + + +class UserLogin(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + user_name: str = Field(description='用户名称') + password: str = Field(description='用户密码') + code: str | None = Field(default=None, description='验证码') + uuid: str | None = Field(default=None, description='会话编号') + login_info: dict | None = Field(default=None, description='登录信息,前端无需传递') + captcha_enabled: bool | None = Field(default=None, description='是否启用验证码,前端无需传递') + + +class UserRegister(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + username: str = Field(description='用户名称') + password: str = Field(description='用户密码') + confirm_password: str = Field(description='用户二次确认密码') + code: str | None = Field(default=None, description='验证码') + uuid: str | None = Field(default=None, description='会话编号') + + @model_validator(mode='after') + def check_password(self) -> 'UserRegister': + pattern = r"""^[^<>"'|\\]+$""" + if self.password is None or re.match(pattern, self.password): + return self + raise ModelValidatorException(message='密码不能包含非法字符:< > " \' \\ |') + + +class Token(BaseModel): + access_token: str = Field(description='token信息') + token_type: str = Field(description='token类型') + + +class LoginToken(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + token: str = Field(description='token信息') + + +class CaptchaCode(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + captcha_enabled: bool = Field(description='是否启用验证码') + register_enabled: bool = Field(description='是否启用注册') + img: str = Field(description='验证码图片') + uuid: str = Field(description='会话编号') + + +class SmsCode(BaseModel): + is_success: bool | None = Field(default=None, description='操作是否成功') + sms_code: str = Field(description='短信验证码') + session_id: str = Field(description='会话编号') + message: str | None = Field(default=None, description='响应信息') + + +class MenuTreeModel(MenuModel): + children: list['MenuTreeModel'] | None | None = Field(default=None, description='子菜单') + + +class MetaModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + title: str | None = Field(default=None, description='设置路由在侧边栏和面包屑中展示的名字') + icon: str | None = Field(default=None, description='设置路由的图标') + no_cache: bool | None = Field(default=None, description='设置为true,则不会被 缓存') + link: str | None = Field(default=None, description='内链地址(http(s)://开头)') + + +class RouterModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + name: str | None = Field(default=None, description='路由名称') + path: str | None = Field(default=None, description='路由地址') + hidden: bool | None = Field(default=None, description='是否隐藏路由,当设置 true 的时候该路由不会再侧边栏出现') + redirect: str | None = Field( + default=None, description='重定向地址,当设置 noRedirect 的时候该路由在面包屑导航中不可被点击' + ) + component: str | None = Field(default=None, description='组件地址') + query: str | None = Field(default=None, description='路由参数:如 {"id": 1, "name": "ry"}') + always_show: bool | None = Field( + default=None, description='当你一个路由下面的children声明的路由大于1个时,自动会变成嵌套的模式--如组件页面' + ) + meta: MetaModel | None = Field(default=None, description='其他元素') + children: list['RouterModel'] | None | None = Field(default=None, description='子路由') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/menu_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/menu_vo.py new file mode 100644 index 0000000..a35be7d --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/menu_vo.py @@ -0,0 +1,100 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size + + +class MenuModel(BaseModel): + """ + 菜单表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + menu_id: int | None = Field(default=None, description='菜单ID') + menu_name: str | None = Field(default=None, description='菜单名称') + parent_id: int | None = Field(default=None, description='父菜单ID') + order_num: int | None = Field(default=None, description='显示顺序') + path: str | None = Field(default=None, description='路由地址') + component: str | None = Field(default=None, description='组件路径') + query: str | None = Field(default=None, description='路由参数') + route_name: str | None = Field(default=None, description='路由名称') + is_frame: Literal[0, 1] | None = Field(default=None, description='是否为外链(0是 1否)') + is_cache: Literal[0, 1] | None = Field(default=None, description='是否缓存(0缓存 1不缓存)') + menu_type: Literal['M', 'C', 'F'] | None = Field(default=None, description='菜单类型(M目录 C菜单 F按钮)') + visible: Literal['0', '1'] | None = Field(default=None, description='菜单状态(0显示 1隐藏)') + status: Literal['0', '1'] | None = Field(default=None, description='菜单状态(0正常 1停用)') + perms: str | None = Field(default=None, description='权限标识') + icon: str | None = Field(default=None, description='菜单图标') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='menu_name', message='菜单名称不能为空') + @Size(field_name='menu_name', min_length=0, max_length=50, message='菜单名称长度不能超过50个字符') + def get_menu_name(self) -> str | None: + return self.menu_name + + @NotBlank(field_name='order_num', message='显示顺序不能为空') + def get_order_num(self) -> int | None: + return self.order_num + + @Size(field_name='path', min_length=0, max_length=200, message='路由地址长度不能超过200个字符') + def get_path(self) -> str | None: + return self.path + + @Size(field_name='component', min_length=0, max_length=255, message='组件路径长度不能超过255个字符') + def get_component(self) -> str | None: + return self.component + + @NotBlank(field_name='menu_type', message='菜单类型不能为空') + def get_menu_type(self) -> Literal['M', 'C', 'F'] | None: + return self.menu_type + + @Size(field_name='perms', min_length=0, max_length=100, message='权限标识长度不能超过100个字符') + def get_perms(self) -> str | None: + return self.perms + + def validate_fields(self) -> None: + self.get_menu_name() + self.get_order_num() + self.get_path() + self.get_component() + self.get_menu_type() + self.get_perms() + + +class MenuQueryModel(MenuModel): + """ + 菜单管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class MenuTreeModel(BaseModel): + """ + 菜单树模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + id: int = Field(description='菜单id') + label: str = Field(description='菜单名称') + parent_id: int = Field(description='父菜单id') + children: list['MenuTreeModel'] | None = Field(default=None, description='子菜单树') + + +class DeleteMenuModel(BaseModel): + """ + 删除菜单模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + menu_ids: str = Field(description='需要删除的菜单ID') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/notice_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/notice_vo.py new file mode 100644 index 0000000..40d8655 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/notice_vo.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size, Xss + + +class NoticeModel(BaseModel): + """ + 通知公告表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + notice_id: int | None = Field(default=None, description='公告ID') + notice_title: str | None = Field(default=None, description='公告标题') + notice_type: Literal['1', '2'] | None = Field(default=None, description='公告类型(1通知 2公告)') + notice_content: bytes | None = Field(default=None, description='公告内容') + status: Literal['0', '1'] | None = Field(default=None, description='公告状态(0正常 1关闭)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @Xss(field_name='notice_title', message='公告标题不能包含脚本字符') + @NotBlank(field_name='notice_title', message='公告标题不能为空') + @Size(field_name='notice_title', min_length=0, max_length=50, message='公告标题不能超过50个字符') + def get_notice_title(self) -> str | None: + return self.notice_title + + def validate_fields(self) -> None: + self.get_notice_title() + + +class NoticeQueryModel(NoticeModel): + """ + 通知公告管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class NoticePageQueryModel(NoticeQueryModel): + """ + 通知公告管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteNoticeModel(BaseModel): + """ + 删除通知公告模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + notice_ids: str = Field(description='需要删除的公告ID') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/online_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/online_vo.py new file mode 100644 index 0000000..88f5b95 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/online_vo.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class OnlineModel(BaseModel): + """ + 在线用户对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + token_id: str | None = Field(default=None, description='会话编号') + user_name: str | None = Field(default=None, description='登录名称') + dept_name: str | None = Field(default=None, description='所属部门') + ipaddr: str | None = Field(default=None, description='主机') + login_location: str | None = Field(default=None, description='登录地点') + browser: str | None = Field(default=None, description='浏览器类型') + os: str | None = Field(default=None, description='操作系统') + login_time: datetime | None = Field(default=None, description='登录时间') + + +class OnlineQueryModel(OnlineModel): + """ + 岗位管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class OnlinePageResponseModel(BaseModel): + """ + 在线用户分页响应模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + rows: list[OnlineModel] = Field(description='在线用户记录列表') + total: int = Field(description='总记录数') + + +class DeleteOnlineModel(BaseModel): + """ + 强退在线用户模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + token_ids: str = Field(description='需要强退的会话编号') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/post_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/post_vo.py new file mode 100644 index 0000000..f3826d4 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/post_vo.py @@ -0,0 +1,72 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size + + +class PostModel(BaseModel): + """ + 岗位信息表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + post_id: int | None = Field(default=None, description='岗位ID') + post_code: str | None = Field(default=None, description='岗位编码') + post_name: str | None = Field(default=None, description='岗位名称') + post_sort: int | None = Field(default=None, description='显示顺序') + status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='post_code', message='岗位编码不能为空') + @Size(field_name='post_code', min_length=0, max_length=64, message='岗位编码长度不能超过64个字符') + def get_post_code(self) -> str | None: + return self.post_code + + @NotBlank(field_name='post_name', message='岗位名称不能为空') + @Size(field_name='post_name', min_length=0, max_length=50, message='岗位名称长度不能超过50个字符') + def get_post_name(self) -> str | None: + return self.post_name + + @NotBlank(field_name='post_sort', message='显示顺序不能为空') + def get_post_sort(self) -> int | None: + return self.post_sort + + def validate_fields(self) -> None: + self.get_post_code() + self.get_post_name() + self.get_post_sort() + + +class PostQueryModel(PostModel): + """ + 岗位管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class PostPageQueryModel(PostQueryModel): + """ + 岗位管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeletePostModel(BaseModel): + """ + 删除岗位模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + post_ids: str = Field(description='需要删除的岗位ID') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/role_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/role_vo.py new file mode 100644 index 0000000..aff9c38 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/role_vo.py @@ -0,0 +1,157 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size + + +class RoleModel(BaseModel): + """ + 角色表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + role_id: int | None = Field(default=None, description='角色ID') + role_name: str | None = Field(default=None, description='角色名称') + role_key: str | None = Field(default=None, description='角色权限字符串') + role_sort: int | None = Field(default=None, description='显示顺序') + data_scope: Literal['1', '2', '3', '4', '5'] | None = Field( + default=None, + description='数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限 5:仅本人数据权限)', + ) + menu_check_strictly: int | bool | None = Field(default=None, description='菜单树选择项是否关联显示') + dept_check_strictly: int | bool | None = Field(default=None, description='部门树选择项是否关联显示') + status: Literal['0', '1'] | None = Field(default=None, description='角色状态(0正常 1停用)') + del_flag: Literal['0', '2'] | None = Field(default=None, description='删除标志(0代表存在 2代表删除)') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + admin: bool | None = Field(default=False, description='是否为admin') + + @field_validator('menu_check_strictly', 'dept_check_strictly') + @classmethod + def check_filed_mapping(cls, v: int | bool) -> int | bool: + if v == 1: + v = True + elif v == 0: + v = False + elif v is True: + v = 1 + elif v is False: + v = 0 + return v + + @model_validator(mode='after') + def check_admin(self) -> 'RoleModel': + if self.role_id == 1: + self.admin = True + else: + self.admin = False + return self + + @NotBlank(field_name='role_name', message='角色名称不能为空') + @Size(field_name='role_name', min_length=0, max_length=30, message='角色名称长度不能超过30个字符') + def get_role_name(self) -> str | None: + return self.role_name + + @NotBlank(field_name='role_key', message='权限字符不能为空') + @Size(field_name='role_key', min_length=0, max_length=100, message='权限字符长度不能超过100个字符') + def get_role_key(self) -> str | None: + return self.role_key + + @NotBlank(field_name='role_sort', message='显示顺序不能为空') + def get_role_sort(self) -> int | None: + return self.role_sort + + def validate_fields(self) -> None: + self.get_role_name() + self.get_role_key() + self.get_role_sort() + + +class RoleMenuModel(BaseModel): + """ + 角色和菜单关联表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + role_id: int | None = Field(default=None, description='角色ID') + menu_id: int | None = Field(default=None, description='菜单ID') + + +class RoleDeptModel(BaseModel): + """ + 角色和部门关联表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + role_id: int | None = Field(default=None, description='角色ID') + dept_id: int | None = Field(default=None, description='部门ID') + + +class RoleQueryModel(RoleModel): + """ + 角色管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class RolePageQueryModel(RoleQueryModel): + """ + 角色管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class RoleMenuQueryModel(BaseModel): + """ + 角色菜单查询模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + menus: list = Field(default=[], description='菜单信息') + checked_keys: list[int] = Field(default=[], description='已选择的菜单ID信息') + + +class RoleDeptQueryModel(BaseModel): + """ + 角色部门查询模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + depts: list = Field(default=[], description='部门信息') + checked_keys: list[int] = Field(default=[], description='已选择的部门ID信息') + + +class AddRoleModel(RoleModel): + """ + 新增角色模型 + """ + + dept_ids: list = Field(default=[], description='部门ID信息') + menu_ids: list = Field(default=[], description='菜单ID信息') + type: str | None = Field(default=None, description='操作类型') + + +class DeleteRoleModel(BaseModel): + """ + 删除角色模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + role_ids: str = Field(description='需要删除的角色ID') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/server_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/server_vo.py new file mode 100644 index 0000000..6d810ba --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/server_vo.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class CpuInfo(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + cpu_num: int | None = Field(default=None, description='核心数') + used: float | None = Field(default=None, description='CPU用户使用率') + sys: float | None = Field(default=None, description='CPU系统使用率') + free: float | None = Field(default=None, description='CPU当前空闲率') + + +class MemoryInfo(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + total: str | None = Field(default=None, description='内存总量') + used: str | None = Field(default=None, description='已用内存') + free: str | None = Field(default=None, description='剩余内存') + usage: float | None = Field(default=None, description='使用率') + + +class SysInfo(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + computer_ip: str | None = Field(default=None, description='服务器IP') + computer_name: str | None = Field(default=None, description='服务器名称') + os_arch: str | None = Field(default=None, description='系统架构') + os_name: str | None = Field(default=None, description='操作系统') + user_dir: str | None = Field(default=None, description='项目路径') + + +class PyInfo(MemoryInfo): + model_config = ConfigDict(alias_generator=to_camel) + + name: str | None = Field(default=None, description='Python名称') + version: str | None = Field(default=None, description='Python版本') + start_time: str | None = Field(default=None, description='启动时间') + run_time: str | None = Field(default=None, description='运行时长') + home: str | None = Field(default=None, description='安装路径') + + +class SysFiles(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + dir_name: str | None = Field(default=None, description='盘符路径') + sys_type_name: str | None = Field(default=None, description='盘符类型') + type_name: str | None = Field(default=None, description='文件类型') + total: str | None = Field(default=None, description='总大小') + used: str | None = Field(default=None, description='已经使用量') + free: str | None = Field(default=None, description='剩余大小') + usage: str | None = Field(default=None, description='资源的使用率') + + +class ServerMonitorModel(BaseModel): + """ + 服务监控对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + cpu: CpuInfo | None = Field(description='CPU相关信息') + py: PyInfo | None = Field(description='Python相关信息') + mem: MemoryInfo | None = Field(description='內存相关信息') + sys: SysInfo | None = Field(description='服务器相关信息') + sys_files: list[SysFiles] | None = Field(description='磁盘相关信息') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py new file mode 100644 index 0000000..d7511fd --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class TransportCryptoFrontendConfigModel(BaseModel): + """ + 传输层加解密前端运行配置模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + transport_crypto_enabled: bool = Field(description='后端是否启用传输层加解密') + transport_crypto_mode: str = Field(description='当前传输层加解密模式') + transport_crypto_active: bool = Field(description='前端当前是否应启用传输层加解密') + envelope_version: str = Field(description='当前传输层加密信封协议版本') + public_key_url: str = Field(description='传输层公钥接口路径') + request_envelope_algorithm: str = Field(description='前端请求信封算法标识') + response_envelope_algorithm: str = Field(description='前端响应信封算法标识') + enabled_paths: list[str] = Field(description='启用传输层加解密的路径列表') + required_paths: list[str] = Field(description='强制要求加密传输的路径列表') + exclude_paths: list[str] = Field(description='排除传输层加解密的路径列表') + max_encrypted_get_url_length: int = Field(description='前端执行加密GET/DELETE请求时允许的最大URL长度') + config_expire_at: int = Field(description='前端配置建议刷新时间戳') + + +class TransportCryptoPublicKeyModel(BaseModel): + """ + 传输层加解密公钥下发模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + kid: str = Field(description='当前启用的密钥版本标识') + envelope_version: str = Field(description='当前传输层加密信封协议版本') + alg: str = Field(description='当前传输层加密算法标识') + public_key: str = Field(description='当前可用的传输层公钥') + supported_kids: list[str] = Field(description='当前支持解密的密钥版本列表') + expire_at: int = Field(description='当前公钥建议刷新时间戳') + + +class TransportCryptoKidStatModel(BaseModel): + """ + 传输层加解密按密钥版本统计模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + kid: str | None = Field(default=None, description='密钥版本标识') + encrypted_requests: int | None = Field(default=0, description='加密请求次数') + decrypt_success: int | None = Field(default=0, description='请求解密成功次数') + decrypt_failure: int | None = Field(default=0, description='请求解密失败次数') + encrypted_responses: int | None = Field(default=0, description='加密响应次数') + + +class TransportCryptoFailureRecordModel(BaseModel): + """ + 传输层加解密最近失败记录模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + time: datetime | None = Field(default=None, description='失败时间') + method: str | None = Field(default=None, description='请求方法') + path: str | None = Field(default=None, description='请求路径') + reason: str | None = Field(default=None, description='失败原因分类') + kid: str | None = Field(default=None, description='密钥版本标识') + + +class TransportCryptoMonitorModel(BaseModel): + """ + 传输层加解密监控模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + monitor_scope: str | None = Field(default=None, description='监控统计范围,默认基于Redis聚合') + started_at: datetime | None = Field(default=None, description='当前监控统计起始时间') + app_env: str | None = Field(default=None, description='当前应用环境') + transport_crypto_enabled: bool | None = Field(default=None, description='是否启用传输层加解密') + transport_crypto_mode: str | None = Field(default=None, description='当前传输层加解密模式') + current_kid: str | None = Field(default=None, description='当前启用的密钥版本') + supported_kids: list[str] | None = Field(default=[], description='当前支持的密钥版本列表') + enabled_paths: list[str] | None = Field(default=[], description='启用传输层加解密的路径列表') + required_paths: list[str] | None = Field(default=[], description='强制要求加密传输的路径列表') + exclude_paths: list[str] | None = Field(default=[], description='排除传输层加解密的路径列表') + requests_total: int | None = Field(default=0, description='命中传输层加解密规则的请求总数') + plain_requests_total: int | None = Field(default=0, description='明文请求总数') + encrypted_requests_total: int | None = Field(default=0, description='加密请求总数') + required_rejected_total: int | None = Field(default=0, description='强制加密接口被拒绝的次数') + decrypt_success_total: int | None = Field(default=0, description='请求解密成功次数') + decrypt_failure_total: int | None = Field(default=0, description='请求解密失败次数') + plain_responses_total: int | None = Field(default=0, description='明文响应次数') + encrypted_responses_total: int | None = Field(default=0, description='加密响应次数') + encrypted_error_responses_total: int | None = Field(default=0, description='加密错误响应次数') + failure_reasons: dict[str, int] | None = Field(default={}, description='按失败原因归类的次数统计') + kid_stats: list[TransportCryptoKidStatModel] | None = Field(default=[], description='按密钥版本归类的统计信息') + recent_failures: list[TransportCryptoFailureRecordModel] | None = Field(default=[], description='最近失败事件列表') diff --git a/shierfeng-fastapi-backend/module_admin/entity/vo/user_vo.py b/shierfeng-fastapi-backend/module_admin/entity/vo/user_vo.py new file mode 100644 index 0000000..399b1db --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/entity/vo/user_vo.py @@ -0,0 +1,299 @@ +import re +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import Network, NotBlank, Size, Xss + +from exceptions.exception import ModelValidatorException +from module_admin.entity.vo.dept_vo import DeptModel +from module_admin.entity.vo.post_vo import PostModel +from module_admin.entity.vo.role_vo import RoleModel + + +class TokenData(BaseModel): + """ + token解析结果 + """ + + user_id: int | None = Field(default=None, description='用户ID') + + +class UserModel(BaseModel): + """ + 用户表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + user_id: int | None = Field(default=None, description='用户ID') + dept_id: int | None = Field(default=None, description='部门ID') + user_name: str | None = Field(default=None, description='用户账号') + nick_name: str | None = Field(default=None, description='用户昵称') + user_type: str | None = Field(default=None, description='用户类型(00系统用户)') + email: str | None = Field(default=None, description='用户邮箱') + phonenumber: str | None = Field(default=None, description='手机号码') + sex: Literal['0', '1', '2'] | None = Field(default=None, description='用户性别(0男 1女 2未知)') + avatar: str | None = Field(default=None, description='头像地址') + password: str | None = Field(default=None, description='密码') + status: Literal['0', '1'] | None = Field(default=None, description='帐号状态(0正常 1停用)') + del_flag: Literal['0', '2'] | None = Field(default=None, description='删除标志(0代表存在 2代表删除)') + login_ip: str | None = Field(default=None, description='最后登录IP') + login_date: datetime | None = Field(default=None, description='最后登录时间') + pwd_update_date: datetime | None = Field(default=None, description='密码最后更新时间') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + admin: bool | None = Field(default=False, description='是否为admin') + + @model_validator(mode='after') + def check_password(self) -> 'UserModel': + pattern = r"""^[^<>"'|\\]+$""" + if self.password is None or re.match(pattern, self.password): + return self + raise ModelValidatorException(message='密码不能包含非法字符:< > " \' \\ |') + + @model_validator(mode='after') + def check_admin(self) -> 'UserModel': + if self.user_id == 1: + self.admin = True + else: + self.admin = False + return self + + @Xss(field_name='user_name', message='用户账号不能包含脚本字符') + @NotBlank(field_name='user_name', message='用户账号不能为空') + @Size(field_name='user_name', min_length=0, max_length=30, message='用户账号长度不能超过30个字符') + def get_user_name(self) -> str | None: + return self.user_name + + @Xss(field_name='nick_name', message='用户昵称不能包含脚本字符') + @Size(field_name='nick_name', min_length=0, max_length=30, message='用户昵称长度不能超过30个字符') + def get_nick_name(self) -> str | None: + return self.nick_name + + @Network(field_name='email', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='email', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self) -> str | None: + return self.email + + @Size(field_name='phonenumber', min_length=0, max_length=11, message='手机号码长度不能超过11个字符') + def get_phonenumber(self) -> str | None: + return self.phonenumber + + def validate_fields(self) -> None: + self.get_user_name() + self.get_nick_name() + self.get_email() + self.get_phonenumber() + + +class UserRowModel(UserModel): + """ + 用户列表行数据模型 + """ + + dept: DeptModel | None = Field(default=None, description='部门信息') + + +class UserRoleModel(BaseModel): + """ + 用户和角色关联表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + user_id: int | None = Field(default=None, description='用户ID') + role_id: int | None = Field(default=None, description='角色ID') + + +class UserPostModel(BaseModel): + """ + 用户与岗位关联表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + user_id: int | None = Field(default=None, description='用户ID') + post_id: int | None = Field(default=None, description='岗位ID') + + +class UserInfoModel(UserModel): + post_ids: str | None | None = Field(default=None, description='岗位ID信息') + role_ids: str | None | None = Field(default=None, description='角色ID信息') + dept: DeptModel | None | None = Field(default=None, description='部门信息') + role: list[RoleModel | None] | None = Field(default=[], description='角色信息') + + +class CurrentUserModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + + permissions: list = Field(description='权限信息') + roles: list = Field(description='角色信息') + user: UserInfoModel | None = Field(description='用户信息') + is_default_modify_pwd: bool = Field(default=False, description='是否初始密码修改提醒') + is_password_expired: bool = Field(default=False, description='密码是否过期提醒') + + +class UserDetailModel(BaseModel): + """ + 获取用户详情信息响应模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + data: UserInfoModel | None | None = Field(default=None, description='用户信息') + post_ids: list | None = Field(default=None, description='岗位ID信息') + posts: list[PostModel | None] = Field(description='岗位信息') + role_ids: list | None = Field(default=None, description='角色ID信息') + roles: list[RoleModel | None] = Field(description='角色信息') + + +class UserProfileModel(BaseModel): + """ + 获取个人信息响应模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + data: UserInfoModel | None = Field(description='用户信息') + post_group: str | None = Field(description='岗位信息') + role_group: str | None = Field(description='角色信息') + + +class AvatarModel(BaseModel): + """ + 上传头像响应模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + img_url: str = Field(description='头像地址') + + +class UserQueryModel(UserModel): + """ + 用户管理不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class UserPageQueryModel(UserQueryModel): + """ + 用户管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class AddUserModel(UserModel): + """ + 新增用户模型 + """ + + role_ids: list | None = Field(default=[], description='角色ID信息') + post_ids: list | None = Field(default=[], description='岗位ID信息') + type: str | None = Field(default=None, description='操作类型') + + +class EditUserModel(AddUserModel): + """ + 编辑用户模型 + """ + + role: list | None = Field(default=[], description='角色信息') + + +class ResetPasswordModel(BaseModel): + """ + 重置密码模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + old_password: str | None = Field(default=None, description='旧密码') + new_password: str | None = Field(default=None, description='新密码') + + @model_validator(mode='after') + def check_new_password(self) -> 'ResetPasswordModel': + pattern = r"""^[^<>"'|\\]+$""" + if self.new_password is None or re.match(pattern, self.new_password): + return self + raise ModelValidatorException(message='密码不能包含非法字符:< > " \' \\ |') + + +class ResetUserModel(UserModel): + """ + 重置用户密码模型 + """ + + old_password: str | None = Field(default=None, description='旧密码') + sms_code: str | None = Field(default=None, description='验证码') + session_id: str | None = Field(default=None, description='会话id') + + +class DeleteUserModel(BaseModel): + """ + 删除用户模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + user_ids: str = Field(description='需要删除的用户ID') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + + +class UserRoleQueryModel(UserModel): + """ + 用户角色关联管理不分页查询模型 + """ + + role_id: int | None = Field(default=None, description='角色ID') + + +class UserRolePageQueryModel(UserRoleQueryModel): + """ + 用户角色关联管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class SelectedRoleModel(RoleModel): + """ + 是否选择角色模型 + """ + + flag: bool | None = Field(default=False, description='选择标识') + + +class UserRoleResponseModel(BaseModel): + """ + 用户角色关联管理列表返回模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + roles: list[SelectedRoleModel | None] = Field(default=[], description='角色信息') + user: UserInfoModel = Field(description='用户信息') + + +class CrudUserRoleModel(BaseModel): + """ + 新增、删除用户关联角色及角色关联用户模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + user_id: int | None = Field(default=None, description='用户ID') + user_ids: str | None = Field(default=None, description='用户ID信息') + role_id: int | None = Field(default=None, description='角色ID') + role_ids: str | None = Field(default=None, description='角色ID信息') diff --git a/shierfeng-fastapi-backend/module_admin/service/cache_service.py b/shierfeng-fastapi-backend/module_admin/service/cache_service.py new file mode 100644 index 0000000..410f6b8 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/cache_service.py @@ -0,0 +1,126 @@ +from fastapi import Request + +from common.enums import RedisInitKeyConfig +from common.vo import CrudResponseModel +from config.get_redis import RedisUtil +from module_admin.entity.vo.cache_vo import CacheInfoModel, CacheMonitorModel + + +class CacheService: + """ + 缓存监控模块服务层 + """ + + @classmethod + async def get_cache_monitor_statistical_info_services(cls, request: Request) -> CacheMonitorModel: + """ + 获取缓存监控信息service + + :param request: Request对象 + :return: 缓存监控信息 + """ + info = await request.app.state.redis.info() + db_size = await request.app.state.redis.dbsize() + command_stats_dict = await request.app.state.redis.info('commandstats') + command_stats = [ + {'name': key.split('_')[1], 'value': str(value.get('calls'))} for key, value in command_stats_dict.items() + ] + result = CacheMonitorModel(commandStats=command_stats, dbSize=db_size, info=info) + + return result + + @classmethod + async def get_cache_monitor_cache_name_services(cls) -> list[CacheInfoModel]: + """ + 获取缓存名称列表信息service + + :return: 缓存名称列表信息 + """ + name_list = [ + CacheInfoModel( + cacheKey='', + cacheName=key_config.key, + cacheValue='', + remark=key_config.remark, + ) + for key_config in RedisInitKeyConfig + ] + + return name_list + + @classmethod + async def get_cache_monitor_cache_key_services(cls, request: Request, cache_name: str) -> list[str]: + """ + 获取缓存键名列表信息service + + :param request: Request对象 + :param cache_name: 缓存名称 + :return: 缓存键名列表信息 + """ + cache_keys: list[str] = await request.app.state.redis.keys(f'{cache_name}*') + cache_key_list = [key.split(':', 1)[1] for key in cache_keys if key.startswith(f'{cache_name}:')] + + return cache_key_list + + @classmethod + async def get_cache_monitor_cache_value_services( + cls, request: Request, cache_name: str, cache_key: str + ) -> CacheInfoModel: + """ + 获取缓存内容信息service + + :param request: Request对象 + :param cache_name: 缓存名称 + :param cache_key: 缓存键名 + :return: 缓存内容信息 + """ + cache_value = await request.app.state.redis.get(f'{cache_name}:{cache_key}') + + return CacheInfoModel(cacheKey=cache_key, cacheName=cache_name, cacheValue=cache_value, remark='') + + @classmethod + async def clear_cache_monitor_cache_name_services(cls, request: Request, cache_name: str) -> CrudResponseModel: + """ + 清除缓存名称对应所有键值service + + :param request: Request对象 + :param cache_name: 缓存名称 + :return: 操作缓存响应信息 + """ + cache_keys = await request.app.state.redis.keys(f'{cache_name}*') + if cache_keys: + await request.app.state.redis.delete(*cache_keys) + + return CrudResponseModel(is_success=True, message=f'{cache_name}对应键值清除成功') + + @classmethod + async def clear_cache_monitor_cache_key_services(cls, request: Request, cache_key: str) -> CrudResponseModel: + """ + 清除缓存名称对应所有键值service + + :param request: Request对象 + :param cache_key: 缓存键名 + :return: 操作缓存响应信息 + """ + cache_keys = await request.app.state.redis.keys(f'*{cache_key}') + if cache_keys: + await request.app.state.redis.delete(*cache_keys) + + return CrudResponseModel(is_success=True, message=f'{cache_key}清除成功') + + @classmethod + async def clear_cache_monitor_all_services(cls, request: Request) -> CrudResponseModel: + """ + 清除所有缓存service + + :param request: Request对象 + :return: 操作缓存响应信息 + """ + cache_keys = await request.app.state.redis.keys() + if cache_keys: + await request.app.state.redis.delete(*cache_keys) + + await RedisUtil.init_sys_dict(request.app.state.redis) + await RedisUtil.init_sys_config(request.app.state.redis) + + return CrudResponseModel(is_success=True, message='所有缓存清除成功') diff --git a/shierfeng-fastapi-backend/module_admin/service/captcha_service.py b/shierfeng-fastapi-backend/module_admin/service/captcha_service.py new file mode 100644 index 0000000..7a81269 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/captcha_service.py @@ -0,0 +1,54 @@ +import base64 +import io +import random + +import anyio +from PIL import Image, ImageDraw, ImageFont + + +class CaptchaService: + """ + 验证码模块服务层 + """ + + @classmethod + async def create_captcha_image_service(cls) -> list[str, int]: + # 创建空白图像 + image = Image.new('RGB', (160, 60), color='#EAEAEA') + + # 创建绘图对象 + draw = ImageDraw.Draw(image) + + # 设置字体 + font_path = (await anyio.Path.cwd()) / 'assets' / 'font' / 'Arial.ttf' + font = ImageFont.truetype(font_path, size=30) + + # 生成两个0-9之间的随机整数 + num1 = random.randint(0, 9) + num2 = random.randint(0, 9) + # 从运算符列表中随机选择一个 + operational_character_list = ['+', '-', '*'] + operational_character = random.choice(operational_character_list) + # 根据选择的运算符进行计算 + if operational_character == '+': + result = num1 + num2 + elif operational_character == '-': + result = num1 - num2 + # 非负数结果的验证码更友好一些 + if result < 0: + num1, num2 = num2, num1 + result = num1 - num2 + else: + result = num1 * num2 + # 绘制文本 + text = f'{num1} {operational_character} {num2} = ?' + draw.text((25, 15), text, fill='blue', font=font) + + # 将图像数据保存到内存中 + buffer = io.BytesIO() + image.save(buffer, format='PNG') + + # 将图像数据转换为base64字符串 + base64_string = base64.b64encode(buffer.getvalue()).decode() + + return [base64_string, result] diff --git a/shierfeng-fastapi-backend/module_admin/service/common_service.py b/shierfeng-fastapi-backend/module_admin/service/common_service.py new file mode 100644 index 0000000..254ad9f --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/common_service.py @@ -0,0 +1,99 @@ +import os +from datetime import datetime + +import aiofiles +from fastapi import BackgroundTasks, Request, UploadFile + +from common.vo import CrudResponseModel +from config.env import UploadConfig +from exceptions.exception import ServiceException +from module_admin.entity.vo.common_vo import UploadResponseModel +from utils.upload_util import UploadUtil + + +class CommonService: + """ + 通用模块服务层 + """ + + @classmethod + async def upload_service(cls, request: Request, file: UploadFile) -> CrudResponseModel: + """ + 通用上传service + + :param request: Request对象 + :param file: 上传文件对象 + :return: 上传结果 + """ + if not UploadUtil.check_file_extension(file): + raise ServiceException(message='文件类型不合法') + relative_path = ( + f'upload/{datetime.now().strftime("%Y")}/{datetime.now().strftime("%m")}/{datetime.now().strftime("%d")}' + ) + dir_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path) + try: + os.makedirs(dir_path) + except FileExistsError: + pass + filename = f'{file.filename.rsplit(".", 1)[0]}_{datetime.now().strftime("%Y%m%d%H%M%S")}{UploadConfig.UPLOAD_MACHINE}{UploadUtil.generate_random_number()}.{file.filename.rsplit(".")[-1]}' + filepath = os.path.join(dir_path, filename) + async with aiofiles.open(filepath, 'wb') as f: + # 流式写出大型文件,这里的10代表10MB + while True: + chunk = await file.read(1024 * 1024 * 10) + if not chunk: + break + await f.write(chunk) + + return CrudResponseModel( + is_success=True, + result=UploadResponseModel( + fileName=f'{UploadConfig.UPLOAD_PREFIX}/{relative_path}/{filename}', + newFileName=filename, + originalFilename=file.filename, + url=f'{request.base_url}{UploadConfig.UPLOAD_PREFIX[1:]}/{relative_path}/{filename}', + ), + message='上传成功', + ) + + @classmethod + async def download_services( + cls, background_tasks: BackgroundTasks, file_name: str, delete: bool + ) -> CrudResponseModel: + """ + 下载下载目录文件service + + :param background_tasks: 后台任务对象 + :param file_name: 下载的文件名称 + :param delete: 是否在下载完成后删除文件 + :return: 上传结果 + """ + filepath = os.path.join(UploadConfig.DOWNLOAD_PATH, file_name) + if '..' in file_name: + raise ServiceException(message='文件名称不合法') + if not UploadUtil.check_file_exists(filepath): + raise ServiceException(message='文件不存在') + if delete: + background_tasks.add_task(UploadUtil.delete_file, filepath) + return CrudResponseModel(is_success=True, result=UploadUtil.generate_file(filepath), message='下载成功') + + @classmethod + async def download_resource_services(cls, resource: str) -> CrudResponseModel: + """ + 下载上传目录文件service + + :param resource: 下载的文件名称 + :return: 上传结果 + """ + filepath = os.path.join(resource.replace(UploadConfig.UPLOAD_PREFIX, UploadConfig.UPLOAD_PATH)) + filename = resource.rsplit('/', 1)[-1] + if ( + '..' in filename + or not UploadUtil.check_file_timestamp(filename) + or not UploadUtil.check_file_machine(filename) + or not UploadUtil.check_file_random_code(filename) + ): + raise ServiceException(message='文件名称不合法') + if not UploadUtil.check_file_exists(filepath): + raise ServiceException(message='文件不存在') + return CrudResponseModel(is_success=True, result=UploadUtil.generate_file(filepath), message='下载成功') diff --git a/shierfeng-fastapi-backend/module_admin/service/config_service.py b/shierfeng-fastapi-backend/module_admin/service/config_service.py new file mode 100644 index 0000000..6dd3fbb --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/config_service.py @@ -0,0 +1,234 @@ +from typing import Any + +from fastapi import Request +from redis import asyncio as aioredis +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.enums import RedisInitKeyConfig +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_admin.dao.config_dao import ConfigDao +from module_admin.entity.vo.config_vo import ConfigModel, ConfigPageQueryModel, DeleteConfigModel +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil + + +class ConfigService: + """ + 参数配置管理模块服务层 + """ + + @classmethod + async def get_config_list_services( + cls, query_db: AsyncSession, query_object: ConfigPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取参数配置列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 参数配置列表信息对象 + """ + config_list_result = await ConfigDao.get_config_list(query_db, query_object, is_page) + + return config_list_result + + @classmethod + async def init_cache_sys_config_services(cls, query_db: AsyncSession, redis: aioredis.Redis) -> None: + """ + 应用初始化:获取所有参数配置对应的键值对信息并缓存service + + :param query_db: orm对象 + :param redis: redis对象 + :return: + """ + # 获取以sys_config:开头的键列表 + keys = await redis.keys(f'{RedisInitKeyConfig.SYS_CONFIG.key}:*') + # 删除匹配的键 + if keys: + await redis.delete(*keys) + config_all = await ConfigDao.get_config_list(query_db, ConfigPageQueryModel(), is_page=False) + for config_obj in config_all: + await redis.set( + f'{RedisInitKeyConfig.SYS_CONFIG.key}:{config_obj.get("configKey")}', + config_obj.get('configValue'), + ) + + @classmethod + async def query_config_list_from_cache_services(cls, redis: aioredis.Redis, config_key: str) -> Any: + """ + 从缓存获取参数键名对应值service + + :param redis: redis对象 + :param config_key: 参数键名 + :return: 参数键名对应值 + """ + result = await redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:{config_key}') + + return result + + @classmethod + async def check_config_key_unique_services(cls, query_db: AsyncSession, page_object: ConfigModel) -> bool: + """ + 校验参数键名是否唯一service + + :param query_db: orm对象 + :param page_object: 参数配置对象 + :return: 校验结果 + """ + config_id = -1 if page_object.config_id is None else page_object.config_id + config = await ConfigDao.get_config_detail_by_info(query_db, ConfigModel(configKey=page_object.config_key)) + if config and config.config_id != config_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_config_services( + cls, request: Request, query_db: AsyncSession, page_object: ConfigModel + ) -> CrudResponseModel: + """ + 新增参数配置信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 新增参数配置对象 + :return: 新增参数配置校验结果 + """ + if not await cls.check_config_key_unique_services(query_db, page_object): + raise ServiceException(message=f'新增参数{page_object.config_name}失败,参数键名已存在') + try: + await ConfigDao.add_config_dao(query_db, page_object) + await query_db.commit() + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SYS_CONFIG.key}:{page_object.config_key}', page_object.config_value + ) + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_config_services( + cls, request: Request, query_db: AsyncSession, page_object: ConfigModel + ) -> CrudResponseModel: + """ + 编辑参数配置信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 编辑参数配置对象 + :return: 编辑参数配置校验结果 + """ + edit_config = page_object.model_dump(exclude_unset=True) + config_info = await cls.config_detail_services(query_db, page_object.config_id) + if config_info.config_id: + if not await cls.check_config_key_unique_services(query_db, page_object): + raise ServiceException(message=f'修改参数{page_object.config_name}失败,参数键名已存在') + try: + await ConfigDao.edit_config_dao(query_db, edit_config) + await query_db.commit() + if config_info.config_key != page_object.config_key: + await request.app.state.redis.delete( + f'{RedisInitKeyConfig.SYS_CONFIG.key}:{config_info.config_key}' + ) + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SYS_CONFIG.key}:{page_object.config_key}', page_object.config_value + ) + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='参数配置不存在') + + @classmethod + async def delete_config_services( + cls, request: Request, query_db: AsyncSession, page_object: DeleteConfigModel + ) -> CrudResponseModel: + """ + 删除参数配置信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 删除参数配置对象 + :return: 删除参数配置校验结果 + """ + if page_object.config_ids: + config_id_list = page_object.config_ids.split(',') + try: + delete_config_key_list = [] + for config_id in config_id_list: + config_info = await cls.config_detail_services(query_db, int(config_id)) + if config_info.config_type == CommonConstant.YES: + raise ServiceException(message=f'内置参数{config_info.config_key}不能删除') + await ConfigDao.delete_config_dao(query_db, ConfigModel(configId=int(config_id))) + delete_config_key_list.append(f'{RedisInitKeyConfig.SYS_CONFIG.key}:{config_info.config_key}') + await query_db.commit() + if delete_config_key_list: + await request.app.state.redis.delete(*delete_config_key_list) + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入参数配置id为空') + + @classmethod + async def config_detail_services(cls, query_db: AsyncSession, config_id: int) -> ConfigModel: + """ + 获取参数配置详细信息service + + :param query_db: orm对象 + :param config_id: 参数配置id + :return: 参数配置id对应的信息 + """ + config = await ConfigDao.get_config_detail_by_id(query_db, config_id=config_id) + result = ConfigModel(**CamelCaseUtil.transform_result(config)) if config else ConfigModel() + + return result + + @staticmethod + async def export_config_list_services(config_list: list) -> bytes: + """ + 导出参数配置信息service + + :param config_list: 参数配置信息列表 + :return: 参数配置信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'configId': '参数主键', + 'configName': '参数名称', + 'configKey': '参数键名', + 'configValue': '参数键值', + 'configType': '系统内置', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + for item in config_list: + if item.get('configType') == 'Y': + item['configType'] = '是' + else: + item['configType'] = '否' + binary_data = ExcelUtil.export_list2excel(config_list, mapping_dict) + + return binary_data + + @classmethod + async def refresh_sys_config_services(cls, request: Request, query_db: AsyncSession) -> CrudResponseModel: + """ + 刷新字典缓存信息service + + :param request: Request对象 + :param query_db: orm对象 + :return: 刷新字典缓存校验结果 + """ + await cls.init_cache_sys_config_services(query_db, request.app.state.redis) + + return CrudResponseModel(is_success=True, message='刷新成功') diff --git a/shierfeng-fastapi-backend/module_admin/service/dept_service.py b/shierfeng-fastapi-backend/module_admin/service/dept_service.py new file mode 100644 index 0000000..da61a2b --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/dept_service.py @@ -0,0 +1,285 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.vo import CrudResponseModel +from exceptions.exception import ServiceException, ServiceWarning +from module_admin.dao.dept_dao import DeptDao +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.vo.dept_vo import DeleteDeptModel, DeptModel, DeptTreeModel +from utils.common_util import CamelCaseUtil + + +class DeptService: + """ + 部门管理模块服务层 + """ + + @classmethod + async def get_dept_tree_services( + cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement + ) -> list[dict[str, Any]]: + """ + 获取部门树信息service + + :param query_db: orm对象 + :param page_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 部门树信息对象 + """ + dept_list_result = await DeptDao.get_dept_list_for_tree(query_db, page_object, data_scope_sql) + dept_tree_model_result = cls.list_to_tree(dept_list_result) + dept_tree_result = [dept.model_dump(exclude_unset=True, by_alias=True) for dept in dept_tree_model_result] + + return dept_tree_result + + @classmethod + async def get_dept_for_edit_option_services( + cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement + ) -> list[dict[str, Any]]: + """ + 获取部门编辑部门树信息service + + :param query_db: orm对象 + :param page_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 部门树信息对象 + """ + dept_list_result = await DeptDao.get_dept_info_for_edit_option(query_db, page_object, data_scope_sql) + + return CamelCaseUtil.transform_result(dept_list_result) + + @classmethod + async def get_dept_list_services( + cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement + ) -> list[dict[str, Any]]: + """ + 获取部门列表信息service + + :param query_db: orm对象 + :param page_object: 分页查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 部门列表信息对象 + """ + dept_list_result = await DeptDao.get_dept_list(query_db, page_object, data_scope_sql) + + return CamelCaseUtil.transform_result(dept_list_result) + + @classmethod + async def check_dept_data_scope_services( + cls, query_db: AsyncSession, dept_id: int, data_scope_sql: ColumnElement + ) -> CrudResponseModel: + """ + 校验部门是否有数据权限service + + :param query_db: orm对象 + :param dept_id: 部门id + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 校验结果 + """ + depts = await DeptDao.get_dept_list(query_db, DeptModel(deptId=dept_id), data_scope_sql) + if depts: + return CrudResponseModel(is_success=True, message='校验通过') + raise ServiceException(message='没有权限访问部门数据') + + @classmethod + async def check_dept_name_unique_services(cls, query_db: AsyncSession, page_object: DeptModel) -> bool: + """ + 校验部门名称是否唯一service + + :param query_db: orm对象 + :param page_object: 部门对象 + :return: 校验结果 + """ + dept_id = -1 if page_object.dept_id is None else page_object.dept_id + dept = await DeptDao.get_dept_detail_by_info( + query_db, DeptModel(deptName=page_object.dept_name, parentId=page_object.parent_id) + ) + if dept and dept.dept_id != dept_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_dept_services(cls, query_db: AsyncSession, page_object: DeptModel) -> CrudResponseModel: + """ + 新增部门信息service + + :param query_db: orm对象 + :param page_object: 新增部门对象 + :return: 新增部门校验结果 + """ + if not await cls.check_dept_name_unique_services(query_db, page_object): + raise ServiceException(message=f'新增部门{page_object.dept_name}失败,部门名称已存在') + parent_info = await DeptDao.get_dept_by_id(query_db, page_object.parent_id) + if parent_info.status != CommonConstant.DEPT_NORMAL: + raise ServiceException(message=f'部门{parent_info.dept_name}停用,不允许新增') + page_object.ancestors = f'{parent_info.ancestors},{page_object.parent_id}' + try: + await DeptDao.add_dept_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_dept_services(cls, query_db: AsyncSession, page_object: DeptModel) -> CrudResponseModel: + """ + 编辑部门信息service + + :param query_db: orm对象 + :param page_object: 编辑部门对象 + :return: 编辑部门校验结果 + """ + if not await cls.check_dept_name_unique_services(query_db, page_object): + raise ServiceException(message=f'修改部门{page_object.dept_name}失败,部门名称已存在') + if page_object.dept_id == page_object.parent_id: + raise ServiceException(message=f'修改部门{page_object.dept_name}失败,上级部门不能是自己') + if ( + page_object.status == CommonConstant.DEPT_DISABLE + and (await DeptDao.count_normal_children_dept_dao(query_db, page_object.dept_id)) > 0 + ): + raise ServiceException(message=f'修改部门{page_object.dept_name}失败,该部门包含未停用的子部门') + new_parent_dept = await DeptDao.get_dept_by_id(query_db, page_object.parent_id) + old_dept = await DeptDao.get_dept_by_id(query_db, page_object.dept_id) + try: + if new_parent_dept and old_dept: + new_ancestors = f'{new_parent_dept.ancestors},{new_parent_dept.dept_id}' + old_ancestors = old_dept.ancestors + page_object.ancestors = new_ancestors + await cls.update_dept_children(query_db, page_object.dept_id, new_ancestors, old_ancestors) + edit_dept = page_object.model_dump(exclude_unset=True) + await DeptDao.edit_dept_dao(query_db, edit_dept) + if ( + page_object.status == CommonConstant.DEPT_NORMAL + and page_object.ancestors + and page_object.ancestors != 0 + ): + await cls.update_parent_dept_status_normal(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def delete_dept_services(cls, query_db: AsyncSession, page_object: DeleteDeptModel) -> CrudResponseModel: + """ + 删除部门信息service + + :param query_db: orm对象 + :param page_object: 删除部门对象 + :return: 删除部门校验结果 + """ + if page_object.dept_ids: + dept_id_list = page_object.dept_ids.split(',') + try: + for dept_id in dept_id_list: + if (await DeptDao.count_children_dept_dao(query_db, int(dept_id))) > 0: + raise ServiceWarning(message='存在下级部门,不允许删除') + if (await DeptDao.count_dept_user_dao(query_db, int(dept_id))) > 0: + raise ServiceWarning(message='部门存在用户,不允许删除') + + await DeptDao.delete_dept_dao(query_db, DeptModel(deptId=dept_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入部门id为空') + + @classmethod + async def dept_detail_services(cls, query_db: AsyncSession, dept_id: int) -> DeptModel: + """ + 获取部门详细信息service + + :param query_db: orm对象 + :param dept_id: 部门id + :return: 部门id对应的信息 + """ + dept = await DeptDao.get_dept_detail_by_id(query_db, dept_id=dept_id) + result = DeptModel(**CamelCaseUtil.transform_result(dept)) if dept else DeptModel() + + return result + + @classmethod + def list_to_tree(cls, permission_list: Sequence[SysDept]) -> list[DeptTreeModel]: + """ + 工具方法:根据部门列表信息生成树形嵌套数据 + + :param permission_list: 部门列表信息 + :return: 部门树形嵌套数据 + """ + _permission_list = [ + DeptTreeModel(id=item.dept_id, label=item.dept_name, parentId=item.parent_id) for item in permission_list + ] + # 转成id为key的字典 + mapping: dict[int, DeptTreeModel] = dict(zip([i.id for i in _permission_list], _permission_list, strict=False)) + + # 树容器 + container: list[DeptTreeModel] = [] + + for d in _permission_list: + # 如果找不到父级项,则是根节点 + parent = mapping.get(d.parent_id) + if parent is None: + container.append(d) + else: + children: list[DeptTreeModel] = parent.children + if not children: + children = [] + children.append(d) + parent.children = children + + return container + + @classmethod + async def replace_first(cls, original_str: str, old_str: str, new_str: str) -> str: + """ + 工具方法:替换字符串 + + :param original_str: 需要替换的原始字符串 + :param old_str: 用于匹配的字符串 + :param new_str: 替换的字符串 + :return: 替换后的字符串 + """ + if original_str.startswith(old_str): + return original_str.replace(old_str, new_str, 1) + return original_str + + @classmethod + async def update_parent_dept_status_normal(cls, query_db: AsyncSession, dept: DeptModel) -> None: + """ + 更新父部门状态为正常 + + :param query_db: orm对象 + :param dept: 部门对象 + :return: + """ + dept_id_list = dept.ancestors.split(',') + await DeptDao.update_dept_status_normal_dao(query_db, list(map(int, dept_id_list))) + + @classmethod + async def update_dept_children( + cls, query_db: AsyncSession, dept_id: int, new_ancestors: str, old_ancestors: str + ) -> None: + """ + 更新子部门信息 + + :param query_db: orm对象 + :param dept_id: 部门id + :param new_ancestors: 新的祖先 + :param old_ancestors: 旧的祖先 + :return: + """ + children = await DeptDao.get_children_dept_dao(query_db, dept_id) + update_children = [] + for child in children: + child_ancestors = await cls.replace_first(child.ancestors, old_ancestors, new_ancestors) + update_children.append({'dept_id': child.dept_id, 'ancestors': child_ancestors}) + if children: + await DeptDao.update_dept_children_dao(query_db, update_children) diff --git a/shierfeng-fastapi-backend/module_admin/service/dict_service.py b/shierfeng-fastapi-backend/module_admin/service/dict_service.py new file mode 100644 index 0000000..afc83ce --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/dict_service.py @@ -0,0 +1,464 @@ +import json +from collections.abc import Sequence +from typing import Any + +from fastapi import Request +from redis import asyncio as aioredis +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.enums import RedisInitKeyConfig +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_admin.dao.dict_dao import DictDataDao, DictTypeDao +from module_admin.entity.do.dict_do import SysDictData +from module_admin.entity.vo.dict_vo import ( + DeleteDictDataModel, + DeleteDictTypeModel, + DictDataModel, + DictDataPageQueryModel, + DictTypeModel, + DictTypePageQueryModel, +) +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil + + +class DictTypeService: + """ + 字典类型管理模块服务层 + """ + + @classmethod + async def get_dict_type_list_services( + cls, query_db: AsyncSession, query_object: DictTypePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取字典类型列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 字典类型列表信息对象 + """ + dict_type_list_result = await DictTypeDao.get_dict_type_list(query_db, query_object, is_page) + + return dict_type_list_result + + @classmethod + async def check_dict_type_unique_services(cls, query_db: AsyncSession, page_object: DictTypeModel) -> bool: + """ + 校验字典类型称是否唯一service + + :param query_db: orm对象 + :param page_object: 字典类型对象 + :return: 校验结果 + """ + dict_id = -1 if page_object.dict_id is None else page_object.dict_id + dict_type = await DictTypeDao.get_dict_type_detail_by_info( + query_db, DictTypeModel(dictType=page_object.dict_type) + ) + if dict_type and dict_type.dict_id != dict_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_dict_type_services( + cls, request: Request, query_db: AsyncSession, page_object: DictTypeModel + ) -> CrudResponseModel: + """ + 新增字典类型信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 新增岗位对象 + :return: 新增字典类型校验结果 + """ + if not await cls.check_dict_type_unique_services(query_db, page_object): + raise ServiceException(message=f'新增字典{page_object.dict_name}失败,字典类型已存在') + try: + await DictTypeDao.add_dict_type_dao(query_db, page_object) + await query_db.commit() + await request.app.state.redis.set(f'{RedisInitKeyConfig.SYS_DICT.key}:{page_object.dict_type}', '') + result = {'is_success': True, 'message': '新增成功'} + except Exception as e: + await query_db.rollback() + raise e + + return CrudResponseModel(**result) + + @classmethod + async def edit_dict_type_services( + cls, request: Request, query_db: AsyncSession, page_object: DictTypeModel + ) -> CrudResponseModel: + """ + 编辑字典类型信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 编辑字典类型对象 + :return: 编辑字典类型校验结果 + """ + edit_dict_type = page_object.model_dump(exclude_unset=True) + dict_type_info = await cls.dict_type_detail_services(query_db, page_object.dict_id) + if dict_type_info.dict_id: + if not await cls.check_dict_type_unique_services(query_db, page_object): + raise ServiceException(message=f'修改字典{page_object.dict_name}失败,字典类型已存在') + try: + query_dict_data = DictDataPageQueryModel(dictType=dict_type_info.dict_type) + dict_data_list = await DictDataDao.get_dict_data_list(query_db, query_dict_data, is_page=False) + if dict_type_info.dict_type != page_object.dict_type: + for dict_data in dict_data_list: + edit_dict_data = DictDataModel( + dictCode=dict_data.get('dict_code'), + dictType=page_object.dict_type, + updateBy=page_object.update_by, + updateTime=page_object.update_time, + ).model_dump(exclude_unset=True) + await DictDataDao.edit_dict_data_dao(query_db, edit_dict_data) + await DictTypeDao.edit_dict_type_dao(query_db, edit_dict_type) + await query_db.commit() + if dict_type_info.dict_type != page_object.dict_type: + dict_data = [CamelCaseUtil.transform_result(row) for row in dict_data_list if row] + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SYS_DICT.key}:{page_object.dict_type}', + json.dumps(dict_data, ensure_ascii=False, default=str), + ) + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='字典类型不存在') + + @classmethod + async def delete_dict_type_services( + cls, request: Request, query_db: AsyncSession, page_object: DeleteDictTypeModel + ) -> CrudResponseModel: + """ + 删除字典类型信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 删除字典类型对象 + :return: 删除字典类型校验结果 + """ + if page_object.dict_ids: + dict_id_list = page_object.dict_ids.split(',') + try: + delete_dict_type_list = [] + for dict_id in dict_id_list: + dict_type_into = await cls.dict_type_detail_services(query_db, int(dict_id)) + if (await DictDataDao.count_dict_data_dao(query_db, dict_type_into.dict_type)) > 0: + raise ServiceException(message=f'{dict_type_into.dict_name}已分配,不能删除') + await DictTypeDao.delete_dict_type_dao(query_db, DictTypeModel(dictId=int(dict_id))) + delete_dict_type_list.append(f'{RedisInitKeyConfig.SYS_DICT.key}:{dict_type_into.dict_type}') + await query_db.commit() + if delete_dict_type_list: + await request.app.state.redis.delete(*delete_dict_type_list) + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入字典类型id为空') + + @classmethod + async def dict_type_detail_services(cls, query_db: AsyncSession, dict_id: int) -> DictTypeModel: + """ + 获取字典类型详细信息service + + :param query_db: orm对象 + :param dict_id: 字典类型id + :return: 字典类型id对应的信息 + """ + dict_type = await DictTypeDao.get_dict_type_detail_by_id(query_db, dict_id=dict_id) + result = DictTypeModel(**CamelCaseUtil.transform_result(dict_type)) if dict_type else DictTypeModel() + + return result + + @staticmethod + async def export_dict_type_list_services(dict_type_list: list) -> bytes: + """ + 导出字典类型信息service + + :param dict_type_list: 字典信息列表 + :return: 字典信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'dictId': '字典编号', + 'dictName': '字典名称', + 'dictType': '字典类型', + 'status': '状态', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + for item in dict_type_list: + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '停用' + binary_data = ExcelUtil.export_list2excel(dict_type_list, mapping_dict) + + return binary_data + + @classmethod + async def refresh_sys_dict_services(cls, request: Request, query_db: AsyncSession) -> CrudResponseModel: + """ + 刷新字典缓存信息service + + :param request: Request对象 + :param query_db: orm对象 + :return: 刷新字典缓存校验结果 + """ + await DictDataService.init_cache_sys_dict_services(query_db, request.app.state.redis) + result = {'is_success': True, 'message': '刷新成功'} + + return CrudResponseModel(**result) + + +class DictDataService: + """ + 字典数据管理模块服务层 + """ + + @classmethod + async def get_dict_data_list_services( + cls, query_db: AsyncSession, query_object: DictDataPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取字典数据列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 字典数据列表信息对象 + """ + dict_data_list_result = await DictDataDao.get_dict_data_list(query_db, query_object, is_page) + + return dict_data_list_result + + @classmethod + async def query_dict_data_list_services(cls, query_db: AsyncSession, dict_type: str) -> Sequence[SysDictData]: + """ + 获取字典数据列表信息service + + :param query_db: orm对象 + :param dict_type: 字典类型 + :return: 字典数据列表信息对象 + """ + dict_data_list_result = await DictDataDao.query_dict_data_list(query_db, dict_type) + + return dict_data_list_result + + @classmethod + async def init_cache_sys_dict_services(cls, query_db: AsyncSession, redis: aioredis.Redis) -> None: + """ + 应用初始化:获取所有字典类型对应的字典数据信息并缓存service + + :param query_db: orm对象 + :param redis: redis对象 + :return: + """ + # 获取以sys_dict:开头的键列表 + keys = await redis.keys(f'{RedisInitKeyConfig.SYS_DICT.key}:*') + # 删除匹配的键 + if keys: + await redis.delete(*keys) + dict_type_all = await DictTypeDao.get_all_dict_type(query_db) + for dict_type_obj in [item for item in dict_type_all if item.status == '0']: + dict_type = dict_type_obj.dict_type + dict_data_list = await DictDataDao.query_dict_data_list(query_db, dict_type) + dict_data = [CamelCaseUtil.transform_result(row) for row in dict_data_list if row] + await redis.set( + f'{RedisInitKeyConfig.SYS_DICT.key}:{dict_type}', + json.dumps(dict_data, ensure_ascii=False, default=str), + ) + + @classmethod + async def query_dict_data_list_from_cache_services( + cls, redis: aioredis.Redis, dict_type: str + ) -> list[dict[str, Any]]: + """ + 从缓存获取字典数据列表信息service + + :param redis: redis对象 + :param dict_type: 字典类型 + :return: 字典数据列表信息对象 + """ + result = [] + dict_data_list_result = await redis.get(f'{RedisInitKeyConfig.SYS_DICT.key}:{dict_type}') + if dict_data_list_result: + result = json.loads(dict_data_list_result) + + return CamelCaseUtil.transform_result(result) + + @classmethod + async def check_dict_data_unique_services(cls, query_db: AsyncSession, page_object: DictDataModel) -> bool: + """ + 校验字典数据是否唯一service + + :param query_db: orm对象 + :param page_object: 字典数据对象 + :return: 校验结果 + """ + dict_code = -1 if page_object.dict_code is None else page_object.dict_code + dict_data = await DictDataDao.get_dict_data_detail_by_info(query_db, page_object) + if dict_data and dict_data.dict_code != dict_code: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_dict_data_services( + cls, request: Request, query_db: AsyncSession, page_object: DictDataModel + ) -> CrudResponseModel: + """ + 新增字典数据信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 新增岗位对象 + :return: 新增字典数据校验结果 + """ + if not await cls.check_dict_data_unique_services(query_db, page_object): + raise ServiceException( + message=f'新增字典数据{page_object.dict_label}失败,{page_object.dict_type}下已存在该字典数据' + ) + try: + await DictDataDao.add_dict_data_dao(query_db, page_object) + await query_db.commit() + dict_data_list = await cls.query_dict_data_list_services(query_db, page_object.dict_type) + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SYS_DICT.key}:{page_object.dict_type}', + json.dumps(CamelCaseUtil.transform_result(dict_data_list), ensure_ascii=False, default=str), + ) + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_dict_data_services( + cls, request: Request, query_db: AsyncSession, page_object: DictDataModel + ) -> CrudResponseModel: + """ + 编辑字典数据信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 编辑字典数据对象 + :return: 编辑字典数据校验结果 + """ + edit_data_type = page_object.model_dump(exclude_unset=True) + dict_data_info = await cls.dict_data_detail_services(query_db, page_object.dict_code) + if dict_data_info.dict_code: + if not await cls.check_dict_data_unique_services(query_db, page_object): + raise ServiceException( + message=f'新增字典数据{page_object.dict_label}失败,{page_object.dict_type}下已存在该字典数据' + ) + try: + await DictDataDao.edit_dict_data_dao(query_db, edit_data_type) + await query_db.commit() + dict_data_list = await cls.query_dict_data_list_services(query_db, page_object.dict_type) + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SYS_DICT.key}:{page_object.dict_type}', + json.dumps(CamelCaseUtil.transform_result(dict_data_list), ensure_ascii=False, default=str), + ) + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='字典数据不存在') + + @classmethod + async def delete_dict_data_services( + cls, request: Request, query_db: AsyncSession, page_object: DeleteDictDataModel + ) -> CrudResponseModel: + """ + 删除字典数据信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 删除字典数据对象 + :return: 删除字典数据校验结果 + """ + if page_object.dict_codes: + dict_code_list = page_object.dict_codes.split(',') + try: + delete_dict_type_list = [] + for dict_code in dict_code_list: + dict_data = await cls.dict_data_detail_services(query_db, int(dict_code)) + await DictDataDao.delete_dict_data_dao(query_db, DictDataModel(dictCode=dict_code)) + delete_dict_type_list.append(dict_data.dict_type) + await query_db.commit() + for dict_type in list(set(delete_dict_type_list)): + dict_data_list = await cls.query_dict_data_list_services(query_db, dict_type) + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SYS_DICT.key}:{dict_type}', + json.dumps(CamelCaseUtil.transform_result(dict_data_list), ensure_ascii=False, default=str), + ) + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入字典数据id为空') + + @classmethod + async def dict_data_detail_services(cls, query_db: AsyncSession, dict_code: int) -> DictDataModel: + """ + 获取字典数据详细信息service + + :param query_db: orm对象 + :param dict_code: 字典数据id + :return: 字典数据id对应的信息 + """ + dict_data = await DictDataDao.get_dict_data_detail_by_id(query_db, dict_code=dict_code) + result = DictDataModel(**CamelCaseUtil.transform_result(dict_data)) if dict_data else DictDataModel() + + return result + + @staticmethod + async def export_dict_data_list_services(dict_data_list: list) -> bytes: + """ + 导出字典数据信息service + + :param dict_data_list: 字典数据信息列表 + :return: 字典数据信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'dictCode': '字典编码', + 'dictSort': '字典标签', + 'dictLabel': '字典键值', + 'dictValue': '字典排序', + 'dictType': '字典类型', + 'cssClass': '样式属性', + 'listClass': '表格回显样式', + 'isDefault': '是否默认', + 'status': '状态', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + for item in dict_data_list: + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '停用' + if item.get('isDefault') == 'Y': + item['isDefault'] = '是' + else: + item['isDefault'] = '否' + binary_data = ExcelUtil.export_list2excel(dict_data_list, mapping_dict) + + return binary_data diff --git a/shierfeng-fastapi-backend/module_admin/service/job_log_service.py b/shierfeng-fastapi-backend/module_admin/service/job_log_service.py new file mode 100644 index 0000000..92b7441 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/job_log_service.py @@ -0,0 +1,144 @@ +from typing import Any + +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from common.vo import CrudResponseModel, PageModel +from module_admin.dao.job_log_dao import JobLogDao +from module_admin.entity.vo.job_vo import DeleteJobLogModel, JobLogModel, JobLogPageQueryModel +from module_admin.service.dict_service import DictDataService +from utils.excel_util import ExcelUtil + + +class JobLogService: + """ + 定时任务日志管理模块服务层 + """ + + @classmethod + async def get_job_log_list_services( + cls, query_db: AsyncSession, query_object: JobLogPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取定时任务日志列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 定时任务日志列表信息对象 + """ + job_log_list_result = await JobLogDao.get_job_log_list(query_db, query_object, is_page) + + return job_log_list_result + + @classmethod + def add_job_log_services(cls, query_db: Session, page_object: JobLogModel) -> CrudResponseModel: + """ + 新增定时任务日志信息service + + :param query_db: orm对象 + :param page_object: 新增定时任务日志对象 + :return: 新增定时任务日志校验结果 + """ + try: + JobLogDao.add_job_log_dao(query_db, page_object) + query_db.commit() + result = {'is_success': True, 'message': '新增成功'} + except Exception as e: + query_db.rollback() + result = {'is_success': False, 'message': str(e)} + + return CrudResponseModel(**result) + + @classmethod + async def delete_job_log_services(cls, query_db: AsyncSession, page_object: DeleteJobLogModel) -> CrudResponseModel: + """ + 删除定时任务日志信息service + + :param query_db: orm对象 + :param page_object: 删除定时任务日志对象 + :return: 删除定时任务日志校验结果 + """ + if page_object.job_log_ids: + job_log_id_list = page_object.job_log_ids.split(',') + try: + for job_log_id in job_log_id_list: + await JobLogDao.delete_job_log_dao(query_db, JobLogModel(jobLogId=job_log_id)) + await query_db.commit() + result = {'is_success': True, 'message': '删除成功'} + except Exception as e: + await query_db.rollback() + raise e + else: + result = {'is_success': False, 'message': '传入定时任务日志id为空'} + return CrudResponseModel(**result) + + @classmethod + async def clear_job_log_services(cls, query_db: AsyncSession) -> CrudResponseModel: + """ + 清除定时任务日志信息service + + :param query_db: orm对象 + :return: 清除定时任务日志校验结果 + """ + try: + await JobLogDao.clear_job_log_dao(query_db) + await query_db.commit() + result = {'is_success': True, 'message': '清除成功'} + except Exception as e: + await query_db.rollback() + raise e + + return CrudResponseModel(**result) + + @staticmethod + async def export_job_log_list_services(request: Request, job_log_list: list) -> bytes: + """ + 导出定时任务日志信息service + + :param request: Request对象 + :param job_log_list: 定时任务日志信息列表 + :return: 定时任务日志信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'jobLogId': '任务日志编码', + 'jobName': '任务名称', + 'jobGroup': '任务组名', + 'jobExecutor': '任务执行器', + 'invokeTarget': '调用目标字符串', + 'jobArgs': '位置参数', + 'jobKwargs': '关键字参数', + 'jobTrigger': '任务触发器', + 'jobMessage': '日志信息', + 'status': '执行状态', + 'exceptionInfo': '异常信息', + 'createTime': '创建时间', + } + + job_group_list = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type='sys_job_group' + ) + job_group_option = [{'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in job_group_list] + job_group_option_dict = {item.get('value'): item for item in job_group_option} + job_executor_list = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type='sys_job_executor' + ) + job_executor_option = [ + {'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in job_executor_list + ] + job_executor_option_dict = {item.get('value'): item for item in job_executor_option} + + for item in job_log_list: + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '暂停' + if str(item.get('jobGroup')) in job_group_option_dict: + item['jobGroup'] = job_group_option_dict.get(str(item.get('jobGroup'))).get('label') + if str(item.get('jobExecutor')) in job_executor_option_dict: + item['jobExecutor'] = job_executor_option_dict.get(str(item.get('jobExecutor'))).get('label') + binary_data = ExcelUtil.export_list2excel(job_log_list, mapping_dict) + + return binary_data diff --git a/shierfeng-fastapi-backend/module_admin/service/job_service.py b/shierfeng-fastapi-backend/module_admin/service/job_service.py new file mode 100644 index 0000000..5e73135 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/job_service.py @@ -0,0 +1,273 @@ +from typing import Any + +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant, JobConstant +from common.vo import CrudResponseModel, PageModel +from config.get_scheduler import SchedulerUtil +from exceptions.exception import ServiceException +from module_admin.dao.job_dao import JobDao +from module_admin.entity.vo.job_vo import DeleteJobModel, EditJobModel, JobModel, JobPageQueryModel +from module_admin.service.dict_service import DictDataService +from utils.common_util import CamelCaseUtil +from utils.cron_util import CronUtil +from utils.excel_util import ExcelUtil +from utils.string_util import StringUtil + + +class JobService: + """ + 定时任务管理模块服务层 + """ + + @classmethod + async def get_job_list_services( + cls, query_db: AsyncSession, query_object: JobPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取定时任务列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 定时任务列表信息对象 + """ + job_list_result = await JobDao.get_job_list(query_db, query_object, is_page) + + return job_list_result + + @classmethod + async def check_job_unique_services(cls, query_db: AsyncSession, page_object: JobModel) -> bool: + """ + 校验定时任务是否存在service + + :param query_db: orm对象 + :param page_object: 定时任务对象 + :return: 校验结果 + """ + job_id = -1 if page_object.job_id is None else page_object.job_id + job = await JobDao.get_job_detail_by_info(query_db, page_object) + if job and job.job_id != job_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_job_services(cls, query_db: AsyncSession, page_object: JobModel) -> CrudResponseModel: + """ + 新增定时任务信息service + + :param query_db: orm对象 + :param page_object: 新增定时任务对象 + :return: 新增定时任务校验结果 + """ + if not CronUtil.validate_cron_expression(page_object.cron_expression): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,Cron表达式不正确') + if StringUtil.contains_ignore_case(page_object.invoke_target, CommonConstant.LOOKUP_RMI): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不允许rmi调用') + if StringUtil.contains_any_ignore_case( + page_object.invoke_target, [CommonConstant.LOOKUP_LDAP, CommonConstant.LOOKUP_LDAPS] + ): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不允许ldap(s)调用') + if StringUtil.contains_any_ignore_case(page_object.invoke_target, [CommonConstant.HTTP, CommonConstant.HTTPS]): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不允许http(s)调用') + if StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_ERROR_LIST): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串存在违规') + if not StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_WHITE_LIST): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不在白名单内') + if not await cls.check_job_unique_services(query_db, page_object): + raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,定时任务已存在') + try: + add_job = await JobDao.add_job_dao(query_db, page_object) + job_info = await cls.job_detail_services(query_db, add_job.job_id) + if job_info.status == '0': + SchedulerUtil.add_scheduler_job(job_info=job_info) + await query_db.commit() + await SchedulerUtil.request_scheduler_sync() + result = {'is_success': True, 'message': '新增成功'} + except Exception as e: + await query_db.rollback() + raise e + + return CrudResponseModel(**result) + + @classmethod + def _deal_edit_job(cls, page_object: EditJobModel, edit_job: dict[str, Any]) -> None: + """ + 处理编辑定时任务字典 + + :param page_object: 编辑定时任务对象 + :param edit_job: 编辑定时任务字典 + """ + if page_object.type == 'status': + del edit_job['type'] + + @classmethod + async def edit_job_services(cls, query_db: AsyncSession, page_object: EditJobModel) -> CrudResponseModel: + """ + 编辑定时任务信息service + + :param query_db: orm对象 + :param page_object: 编辑定时任务对象 + :return: 编辑定时任务校验结果 + """ + edit_job = page_object.model_dump(exclude_unset=True) + cls._deal_edit_job(page_object, edit_job) + job_info = await cls.job_detail_services(query_db, page_object.job_id) + if job_info: + if page_object.type != 'status': + if not CronUtil.validate_cron_expression(page_object.cron_expression): + raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,Cron表达式不正确') + if StringUtil.contains_ignore_case(page_object.invoke_target, CommonConstant.LOOKUP_RMI): + raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,目标字符串不允许rmi调用') + if StringUtil.contains_any_ignore_case( + page_object.invoke_target, [CommonConstant.LOOKUP_LDAP, CommonConstant.LOOKUP_LDAPS] + ): + raise ServiceException( + message=f'修改定时任务{page_object.job_name}失败,目标字符串不允许ldap(s)调用' + ) + if StringUtil.contains_any_ignore_case( + page_object.invoke_target, [CommonConstant.HTTP, CommonConstant.HTTPS] + ): + raise ServiceException( + message=f'修改定时任务{page_object.job_name}失败,目标字符串不允许http(s)调用' + ) + if StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_ERROR_LIST): + raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,目标字符串存在违规') + if not StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_WHITE_LIST): + raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,目标字符串不在白名单内') + if not await cls.check_job_unique_services(query_db, page_object): + raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,定时任务已存在') + try: + await JobDao.edit_job_dao(query_db, edit_job, job_info) + SchedulerUtil.remove_scheduler_job(job_id=edit_job.get('job_id')) + if edit_job.get('status') == '0': + job_info = await cls.job_detail_services(query_db, edit_job.get('job_id')) + SchedulerUtil.add_scheduler_job(job_info=job_info) + await query_db.commit() + await SchedulerUtil.request_scheduler_sync() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='定时任务不存在') + + @classmethod + async def execute_job_once_services(cls, query_db: AsyncSession, page_object: JobModel) -> CrudResponseModel: + """ + 执行一次定时任务service + + :param query_db: orm对象 + :param page_object: 定时任务对象 + :return: 执行一次定时任务结果 + """ + SchedulerUtil.remove_scheduler_job(job_id=page_object.job_id) + job_info = await cls.job_detail_services(query_db, page_object.job_id) + if job_info: + SchedulerUtil.execute_scheduler_job_once(job_info=job_info) + return CrudResponseModel(is_success=True, message='执行成功') + raise ServiceException(message='定时任务不存在') + + @classmethod + async def delete_job_services(cls, query_db: AsyncSession, page_object: DeleteJobModel) -> CrudResponseModel: + """ + 删除定时任务信息service + + :param query_db: orm对象 + :param page_object: 删除定时任务对象 + :return: 删除定时任务校验结果 + """ + if page_object.job_ids: + job_id_list = page_object.job_ids.split(',') + try: + for job_id in job_id_list: + await JobDao.delete_job_dao(query_db, JobModel(jobId=job_id)) + SchedulerUtil.remove_scheduler_job(job_id=job_id) + await query_db.commit() + await SchedulerUtil.request_scheduler_sync() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入定时任务id为空') + + @classmethod + async def job_detail_services(cls, query_db: AsyncSession, job_id: int) -> JobModel: + """ + 获取定时任务详细信息service + + :param query_db: orm对象 + :param job_id: 定时任务id + :return: 定时任务id对应的信息 + """ + job = await JobDao.get_job_detail_by_id(query_db, job_id=job_id) + result = JobModel(**CamelCaseUtil.transform_result(job)) if job else JobModel() + + return result + + @staticmethod + async def export_job_list_services(request: Request, job_list: list) -> bytes: + """ + 导出定时任务信息service + + :param request: Request对象 + :param job_list: 定时任务信息列表 + :return: 定时任务信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'jobId': '任务编码', + 'jobName': '任务名称', + 'jobGroup': '任务组名', + 'jobExecutor': '任务执行器', + 'invokeTarget': '调用目标字符串', + 'jobArgs': '位置参数', + 'jobKwargs': '关键字参数', + 'cronExpression': 'cron执行表达式', + 'misfirePolicy': '计划执行错误策略', + 'concurrent': '是否并发执行', + 'status': '状态', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + job_group_list = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type='sys_job_group' + ) + job_group_option = [{'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in job_group_list] + job_group_option_dict = {item.get('value'): item for item in job_group_option} + job_executor_list = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type='sys_job_executor' + ) + job_executor_option = [ + {'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in job_executor_list + ] + job_executor_option_dict = {item.get('value'): item for item in job_executor_option} + + for item in job_list: + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '暂停' + if str(item.get('jobGroup')) in job_group_option_dict: + item['jobGroup'] = job_group_option_dict.get(str(item.get('jobGroup'))).get('label') + if str(item.get('jobExecutor')) in job_executor_option_dict: + item['jobExecutor'] = job_executor_option_dict.get(str(item.get('jobExecutor'))).get('label') + if item.get('misfirePolicy') == '1': + item['misfirePolicy'] = '立即执行' + elif item.get('misfirePolicy') == '2': + item['misfirePolicy'] = '执行一次' + else: + item['misfirePolicy'] = '放弃执行' + if item.get('concurrent') == '0': + item['concurrent'] = '允许' + else: + item['concurrent'] = '禁止' + binary_data = ExcelUtil.export_list2excel(job_list, mapping_dict) + + return binary_data diff --git a/shierfeng-fastapi-backend/module_admin/service/log_service.py b/shierfeng-fastapi-backend/module_admin/service/log_service.py new file mode 100644 index 0000000..3560f39 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/log_service.py @@ -0,0 +1,508 @@ +import asyncio +import hashlib +import json +import os +import uuid +from typing import Any + +from fastapi import Request +from redis import asyncio as aioredis +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import CrudResponseModel, PageModel +from config.database import AsyncSessionLocal +from config.env import LogConfig +from exceptions.exception import ServiceException +from middlewares.trace_middleware.ctx import TraceCtx +from module_admin.dao.log_dao import LoginLogDao, OperationLogDao +from module_admin.entity.vo.log_vo import ( + DeleteLoginLogModel, + DeleteOperLogModel, + LogininforModel, + LoginLogPageQueryModel, + OperLogModel, + OperLogPageQueryModel, + UnlockUser, +) +from module_admin.service.dict_service import DictDataService +from utils.excel_util import ExcelUtil +from utils.log_util import LogSanitizer, logger + + +class OperationLogService: + """ + 操作日志管理模块服务层 + """ + + @classmethod + async def get_operation_log_list_services( + cls, query_db: AsyncSession, query_object: OperLogPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取操作日志列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 操作日志列表信息对象 + """ + operation_log_list_result = await OperationLogDao.get_operation_log_list(query_db, query_object, is_page) + + return operation_log_list_result + + @classmethod + async def add_operation_log_services(cls, query_db: AsyncSession, page_object: OperLogModel) -> CrudResponseModel: + """ + 新增操作日志service + + :param query_db: orm对象 + :param page_object: 新增操作日志对象 + :return: 新增操作日志校验结果 + """ + try: + await OperationLogDao.add_operation_log_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def delete_operation_log_services( + cls, query_db: AsyncSession, page_object: DeleteOperLogModel + ) -> CrudResponseModel: + """ + 删除操作日志信息service + + :param query_db: orm对象 + :param page_object: 删除操作日志对象 + :return: 删除操作日志校验结果 + """ + if page_object.oper_ids: + oper_id_list = page_object.oper_ids.split(',') + try: + for oper_id in oper_id_list: + await OperationLogDao.delete_operation_log_dao(query_db, OperLogModel(operId=oper_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入操作日志id为空') + + @classmethod + async def clear_operation_log_services(cls, query_db: AsyncSession) -> CrudResponseModel: + """ + 清除操作日志信息service + + :param query_db: orm对象 + :return: 清除操作日志校验结果 + """ + try: + await OperationLogDao.clear_operation_log_dao(query_db) + await query_db.commit() + return CrudResponseModel(is_success=True, message='清除成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def export_operation_log_list_services(cls, request: Request, operation_log_list: list) -> bytes: + """ + 导出操作日志信息service + + :param request: Request对象 + :param operation_log_list: 操作日志信息列表 + :return: 操作日志信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'operId': '日志编号', + 'title': '系统模块', + 'businessType': '操作类型', + 'method': '方法名称', + 'requestMethod': '请求方式', + 'operName': '操作人员', + 'deptName': '部门名称', + 'operUrl': '请求URL', + 'operIp': '操作地址', + 'operLocation': '操作地点', + 'operParam': '请求参数', + 'jsonResult': '返回参数', + 'status': '操作状态', + 'error_msg': '错误消息', + 'operTime': '操作日期', + 'costTime': '消耗时间(毫秒)', + } + + operation_type_list = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type='sys_oper_type' + ) + operation_type_option = [ + {'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in operation_type_list + ] + operation_type_option_dict = {item.get('value'): item for item in operation_type_option} + + for item in operation_log_list: + if item.get('status') == 0: + item['status'] = '成功' + else: + item['status'] = '失败' + if str(item.get('businessType')) in operation_type_option_dict: + item['businessType'] = operation_type_option_dict.get(str(item.get('businessType'))).get('label') + binary_data = ExcelUtil.export_list2excel(operation_log_list, mapping_dict) + + return binary_data + + +class LoginLogService: + """ + 登录日志管理模块服务层 + """ + + @classmethod + async def get_login_log_list_services( + cls, query_db: AsyncSession, query_object: LoginLogPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取登录日志列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 登录日志列表信息对象 + """ + operation_log_list_result = await LoginLogDao.get_login_log_list(query_db, query_object, is_page) + + return operation_log_list_result + + @classmethod + async def add_login_log_services(cls, query_db: AsyncSession, page_object: LogininforModel) -> CrudResponseModel: + """ + 新增登录日志service + + :param query_db: orm对象 + :param page_object: 新增登录日志对象 + :return: 新增登录日志校验结果 + """ + try: + await LoginLogDao.add_login_log_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def delete_login_log_services( + cls, query_db: AsyncSession, page_object: DeleteLoginLogModel + ) -> CrudResponseModel: + """ + 删除操作日志信息service + + :param query_db: orm对象 + :param page_object: 删除操作日志对象 + :return: 删除操作日志校验结果 + """ + if page_object.info_ids: + info_id_list = page_object.info_ids.split(',') + try: + for info_id in info_id_list: + await LoginLogDao.delete_login_log_dao(query_db, LogininforModel(infoId=info_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入登录日志id为空') + + @classmethod + async def clear_login_log_services(cls, query_db: AsyncSession) -> CrudResponseModel: + """ + 清除操作日志信息service + + :param query_db: orm对象 + :return: 清除操作日志校验结果 + """ + try: + await LoginLogDao.clear_login_log_dao(query_db) + await query_db.commit() + return CrudResponseModel(is_success=True, message='清除成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def unlock_user_services(cls, request: Request, unlock_user: UnlockUser) -> CrudResponseModel: + locked_user = await request.app.state.redis.get(f'account_lock:{unlock_user.user_name}') + if locked_user: + await request.app.state.redis.delete(f'account_lock:{unlock_user.user_name}') + return CrudResponseModel(is_success=True, message='解锁成功') + raise ServiceException(message='该用户未锁定') + + @staticmethod + async def export_login_log_list_services(login_log_list: list) -> bytes: + """ + 导出登录日志信息service + + :param login_log_list: 登录日志信息列表 + :return: 登录日志信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'infoId': '访问编号', + 'userName': '用户名称', + 'ipaddr': '登录地址', + 'loginLocation': '登录地点', + 'browser': '浏览器', + 'os': '操作系统', + 'status': '登录状态', + 'msg': '操作信息', + 'loginTime': '登录日期', + } + + for item in login_log_list: + if item.get('status') == '0': + item['status'] = '成功' + else: + item['status'] = '失败' + binary_data = ExcelUtil.export_list2excel(login_log_list, mapping_dict) + + return binary_data + + +class LogQueueService: + """ + 日志队列服务 + """ + + @classmethod + def _build_event_id(cls, request_id: str, log_type: str, source: str) -> str: + """ + 生成日志事件唯一标识 + + :param request_id: 请求唯一标识 + :param log_type: 日志类型 + :param source: 日志来源 + :return: 事件唯一标识 + """ + if not request_id: + return uuid.uuid4().hex + base = f'{request_id}:{log_type}:{source}' + return hashlib.md5(base.encode('utf-8')).hexdigest() + + @classmethod + async def _xadd_event(cls, redis: aioredis.Redis, event_type: str, payload: dict, source: str) -> None: + """ + 写入日志事件到Redis Streams + + :param redis: Redis连接对象 + :param event_type: 事件类型 + :param payload: 事件负载 + :param source: 日志来源 + :return: None + """ + request_id = TraceCtx.get_request_id() + trace_id = TraceCtx.get_trace_id() + span_id = TraceCtx.get_span_id() + event_id = cls._build_event_id(request_id, event_type, source) + await redis.xadd( + LogConfig.log_stream_key, + { + 'event_id': event_id, + 'event_type': event_type, + 'request_id': request_id, + 'trace_id': trace_id, + 'span_id': span_id, + 'payload': json.dumps(payload, ensure_ascii=False, default=str), + }, + maxlen=LogConfig.log_stream_maxlen, + approximate=True, + ) + + @classmethod + async def enqueue_login_log(cls, request: Request, login_log: LogininforModel, source: str) -> None: + """ + 登录日志入队 + + :param request: Request对象 + :param login_log: 登录日志模型 + :param source: 日志来源 + :return: None + """ + payload = LogSanitizer.sanitize_data(login_log.model_dump(by_alias=True, exclude_none=True)) + await cls._xadd_event(request.app.state.redis, 'login', payload, source) + + @classmethod + async def enqueue_operation_log(cls, request: Request, operation_log: OperLogModel, source: str) -> None: + """ + 操作日志入队 + + :param request: Request对象 + :param operation_log: 操作日志模型 + :param source: 日志来源 + :return: None + """ + payload = LogSanitizer.sanitize_data(operation_log.model_dump(by_alias=True, exclude_none=True)) + await cls._xadd_event(request.app.state.redis, 'operation', payload, source) + + +class LogAggregatorService: + """ + 日志聚合消费服务 + """ + + @classmethod + async def _ensure_group(cls, redis: aioredis.Redis) -> None: + """ + 初始化消费组 + + :param redis: Redis连接对象 + :return: None + """ + try: + await redis.xgroup_create( + name=LogConfig.log_stream_key, + groupname=LogConfig.log_stream_group, + id='0-0', + mkstream=True, + ) + except Exception as exc: + if 'BUSYGROUP' not in str(exc): + raise + + @classmethod + async def _acquire_dedup(cls, redis: aioredis.Redis, event_id: str) -> bool: + """ + 获取去重锁 + + :param redis: Redis连接对象 + :param event_id: 事件唯一标识 + :return: 是否获取成功 + """ + if not event_id: + return False + key = f'{LogConfig.log_stream_dedup_prefix}:{event_id}' + return await redis.set(key, '1', nx=True, ex=LogConfig.log_stream_dedup_ttl) + + @classmethod + async def _release_dedup(cls, redis: aioredis.Redis, event_id: str) -> None: + """ + 释放去重锁 + + :param redis: Redis连接对象 + :param event_id: 事件唯一标识 + :return: None + """ + if not event_id: + return + await redis.delete(f'{LogConfig.log_stream_dedup_prefix}:{event_id}') + + @classmethod + async def _claim_pending(cls, redis: aioredis.Redis, consumer_name: str) -> None: + """ + 认领并处理超时未确认的消息 + + :param redis: Redis连接对象 + :param consumer_name: 消费者名称 + :return: None + """ + if LogConfig.log_stream_claim_idle_ms <= 0: + return + start_id = '0-0' + while True: + result = await redis.xautoclaim( + name=LogConfig.log_stream_key, + groupname=LogConfig.log_stream_group, + consumername=consumer_name, + min_idle_time=LogConfig.log_stream_claim_idle_ms, + start_id=start_id, + count=LogConfig.log_stream_claim_batch_size, + ) + if not result: + return + next_start_id, messages = result[0], result[1] + if messages: + await cls._process_messages(redis, LogConfig.log_stream_key, messages) + if not messages or next_start_id == start_id: + return + start_id = next_start_id + + @classmethod + async def consume_stream(cls, redis: aioredis.Redis) -> None: + """ + 消费日志队列 + + :param redis: Redis连接对象 + :return: None + """ + await cls._ensure_group(redis) + consumer_name = f'{LogConfig.log_stream_consumer_prefix}-{os.getpid()}-{uuid.uuid4().hex[:6]}' + last_claim_time = 0.0 + while True: + try: + now = asyncio.get_running_loop().time() + if now - last_claim_time >= LogConfig.log_stream_claim_interval_ms / 1000: + await cls._claim_pending(redis, consumer_name) + last_claim_time = now + result = await redis.xreadgroup( + groupname=LogConfig.log_stream_group, + consumername=consumer_name, + streams={LogConfig.log_stream_key: '>'}, + count=LogConfig.log_stream_batch_size, + block=LogConfig.log_stream_block_ms, + ) + if not result: + continue + for stream_name, messages in result: + await cls._process_messages(redis, stream_name, messages) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error(f'日志聚合消费异常: {exc}') + await asyncio.sleep(1) + + @classmethod + async def _process_messages(cls, redis: aioredis.Redis, stream_name: str, messages: list[tuple[str, dict]]) -> None: + """ + 处理消息并落库 + + :param redis: Redis连接对象 + :param stream_name: Stream名称 + :param messages: 消息列表 + :return: None + """ + if not messages: + return + async with AsyncSessionLocal() as session: + ack_ids: list[str] = [] + dedup_event_ids: list[str] = [] + try: + for message_id, data in messages: + event_type = data.get('event_type') + event_id = data.get('event_id') + payload_raw = data.get('payload') or '{}' + if event_type not in {'login', 'operation'}: + ack_ids.append(message_id) + continue + acquired = await cls._acquire_dedup(redis, event_id) + if not acquired: + ack_ids.append(message_id) + continue + dedup_event_ids.append(event_id) + payload = json.loads(payload_raw) + if event_type == 'login': + await LoginLogDao.add_login_log_dao(session, LogininforModel(**payload)) + elif event_type == 'operation': + await OperationLogDao.add_operation_log_dao(session, OperLogModel(**payload)) + ack_ids.append(message_id) + if ack_ids: + await session.commit() + await redis.xack(stream_name, LogConfig.log_stream_group, *ack_ids) + except Exception: + await session.rollback() + for event_id in dedup_event_ids: + await cls._release_dedup(redis, event_id) + raise diff --git a/shierfeng-fastapi-backend/module_admin/service/login_service.py b/shierfeng-fastapi-backend/module_admin/service/login_service.py new file mode 100644 index 0000000..a136d90 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/login_service.py @@ -0,0 +1,656 @@ +import random +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from fastapi import Depends, Form, Request +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from jwt.exceptions import InvalidTokenError +from sqlalchemy import Row +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant, MenuConstant +from common.context import RequestContext +from common.enums import RedisInitKeyConfig +from common.vo import CrudResponseModel +from config.env import AppConfig, JwtConfig +from config.get_db import get_db +from exceptions.exception import AuthException, LoginException, ServiceException +from module_admin.dao.login_dao import login_by_account +from module_admin.dao.user_dao import UserDao +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.user_do import SysUser +from module_admin.entity.vo.login_vo import MenuTreeModel, MetaModel, RouterModel, SmsCode, UserLogin, UserRegister +from module_admin.entity.vo.user_vo import AddUserModel, CurrentUserModel, ResetUserModel, TokenData, UserInfoModel +from module_admin.service.user_service import UserService +from utils.client_ip_util import ClientIPUtil +from utils.common_util import CamelCaseUtil +from utils.log_util import logger +from utils.message_util import message_service +from utils.pwd_util import PwdUtil + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='login') + + +class CustomOAuth2PasswordRequestForm(OAuth2PasswordRequestForm): + """ + 自定义OAuth2PasswordRequestForm类,增加验证码及会话编号参数 + """ + + def __init__( + self, + grant_type: str = Form(default=None, pattern='password'), + username: str = Form(), + password: str = Form(), + scope: str = Form(default=''), + client_id: str | None = Form(default=None), + client_secret: str | None = Form(default=None), + code: str | None = Form(default=''), + uuid: str | None = Form(default=''), + login_info: dict[str, str] | None = Form(default=None), + ) -> None: + super().__init__( + grant_type=grant_type, + username=username, + password=password, + scope=scope, + client_id=client_id, + client_secret=client_secret, + ) + self.code = code + self.uuid = uuid + self.login_info = login_info + + +class LoginService: + """ + 登录模块服务层 + """ + + @classmethod + async def authenticate_user( + cls, request: Request, query_db: AsyncSession, login_user: UserLogin + ) -> Row[tuple[SysUser, SysDept]]: + """ + 根据用户名密码校验用户登录 + + :param request: Request对象 + :param query_db: orm对象 + :param login_user: 登录用户对象 + :return: 校验结果 + """ + await cls.__check_login_ip(request) + account_lock = await request.app.state.redis.get( + f'{RedisInitKeyConfig.ACCOUNT_LOCK.key}:{login_user.user_name}' + ) + if login_user.user_name == account_lock: + logger.warning('账号已锁定,请稍后再试') + raise LoginException(data='', message='账号已锁定,请稍后再试') + # 判断请求是否来自于api文档,如果是返回指定格式的结果,用于修复api文档认证成功后token显示undefined的bug + request_from_swagger = ( + request.headers.get('referer').endswith('docs') if request.headers.get('referer') else False + ) + request_from_redoc = ( + request.headers.get('referer').endswith('redoc') if request.headers.get('referer') else False + ) + # 判断是否开启验证码,开启则验证,否则不验证(dev模式下来自API文档的登录请求不检验) + if not login_user.captcha_enabled or ( + (request_from_swagger or request_from_redoc) and AppConfig.app_env == 'dev' + ): + pass + else: + await cls.__check_login_captcha(request, login_user) + user = await login_by_account(query_db, login_user.user_name) + if not user: + logger.warning('用户不存在') + raise LoginException(data='', message='用户不存在') + if not PwdUtil.verify_password(login_user.password, user[0].password): + cache_password_error_count = await request.app.state.redis.get( + f'{RedisInitKeyConfig.PASSWORD_ERROR_COUNT.key}:{login_user.user_name}' + ) + password_error_counted = 0 + if cache_password_error_count: + password_error_counted = cache_password_error_count + password_error_count = int(password_error_counted) + 1 + await request.app.state.redis.set( + f'{RedisInitKeyConfig.PASSWORD_ERROR_COUNT.key}:{login_user.user_name}', + password_error_count, + ex=timedelta(minutes=10), + ) + if password_error_count > CommonConstant.PASSWORD_ERROR_COUNT: + await request.app.state.redis.delete( + f'{RedisInitKeyConfig.PASSWORD_ERROR_COUNT.key}:{login_user.user_name}' + ) + await request.app.state.redis.set( + f'{RedisInitKeyConfig.ACCOUNT_LOCK.key}:{login_user.user_name}', + login_user.user_name, + ex=timedelta(minutes=10), + ) + logger.warning('10分钟内密码已输错超过5次,账号已锁定,请10分钟后再试') + raise LoginException(data='', message='10分钟内密码已输错超过5次,账号已锁定,请10分钟后再试') + logger.warning('密码错误') + raise LoginException(data='', message='密码错误') + if user[0].status == '1': + logger.warning('用户已停用') + raise LoginException(data='', message='用户已停用') + await request.app.state.redis.delete(f'{RedisInitKeyConfig.PASSWORD_ERROR_COUNT.key}:{login_user.user_name}') + return user + + @classmethod + async def __check_login_ip(cls, request: Request) -> bool: + """ + 校验用户登录ip是否在黑名单内 + + :param request: Request对象 + :return: 校验结果 + """ + black_ip_value = await request.app.state.redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.login.blackIPList') + black_ip_list = black_ip_value.split(',') if black_ip_value else [] + if ClientIPUtil.get_client_ip(request) in black_ip_list: + logger.warning('当前IP禁止登录') + raise LoginException(data='', message='当前IP禁止登录') + return True + + @classmethod + async def __check_login_captcha(cls, request: Request, login_user: UserLogin) -> bool: + """ + 校验用户登录验证码 + + :param request: Request对象 + :param login_user: 登录用户对象 + :return: 校验结果 + """ + captcha_value = await request.app.state.redis.get(f'{RedisInitKeyConfig.CAPTCHA_CODES.key}:{login_user.uuid}') + if not captcha_value: + logger.warning('验证码已失效') + raise LoginException(data='', message='验证码已失效') + if login_user.code != str(captcha_value): + logger.warning('验证码错误') + raise LoginException(data='', message='验证码错误') + return True + + @classmethod + async def create_access_token(cls, data: dict, expires_delta: timedelta | None = None) -> str: + """ + 根据登录信息创建当前用户token + + :param data: 登录信息 + :param expires_delta: token有效期 + :return: token + """ + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=30) + to_encode.update({'exp': expire}) + encoded_jwt = jwt.encode(to_encode, JwtConfig.jwt_secret_key, algorithm=JwtConfig.jwt_algorithm) + return encoded_jwt + + @classmethod + async def get_current_user( + cls, request: Request = Request, token: str = Depends(oauth2_scheme), query_db: AsyncSession = Depends(get_db) + ) -> CurrentUserModel: + """ + 根据token获取当前用户信息 + + :param request: Request对象 + :param token: 用户token + :param query_db: orm对象 + :return: 当前用户信息对象 + :raise: 令牌异常AuthException + """ + # if token[:6] != 'Bearer': + # logger.warning("用户token不合法") + # raise AuthException(data="", message="用户token不合法") + try: + if token.startswith('Bearer'): + token = token.split(' ')[1] + payload = jwt.decode(token, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + user_id: str = payload.get('user_id') + session_id: str = payload.get('session_id') + if not user_id: + logger.warning('用户token不合法') + raise AuthException(data='', message='用户token不合法') + token_data = TokenData(user_id=int(user_id)) + except InvalidTokenError as e: + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') from e + query_user = await UserDao.get_user_by_id(query_db, user_id=token_data.user_id) + if query_user.get('user_basic_info') is None: + logger.warning('用户token不合法') + raise AuthException(data='', message='用户token不合法') + if AppConfig.app_same_time_login: + redis_token = await request.app.state.redis.get(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}') + else: + # 此方法可实现同一账号同一时间只能登录一次 + redis_token = await request.app.state.redis.get( + f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{query_user.get("user_basic_info").user_id}' + ) + if token == redis_token: + if AppConfig.app_same_time_login: + await request.app.state.redis.set( + f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}', + redis_token, + ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), + ) + else: + await request.app.state.redis.set( + f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{query_user.get("user_basic_info").user_id}', + redis_token, + ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), + ) + + role_id_list = [item.role_id for item in query_user.get('user_role_info')] + if 1 in role_id_list: # noqa: SIM108 + permissions = ['*:*:*'] + else: + permissions = [row.perms for row in query_user.get('user_menu_info')] + post_ids = ','.join([str(row.post_id) for row in query_user.get('user_post_info')]) + role_ids = ','.join([str(row.role_id) for row in query_user.get('user_role_info')]) + roles = [row.role_key for row in query_user.get('user_role_info')] + is_default_modify_pwd = await cls.__init_password_is_modify( + request, query_user.get('user_basic_info').pwd_update_date + ) + is_password_expired = await cls.__password_is_expired( + request, query_user.get('user_basic_info').pwd_update_date + ) + + current_user = CurrentUserModel( + permissions=permissions, + roles=roles, + user=UserInfoModel( + **CamelCaseUtil.transform_result(query_user.get('user_basic_info')), + postIds=post_ids, + roleIds=role_ids, + dept=CamelCaseUtil.transform_result(query_user.get('user_dept_info')), + role=CamelCaseUtil.transform_result(query_user.get('user_role_info')), + ), + isDefaultModifyPwd=is_default_modify_pwd, + isPasswordExpired=is_password_expired, + ) + # 设置当前用户信息到上下文 + RequestContext.set_current_user(current_user) + return current_user + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') + + @classmethod + async def __init_password_is_modify(cls, request: Request, pwd_update_date: datetime) -> bool: + """ + 判断当前用户是否初始密码登录 + + :param request: Request对象 + :param pwd_update_date: 密码最后更新时间 + :return: 是否初始密码登录 + """ + init_password_is_modify = await request.app.state.redis.get( + f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.initPasswordModify' + ) + return init_password_is_modify == '1' and pwd_update_date is None + + @classmethod + async def __password_is_expired(cls, request: Request, pwd_update_date: datetime) -> bool: + """ + 判断当前用户密码是否过期 + + :param request: Request对象 + :param pwd_update_date: 密码最后更新时间 + :return: 密码是否过期 + """ + password_validate_days = await request.app.state.redis.get( + f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.passwordValidateDays' + ) + if password_validate_days and int(password_validate_days) > 0: + if pwd_update_date is None: + return True + expire_date = pwd_update_date + timedelta(days=int(password_validate_days)) + if datetime.now() > expire_date: + return True + return False + + @classmethod + async def get_current_user_routers(cls, user_id: int, query_db: AsyncSession) -> list[dict[str, Any]]: + """ + 根据用户id获取当前用户路由信息 + + :param user_id: 用户id + :param query_db: orm对象 + :return: 当前用户路由信息对象 + """ + query_user = await UserDao.get_user_by_id(query_db, user_id=user_id) + user_router_menu = sorted( + [ + row + for row in query_user.get('user_menu_info') + if row.menu_type in [MenuConstant.TYPE_DIR, MenuConstant.TYPE_MENU] + ], + key=lambda x: x.order_num, + ) + menus = cls.__generate_menus(0, user_router_menu) + user_router = cls.__generate_user_router_menu(menus) + return [router.model_dump(exclude_unset=True, by_alias=True) for router in user_router] + + @classmethod + def __generate_menus(cls, pid: int, permission_list: list[SysMenu]) -> list[MenuTreeModel]: + """ + 工具方法:根据菜单信息生成菜单信息树形嵌套数据 + + :param pid: 菜单id + :param permission_list: 菜单列表信息 + :return: 菜单信息树形嵌套数据 + """ + menu_list: list[MenuTreeModel] = [] + for permission in permission_list: + if permission.parent_id == pid: + children = cls.__generate_menus(permission.menu_id, permission_list) + menu_list_data = MenuTreeModel(**CamelCaseUtil.transform_result(permission)) + if children: + menu_list_data.children = children + menu_list.append(menu_list_data) + + return menu_list + + @classmethod + def __generate_user_router_menu(cls, permission_list: list[MenuTreeModel]) -> list[RouterModel]: + """ + 工具方法:根据菜单树信息生成路由信息树形嵌套数据 + + :param permission_list: 菜单树列表信息 + :return: 路由信息树形嵌套数据 + """ + router_list: list[RouterModel] = [] + for permission in permission_list: + router = RouterModel( + hidden=permission.visible == '1', + name=RouterUtil.get_router_name(permission), + path=RouterUtil.get_router_path(permission), + component=RouterUtil.get_component(permission), + query=permission.query, + meta=MetaModel( + title=permission.menu_name, + icon=permission.icon, + noCache=permission.is_cache == 1, + link=permission.path if RouterUtil.is_http(permission.path) else None, + ), + ) + c_menus = permission.children + if c_menus and permission.menu_type == MenuConstant.TYPE_DIR: + router.always_show = True + router.redirect = 'noRedirect' + router.children = cls.__generate_user_router_menu(c_menus) + elif RouterUtil.is_menu_frame(permission): + router.meta = None + children_list: list[RouterModel] = [] + children = RouterModel( + path=permission.path, + component=permission.component, + name=RouterUtil.get_route_name(permission.route_name, permission.path), + meta=MetaModel( + title=permission.menu_name, + icon=permission.icon, + noCache=permission.is_cache == 1, + link=permission.path if RouterUtil.is_http(permission.path) else None, + ), + query=permission.query, + ) + children_list.append(children) + router.children = children_list + elif permission.parent_id == 0 and RouterUtil.is_inner_link(permission): + router.meta = MetaModel(title=permission.menu_name, icon=permission.icon) + router.path = '/' + children_list: list[RouterModel] = [] + router_path = RouterUtil.inner_link_replace_each(permission.path) + children = RouterModel( + path=router_path, + component=MenuConstant.INNER_LINK, + name=RouterUtil.get_route_name(permission.route_name, permission.path), + meta=MetaModel( + title=permission.menu_name, + icon=permission.icon, + link=permission.path if RouterUtil.is_http(permission.path) else None, + ), + ) + children_list.append(children) + router.children = children_list + + router_list.append(router) + + return router_list + + @classmethod + async def register_user_services( + cls, request: Request, query_db: AsyncSession, user_register: UserRegister + ) -> CrudResponseModel: + """ + 用户注册services + + :param request: Request对象 + :param query_db: orm对象 + :param user_register: 注册用户对象 + :return: 注册结果 + """ + register_enabled = ( + await request.app.state.redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.registerUser') == 'true' + ) + captcha_enabled = ( + await request.app.state.redis.get(f'{RedisInitKeyConfig.SYS_CONFIG.key}:sys.account.captchaEnabled') + == 'true' + ) + if user_register.password == user_register.confirm_password: + if register_enabled: + if captcha_enabled: + captcha_value = await request.app.state.redis.get( + f'{RedisInitKeyConfig.CAPTCHA_CODES.key}:{user_register.uuid}' + ) + if not captcha_value: + raise ServiceException(message='验证码已失效') + if user_register.code != str(captcha_value): + raise ServiceException(message='验证码错误') + add_user = AddUserModel( + userName=user_register.username, + nickName=user_register.username, + password=PwdUtil.get_password_hash(user_register.password), + pwdUpdateDate=datetime.now(), + ) + result = await UserService.add_user_services(query_db, add_user) + return result + raise ServiceException(message='注册程序已关闭,禁止注册') + raise ServiceException(message='两次输入的密码不一致') + + @classmethod + async def get_sms_code_services(cls, request: Request, query_db: AsyncSession, user: ResetUserModel) -> SmsCode: + """ + 获取短信验证码service + + :param request: Request对象 + :param query_db: orm对象 + :param user: 用户对象 + :return: 短信验证码对象 + """ + redis_sms_result = await request.app.state.redis.get(f'{RedisInitKeyConfig.SMS_CODE.key}:{user.session_id}') + if redis_sms_result: + return SmsCode(is_success=False, sms_code='', session_id='', message='短信验证码仍在有效期内') + is_user = await UserDao.get_user_by_name(query_db, user.user_name) + if is_user: + sms_code = str(random.randint(100000, 999999)) + session_id = str(uuid.uuid4()) + await request.app.state.redis.set( + f'{RedisInitKeyConfig.SMS_CODE.key}:{session_id}', sms_code, ex=timedelta(minutes=2) + ) + # 此处模拟调用短信服务 + message_service(sms_code) + + return SmsCode(is_success=True, sms_code=sms_code, session_id=session_id, message='获取成功') + + return SmsCode(is_success=False, sms_code='', session_id='', message='用户不存在') + + @classmethod + async def forget_user_services( + cls, request: Request, query_db: AsyncSession, forget_user: ResetUserModel + ) -> CrudResponseModel: + """ + 用户忘记密码services + + :param request: Request对象 + :param query_db: orm对象 + :param forget_user: 重置用户对象 + :return: 重置结果 + """ + redis_sms_result = await request.app.state.redis.get( + f'{RedisInitKeyConfig.SMS_CODE.key}:{forget_user.session_id}' + ) + if forget_user.sms_code == redis_sms_result: + forget_user.password = PwdUtil.get_password_hash(forget_user.password) + forget_user.user_id = (await UserDao.get_user_by_name(query_db, forget_user.user_name)).user_id + edit_result = await UserService.reset_user_services(query_db, forget_user) + result = edit_result.dict() + elif not redis_sms_result: + result = {'is_success': False, 'message': '短信验证码已过期'} + else: + await request.app.state.redis.delete(f'{RedisInitKeyConfig.SMS_CODE.key}:{forget_user.session_id}') + result = {'is_success': False, 'message': '短信验证码不正确'} + + return CrudResponseModel(**result) + + @classmethod + async def logout_services(cls, request: Request, token_id: str) -> bool: + """ + 退出登录services + + :param request: Request对象 + :param token_id: 令牌编号 + :return: 退出登录结果 + """ + await request.app.state.redis.delete(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{token_id}') + # await request.app.state.redis.delete(f'{current_user.user.user_id}_access_token') + # await request.app.state.redis.delete(f'{current_user.user.user_id}_session_id') + + return True + + +class RouterUtil: + """ + 路由处理工具类 + """ + + @classmethod + def get_router_name(cls, menu: MenuTreeModel) -> str: + """ + 获取路由名称 + + :param menu: 菜单数对象 + :return: 路由名称 + """ + # 非外链并且是一级目录(类型为目录) + if cls.is_menu_frame(menu): + return '' + + return cls.get_route_name(menu.route_name, menu.path) + + @classmethod + def get_route_name(cls, name: str, path: str) -> str: + """ + 获取路由名称,如没有配置路由名称则取路由地址 + + :param name: 路由名称 + :param path: 路由地址 + :return: 路由名称(驼峰格式) + """ + router_name = name if name else path + return router_name.capitalize() + + @classmethod + def get_router_path(cls, menu: MenuTreeModel) -> str | None: + """ + 获取路由地址 + + :param menu: 菜单数对象 + :return: 路由地址 + """ + # 内链打开外网方式 + router_path = menu.path + if menu.parent_id != 0 and cls.is_inner_link(menu): + router_path = cls.inner_link_replace_each(router_path) + # 非外链并且是一级目录(类型为目录) + if menu.parent_id == 0 and menu.menu_type == MenuConstant.TYPE_DIR and menu.is_frame == MenuConstant.NO_FRAME: + router_path = f'/{menu.path}' + # 非外链并且是一级目录(类型为菜单) + elif cls.is_menu_frame(menu): + router_path = '/' + return router_path + + @classmethod + def get_component(cls, menu: MenuTreeModel) -> str: + """ + 获取组件信息 + + :param menu: 菜单数对象 + :return: 组件信息 + """ + component = MenuConstant.LAYOUT + if menu.component and not cls.is_menu_frame(menu): + component = menu.component + elif (menu.component is None or menu.component == '') and menu.parent_id != 0 and cls.is_inner_link(menu): + component = MenuConstant.INNER_LINK + elif (menu.component is None or menu.component == '') and cls.is_parent_view(menu): + component = MenuConstant.PARENT_VIEW + return component + + @classmethod + def is_menu_frame(cls, menu: MenuTreeModel) -> bool: + """ + 判断是否为菜单内部跳转 + + :param menu: 菜单数对象 + :return: 是否为菜单内部跳转 + """ + return ( + menu.parent_id == 0 and menu.menu_type == MenuConstant.TYPE_MENU and menu.is_frame == MenuConstant.NO_FRAME + ) + + @classmethod + def is_inner_link(cls, menu: MenuTreeModel) -> bool: + """ + 判断是否为内链组件 + + :param menu: 菜单数对象 + :return: 是否为内链组件 + """ + return menu.is_frame == MenuConstant.NO_FRAME and cls.is_http(menu.path) + + @classmethod + def is_parent_view(cls, menu: MenuTreeModel) -> bool: + """ + 判断是否为parent_view组件 + + :param menu: 菜单数对象 + :return: 是否为parent_view组件 + """ + return menu.parent_id != 0 and menu.menu_type == MenuConstant.TYPE_DIR + + @classmethod + def is_http(cls, link: str) -> bool: + """ + 判断是否为http(s)://开头 + + :param link: 链接 + :return: 是否为http(s)://开头 + """ + return link.startswith((CommonConstant.HTTP, CommonConstant.HTTPS)) + + @classmethod + def inner_link_replace_each(cls, path: str) -> str: + """ + 内链域名特殊字符替换 + + :param path: 内链域名 + :return: 替换后的内链域名 + """ + old_values = [CommonConstant.HTTP, CommonConstant.HTTPS, CommonConstant.WWW, '.', ':'] + new_values = ['', '', '', '/', '/'] + for old, new in zip(old_values, new_values, strict=False): + path = path.replace(old, new) + return path diff --git a/shierfeng-fastapi-backend/module_admin/service/menu_service.py b/shierfeng-fastapi-backend/module_admin/service/menu_service.py new file mode 100644 index 0000000..734bfe6 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/menu_service.py @@ -0,0 +1,217 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant, MenuConstant +from common.vo import CrudResponseModel +from exceptions.exception import ServiceException, ServiceWarning +from module_admin.dao.menu_dao import MenuDao +from module_admin.dao.role_dao import RoleDao +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.vo.menu_vo import DeleteMenuModel, MenuModel, MenuQueryModel, MenuTreeModel +from module_admin.entity.vo.role_vo import RoleMenuQueryModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from utils.common_util import CamelCaseUtil +from utils.string_util import StringUtil + + +class MenuService: + """ + 菜单管理模块服务层 + """ + + @classmethod + async def get_menu_tree_services( + cls, query_db: AsyncSession, current_user: CurrentUserModel | None = None + ) -> list[dict[str, Any]]: + """ + 获取菜单树信息service + + :param query_db: orm对象 + :param current_user: 当前用户对象 + :return: 菜单树信息对象 + """ + menu_list_result = await MenuDao.get_menu_list_for_tree( + query_db, current_user.user.user_id, current_user.user.role + ) + menu_tree_model_result = cls.list_to_tree(menu_list_result) + menu_tree_result = [menu.model_dump(exclude_unset=True, by_alias=True) for menu in menu_tree_model_result] + + return menu_tree_result + + @classmethod + async def get_role_menu_tree_services( + cls, query_db: AsyncSession, role_id: int, current_user: CurrentUserModel | None = None + ) -> RoleMenuQueryModel: + """ + 根据角色id获取菜单树信息service + + :param query_db: orm对象 + :param role_id: 角色id + :param current_user: 当前用户对象 + :return: 当前角色id的菜单树信息对象 + """ + menu_list_result = await MenuDao.get_menu_list_for_tree( + query_db, current_user.user.user_id, current_user.user.role + ) + menu_tree_result = cls.list_to_tree(menu_list_result) + role = await RoleDao.get_role_detail_by_id(query_db, role_id) + role_menu_list = await RoleDao.get_role_menu_dao(query_db, role) + checked_keys = [row.menu_id for row in role_menu_list] + result = RoleMenuQueryModel(menus=menu_tree_result, checkedKeys=checked_keys) + + return result + + @classmethod + async def get_menu_list_services( + cls, query_db: AsyncSession, page_object: MenuQueryModel, current_user: CurrentUserModel | None = None + ) -> list[dict[str, Any]]: + """ + 获取菜单列表信息service + + :param query_db: orm对象 + :param page_object: 分页查询参数对象 + :param current_user: 当前用户对象 + :return: 菜单列表信息对象 + """ + menu_list_result = await MenuDao.get_menu_list( + query_db, page_object, current_user.user.user_id, current_user.user.role + ) + + return CamelCaseUtil.transform_result(menu_list_result) + + @classmethod + async def check_menu_name_unique_services(cls, query_db: AsyncSession, page_object: MenuModel) -> bool: + """ + 校验菜单名称是否唯一service + + :param query_db: orm对象 + :param page_object: 菜单对象 + :return: 校验结果 + """ + menu_id = -1 if page_object.menu_id is None else page_object.menu_id + menu = await MenuDao.get_menu_detail_by_info(query_db, MenuModel(menuName=page_object.menu_name)) + if menu and menu.menu_id != menu_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_menu_services(cls, query_db: AsyncSession, page_object: MenuModel) -> CrudResponseModel: + """ + 新增菜单信息service + + :param query_db: orm对象 + :param page_object: 新增菜单对象 + :return: 新增菜单校验结果 + """ + if not await cls.check_menu_name_unique_services(query_db, page_object): + raise ServiceException(message=f'新增菜单{page_object.menu_name}失败,菜单名称已存在') + if page_object.is_frame == MenuConstant.YES_FRAME and not StringUtil.is_http(page_object.path): + raise ServiceException(message=f'新增菜单{page_object.menu_name}失败,地址必须以http(s)://开头') + try: + await MenuDao.add_menu_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_menu_services(cls, query_db: AsyncSession, page_object: MenuModel) -> CrudResponseModel: + """ + 编辑菜单信息service + + :param query_db: orm对象 + :param page_object: 编辑部门对象 + :return: 编辑菜单校验结果 + """ + edit_menu = page_object.model_dump(exclude_unset=True) + menu_info = await cls.menu_detail_services(query_db, page_object.menu_id) + if menu_info.menu_id: + if not await cls.check_menu_name_unique_services(query_db, page_object): + raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,菜单名称已存在') + if page_object.is_frame == MenuConstant.YES_FRAME and not StringUtil.is_http(page_object.path): + raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,地址必须以http(s)://开头') + if page_object.menu_id == page_object.parent_id: + raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,上级菜单不能选择自己') + try: + await MenuDao.edit_menu_dao(query_db, edit_menu) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='菜单不存在') + + @classmethod + async def delete_menu_services(cls, query_db: AsyncSession, page_object: DeleteMenuModel) -> CrudResponseModel: + """ + 删除菜单信息service + + :param query_db: orm对象 + :param page_object: 删除菜单对象 + :return: 删除菜单校验结果 + """ + if page_object.menu_ids: + menu_id_list = page_object.menu_ids.split(',') + try: + for menu_id in menu_id_list: + if (await MenuDao.has_child_by_menu_id_dao(query_db, int(menu_id))) > 0: + raise ServiceWarning(message='存在子菜单,不允许删除') + if (await MenuDao.check_menu_exist_role_dao(query_db, int(menu_id))) > 0: + raise ServiceWarning(message='菜单已分配,不允许删除') + await MenuDao.delete_menu_dao(query_db, MenuModel(menuId=menu_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入菜单id为空') + + @classmethod + async def menu_detail_services(cls, query_db: AsyncSession, menu_id: int) -> MenuModel: + """ + 获取菜单详细信息service + + :param query_db: orm对象 + :param menu_id: 菜单id + :return: 菜单id对应的信息 + """ + menu = await MenuDao.get_menu_detail_by_id(query_db, menu_id=menu_id) + result = MenuModel(**CamelCaseUtil.transform_result(menu)) if menu else MenuModel() + + return result + + @classmethod + def list_to_tree(cls, permission_list: Sequence[SysMenu]) -> list[MenuTreeModel]: + """ + 工具方法:根据菜单列表信息生成树形嵌套数据 + + :param permission_list: 菜单列表信息 + :return: 菜单树形嵌套数据 + """ + _permission_list = [ + MenuTreeModel(id=item.menu_id, label=item.menu_name, parentId=item.parent_id) for item in permission_list + ] + # 转成id为key的字典 + mapping: dict[int, MenuTreeModel] = dict(zip([i.id for i in _permission_list], _permission_list, strict=False)) + + # 树容器 + container: list[MenuTreeModel] = [] + + for d in _permission_list: + # 如果找不到父级项,则是根节点 + parent = mapping.get(d.parent_id) + if parent is None: + container.append(d) + else: + children: list[MenuTreeModel] = parent.children + if not children: + children = [] + children.append(d) + parent.children = children + + return container diff --git a/shierfeng-fastapi-backend/module_admin/service/notice_service.py b/shierfeng-fastapi-backend/module_admin/service/notice_service.py new file mode 100644 index 0000000..55c48dc --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/notice_service.py @@ -0,0 +1,126 @@ +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_admin.dao.notice_dao import NoticeDao +from module_admin.entity.vo.notice_vo import DeleteNoticeModel, NoticeModel, NoticePageQueryModel +from utils.common_util import CamelCaseUtil + + +class NoticeService: + """ + 通知公告管理模块服务层 + """ + + @classmethod + async def get_notice_list_services( + cls, query_db: AsyncSession, query_object: NoticePageQueryModel, is_page: bool = True + ) -> PageModel | list[dict[str, Any]]: + """ + 获取通知公告列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 通知公告列表信息对象 + """ + notice_list_result = await NoticeDao.get_notice_list(query_db, query_object, is_page) + + return notice_list_result + + @classmethod + async def check_notice_unique_services(cls, query_db: AsyncSession, page_object: NoticeModel) -> bool: + """ + 校验通知公告是否存在service + + :param query_db: orm对象 + :param page_object: 通知公告对象 + :return: 校验结果 + """ + notice_id = -1 if page_object.notice_id is None else page_object.notice_id + notice = await NoticeDao.get_notice_detail_by_info(query_db, page_object) + if notice and notice.notice_id != notice_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_notice_services(cls, query_db: AsyncSession, page_object: NoticeModel) -> CrudResponseModel: + """ + 新增通知公告信息service + + :param query_db: orm对象 + :param page_object: 新增通知公告对象 + :return: 新增通知公告校验结果 + """ + if not await cls.check_notice_unique_services(query_db, page_object): + raise ServiceException(message=f'新增通知公告{page_object.notice_title}失败,通知公告已存在') + try: + await NoticeDao.add_notice_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_notice_services(cls, query_db: AsyncSession, page_object: NoticeModel) -> CrudResponseModel: + """ + 编辑通知公告信息service + + :param query_db: orm对象 + :param page_object: 编辑通知公告对象 + :return: 编辑通知公告校验结果 + """ + edit_notice = page_object.model_dump(exclude_unset=True) + notice_info = await cls.notice_detail_services(query_db, page_object.notice_id) + if notice_info.notice_id: + if not await cls.check_notice_unique_services(query_db, page_object): + raise ServiceException(message=f'修改通知公告{page_object.notice_title}失败,通知公告已存在') + try: + await NoticeDao.edit_notice_dao(query_db, edit_notice) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='通知公告不存在') + + @classmethod + async def delete_notice_services(cls, query_db: AsyncSession, page_object: DeleteNoticeModel) -> CrudResponseModel: + """ + 删除通知公告信息service + + :param query_db: orm对象 + :param page_object: 删除通知公告对象 + :return: 删除通知公告校验结果 + """ + if page_object.notice_ids: + notice_id_list = page_object.notice_ids.split(',') + try: + for notice_id in notice_id_list: + await NoticeDao.delete_notice_dao(query_db, NoticeModel(noticeId=notice_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入通知公告id为空') + + @classmethod + async def notice_detail_services(cls, query_db: AsyncSession, notice_id: int) -> NoticeModel: + """ + 获取通知公告详细信息service + + :param query_db: orm对象 + :param notice_id: 通知公告id + :return: 通知公告id对应的信息 + """ + notice = await NoticeDao.get_notice_detail_by_id(query_db, notice_id=notice_id) + result = NoticeModel(**CamelCaseUtil.transform_result(notice)) if notice else NoticeModel() + + return result diff --git a/shierfeng-fastapi-backend/module_admin/service/online_service.py b/shierfeng-fastapi-backend/module_admin/service/online_service.py new file mode 100644 index 0000000..6ed5070 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/online_service.py @@ -0,0 +1,78 @@ +from typing import Any + +import jwt +from fastapi import Request + +from common.enums import RedisInitKeyConfig +from common.vo import CrudResponseModel +from config.env import AppConfig, JwtConfig +from exceptions.exception import ServiceException +from module_admin.entity.vo.online_vo import DeleteOnlineModel, OnlineQueryModel +from utils.common_util import CamelCaseUtil + + +class OnlineService: + """ + 在线用户管理模块服务层 + """ + + @classmethod + async def get_online_list_services(cls, request: Request, query_object: OnlineQueryModel) -> list[dict[str, Any]]: + """ + 获取在线用户表信息service + + :param request: Request对象 + :param query_object: 查询参数对象 + :return: 在线用户列表信息 + """ + access_token_keys = await request.app.state.redis.keys(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}*') + if not access_token_keys: + access_token_keys = [] + access_token_values_list = [await request.app.state.redis.get(key) for key in access_token_keys] + online_info_list = [] + for item in access_token_values_list: + payload = jwt.decode(item, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + online_dict = { + 'token_id': payload.get('session_id') if AppConfig.app_same_time_login else payload.get('user_id'), + 'user_name': payload.get('user_name'), + 'dept_name': payload.get('dept_name'), + 'ipaddr': payload.get('login_info').get('ipaddr'), + 'login_location': payload.get('login_info').get('loginLocation'), + 'browser': payload.get('login_info').get('browser'), + 'os': payload.get('login_info').get('os'), + 'login_time': payload.get('login_info').get('loginTime'), + } + if query_object.user_name and not query_object.ipaddr: + if query_object.user_name == payload.get('user_name'): + online_info_list = [online_dict] + break + elif not query_object.user_name and query_object.ipaddr: + if query_object.ipaddr == payload.get('login_info').get('ipaddr'): + online_info_list = [online_dict] + break + elif query_object.user_name and query_object.ipaddr: + if query_object.user_name == payload.get('user_name') and query_object.ipaddr == payload.get( + 'login_info' + ).get('ipaddr'): + online_info_list = [online_dict] + break + else: + online_info_list.append(online_dict) + + return CamelCaseUtil.transform_result(online_info_list) + + @classmethod + async def delete_online_services(cls, request: Request, page_object: DeleteOnlineModel) -> CrudResponseModel: + """ + 强退在线用户信息service + + :param request: Request对象 + :param page_object: 强退在线用户对象 + :return: 强退在线用户校验结果 + """ + if page_object.token_ids: + token_id_list = page_object.token_ids.split(',') + for token_id in token_id_list: + await request.app.state.redis.delete(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{token_id}') + return CrudResponseModel(is_success=True, message='强退成功') + raise ServiceException(message='传入session_id为空') diff --git a/shierfeng-fastapi-backend/module_admin/service/post_service.py b/shierfeng-fastapi-backend/module_admin/service/post_service.py new file mode 100644 index 0000000..9a11f23 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/post_service.py @@ -0,0 +1,180 @@ +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_admin.dao.post_dao import PostDao +from module_admin.entity.vo.post_vo import DeletePostModel, PostModel, PostPageQueryModel +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil + + +class PostService: + """ + 岗位管理模块服务层 + """ + + @classmethod + async def get_post_list_services( + cls, query_db: AsyncSession, query_object: PostPageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取岗位列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 岗位列表信息对象 + """ + post_list_result = await PostDao.get_post_list(query_db, query_object, is_page) + + return post_list_result + + @classmethod + async def check_post_name_unique_services(cls, query_db: AsyncSession, page_object: PostModel) -> bool: + """ + 检查岗位名称是否唯一service + + :param query_db: orm对象 + :param page_object: 岗位对象 + :return: 校验结果 + """ + post_id = -1 if page_object.post_id is None else page_object.post_id + post = await PostDao.get_post_detail_by_info(query_db, PostModel(postName=page_object.post_name)) + if post and post.post_id != post_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def check_post_code_unique_services(cls, query_db: AsyncSession, page_object: PostModel) -> bool: + """ + 检查岗位编码是否唯一service + + :param query_db: orm对象 + :param page_object: 岗位对象 + :return: 校验结果 + """ + post_id = -1 if page_object.post_id is None else page_object.post_id + post = await PostDao.get_post_detail_by_info(query_db, PostModel(postCode=page_object.post_code)) + if post and post.post_id != post_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_post_services(cls, query_db: AsyncSession, page_object: PostModel) -> CrudResponseModel: + """ + 新增岗位信息service + + :param query_db: orm对象 + :param page_object: 新增岗位对象 + :return: 新增岗位校验结果 + """ + if not await cls.check_post_name_unique_services(query_db, page_object): + raise ServiceException(message=f'新增岗位{page_object.post_name}失败,岗位名称已存在') + if not await cls.check_post_code_unique_services(query_db, page_object): + raise ServiceException(message=f'新增岗位{page_object.post_name}失败,岗位编码已存在') + try: + await PostDao.add_post_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_post_services(cls, query_db: AsyncSession, page_object: PostModel) -> CrudResponseModel: + """ + 编辑岗位信息service + + :param query_db: orm对象 + :param page_object: 编辑岗位对象 + :return: 编辑岗位校验结果 + """ + edit_post = page_object.model_dump(exclude_unset=True) + post_info = await cls.post_detail_services(query_db, page_object.post_id) + if post_info.post_id: + if not await cls.check_post_name_unique_services(query_db, page_object): + raise ServiceException(message=f'修改岗位{page_object.post_name}失败,岗位名称已存在') + if not await cls.check_post_code_unique_services(query_db, page_object): + raise ServiceException(message=f'修改岗位{page_object.post_name}失败,岗位编码已存在') + try: + await PostDao.edit_post_dao(query_db, edit_post) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='岗位不存在') + + @classmethod + async def delete_post_services(cls, query_db: AsyncSession, page_object: DeletePostModel) -> CrudResponseModel: + """ + 删除岗位信息service + + :param query_db: orm对象 + :param page_object: 删除岗位对象 + :return: 删除岗位校验结果 + """ + if page_object.post_ids: + post_id_list = page_object.post_ids.split(',') + try: + for post_id in post_id_list: + post = await cls.post_detail_services(query_db, int(post_id)) + if (await PostDao.count_user_post_dao(query_db, int(post_id))) > 0: + raise ServiceException(message=f'{post.post_name}已分配,不能删除') + await PostDao.delete_post_dao(query_db, PostModel(postId=post_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入岗位id为空') + + @classmethod + async def post_detail_services(cls, query_db: AsyncSession, post_id: int) -> PostModel: + """ + 获取岗位详细信息service + + :param query_db: orm对象 + :param post_id: 岗位id + :return: 岗位id对应的信息 + """ + post = await PostDao.get_post_detail_by_id(query_db, post_id=post_id) + result = PostModel(**CamelCaseUtil.transform_result(post)) if post else PostModel() + + return result + + @staticmethod + async def export_post_list_services(post_list: list) -> bytes: + """ + 导出岗位信息service + + :param post_list: 岗位信息列表 + :return: 岗位信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'postId': '岗位编号', + 'postCode': '岗位编码', + 'postName': '岗位名称', + 'postSort': '显示顺序', + 'status': '状态', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + for item in post_list: + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '停用' + binary_data = ExcelUtil.export_list2excel(post_list, mapping_dict) + + return binary_data diff --git a/shierfeng-fastapi-backend/module_admin/service/role_service.py b/shierfeng-fastapi-backend/module_admin/service/role_service.py new file mode 100644 index 0000000..b7cd0a2 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/role_service.py @@ -0,0 +1,368 @@ +from typing import Any + +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_admin.dao.role_dao import RoleDao +from module_admin.dao.user_dao import UserDao +from module_admin.entity.vo.role_vo import ( + AddRoleModel, + DeleteRoleModel, + RoleDeptModel, + RoleDeptQueryModel, + RoleMenuModel, + RoleModel, + RolePageQueryModel, +) +from module_admin.entity.vo.user_vo import UserInfoModel, UserRolePageQueryModel +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil + + +class RoleService: + """ + 角色管理模块服务层 + """ + + @classmethod + async def get_role_select_option_services(cls, query_db: AsyncSession) -> list[dict[str, Any]]: + """ + 获取角色列表不分页信息service + + :param query_db: orm对象 + :return: 角色列表不分页信息对象 + """ + role_list_result = await RoleDao.get_role_select_option_dao(query_db) + + return CamelCaseUtil.transform_result(role_list_result) + + @classmethod + async def get_role_dept_tree_services(cls, query_db: AsyncSession, role_id: int) -> RoleDeptQueryModel: + """ + 根据角色id获取部门树信息service + + :param query_db: orm对象 + :param role_id: 角色id + :return: 当前角色id的部门树信息对象 + """ + role = await cls.role_detail_services(query_db, role_id) + role_dept_list = await RoleDao.get_role_dept_dao(query_db, role) + checked_keys = [row.dept_id for row in role_dept_list] + result = RoleDeptQueryModel(checkedKeys=checked_keys) + + return result + + @classmethod + async def get_role_list_services( + cls, + query_db: AsyncSession, + query_object: RolePageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel | list[dict[str, Any]]: + """ + 获取角色列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 角色列表信息对象 + """ + role_list_result = await RoleDao.get_role_list(query_db, query_object, data_scope_sql, is_page) + + return role_list_result + + @classmethod + async def check_role_allowed_services(cls, check_role: RoleModel) -> CrudResponseModel: + """ + 校验角色是否允许操作service + + :param check_role: 角色信息 + :return: 校验结果 + """ + if check_role.admin: + raise ServiceException(message='不允许操作超级管理员角色') + return CrudResponseModel(is_success=True, message='校验通过') + + @classmethod + async def check_role_data_scope_services( + cls, query_db: AsyncSession, role_ids: str, data_scope_sql: ColumnElement + ) -> None: + """ + 校验角色是否有数据权限service + + :param query_db: orm对象 + :param role_ids: 角色id + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 校验结果 + """ + role_id_list = role_ids.split(',') if role_ids else [] + if role_id_list: + for role_id in role_id_list: + roles = await RoleDao.get_role_list( + query_db, RolePageQueryModel(roleId=int(role_id)), data_scope_sql, is_page=False + ) + if roles: + continue + raise ServiceException(message='没有权限访问角色数据') + + @classmethod + async def check_role_name_unique_services(cls, query_db: AsyncSession, page_object: RoleModel) -> bool: + """ + 校验角色名称是否唯一service + + :param query_db: orm对象 + :param page_object: 角色对象 + :return: 校验结果 + """ + role_id = -1 if page_object.role_id is None else page_object.role_id + role = await RoleDao.get_role_by_info(query_db, RoleModel(roleName=page_object.role_name)) + if role and role.role_id != role_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def check_role_key_unique_services(cls, query_db: AsyncSession, page_object: RoleModel) -> bool: + """ + 校验角色权限字符是否唯一service + + :param query_db: orm对象 + :param page_object: 角色对象 + :return: 校验结果 + """ + role_id = -1 if page_object.role_id is None else page_object.role_id + role = await RoleDao.get_role_by_info(query_db, RoleModel(roleKey=page_object.role_key)) + if role and role.role_id != role_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_role_services(cls, query_db: AsyncSession, page_object: AddRoleModel) -> CrudResponseModel: + """ + 新增角色信息service + + :param query_db: orm对象 + :param page_object: 新增角色对象 + :return: 新增角色校验结果 + """ + add_role = RoleModel(**page_object.model_dump(by_alias=True)) + if not await cls.check_role_name_unique_services(query_db, page_object): + raise ServiceException(message=f'新增角色{page_object.role_name}失败,角色名称已存在') + if not await cls.check_role_key_unique_services(query_db, page_object): + raise ServiceException(message=f'新增角色{page_object.role_name}失败,角色权限已存在') + try: + add_result = await RoleDao.add_role_dao(query_db, add_role) + role_id = add_result.role_id + if page_object.menu_ids: + for menu in page_object.menu_ids: + await RoleDao.add_role_menu_dao(query_db, RoleMenuModel(roleId=role_id, menuId=menu)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_role_services(cls, query_db: AsyncSession, page_object: AddRoleModel) -> CrudResponseModel: + """ + 编辑角色信息service + + :param query_db: orm对象 + :param page_object: 编辑角色对象 + :return: 编辑角色校验结果 + """ + edit_role = page_object.model_dump(exclude_unset=True, exclude={'admin'}) + if page_object.type != 'status': + del edit_role['menu_ids'] + if page_object.type == 'status': + del edit_role['type'] + role_info = await cls.role_detail_services(query_db, edit_role.get('role_id')) + if role_info: + if page_object.type != 'status': + if not await cls.check_role_name_unique_services(query_db, page_object): + raise ServiceException(message=f'修改角色{page_object.role_name}失败,角色名称已存在') + if not await cls.check_role_key_unique_services(query_db, page_object): + raise ServiceException(message=f'修改角色{page_object.role_name}失败,角色权限已存在') + try: + await RoleDao.edit_role_dao(query_db, edit_role) + if page_object.type != 'status': + await RoleDao.delete_role_menu_dao(query_db, RoleMenuModel(roleId=page_object.role_id)) + if page_object.menu_ids: + for menu in page_object.menu_ids: + await RoleDao.add_role_menu_dao( + query_db, RoleMenuModel(roleId=page_object.role_id, menuId=menu) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='角色不存在') + + @classmethod + async def role_datascope_services(cls, query_db: AsyncSession, page_object: AddRoleModel) -> CrudResponseModel: + """ + 分配角色数据权限service + + :param query_db: orm对象 + :param page_object: 角色数据权限对象 + :return: 分配角色数据权限结果 + """ + edit_role = page_object.model_dump(exclude_unset=True, exclude={'admin', 'dept_ids'}) + role_info = await cls.role_detail_services(query_db, page_object.role_id) + if role_info.role_id: + try: + await RoleDao.edit_role_dao(query_db, edit_role) + await RoleDao.delete_role_dept_dao(query_db, RoleDeptModel(roleId=page_object.role_id)) + if page_object.dept_ids and page_object.data_scope == '2': + for dept in page_object.dept_ids: + await RoleDao.add_role_dept_dao( + query_db, RoleDeptModel(roleId=page_object.role_id, deptId=dept) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='分配成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='角色不存在') + + @classmethod + async def delete_role_services(cls, query_db: AsyncSession, page_object: DeleteRoleModel) -> CrudResponseModel: + """ + 删除角色信息service + + :param query_db: orm对象 + :param page_object: 删除角色对象 + :return: 删除角色校验结果 + """ + if page_object.role_ids: + role_id_list = page_object.role_ids.split(',') + try: + for role_id in role_id_list: + role = await cls.role_detail_services(query_db, int(role_id)) + if (await RoleDao.count_user_role_dao(query_db, int(role_id))) > 0: + raise ServiceException(message=f'角色{role.role_name}已分配,不能删除') + role_id_dict = { + 'roleId': role_id, + 'updateBy': page_object.update_by, + 'updateTime': page_object.update_time, + } + await RoleDao.delete_role_menu_dao(query_db, RoleMenuModel(**role_id_dict)) + await RoleDao.delete_role_dept_dao(query_db, RoleDeptModel(**role_id_dict)) + await RoleDao.delete_role_dao(query_db, RoleModel(**role_id_dict)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入角色id为空') + + @classmethod + async def role_detail_services(cls, query_db: AsyncSession, role_id: int) -> RoleModel: + """ + 获取角色详细信息service + + :param query_db: orm对象 + :param role_id: 角色id + :return: 角色id对应的信息 + """ + role = await RoleDao.get_role_detail_by_id(query_db, role_id=role_id) + result = RoleModel(**CamelCaseUtil.transform_result(role)) if role else RoleModel() + + return result + + @staticmethod + async def export_role_list_services(role_list: list) -> bytes: + """ + 导出角色列表信息service + + :param role_list: 角色信息列表 + :return: 角色列表信息对象 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'roleId': '角色编号', + 'roleName': '角色名称', + 'roleKey': '权限字符', + 'roleSort': '显示顺序', + 'status': '状态', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + for item in role_list: + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '停用' + binary_data = ExcelUtil.export_list2excel(role_list, mapping_dict) + + return binary_data + + @classmethod + async def get_role_user_allocated_list_services( + cls, + query_db: AsyncSession, + page_object: UserRolePageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel: + """ + 根据角色id获取已分配用户列表 + + :param query_db: orm对象 + :param page_object: 用户关联角色对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 已分配用户列表 + """ + query_user_list = await UserDao.get_user_role_allocated_list_by_role_id( + query_db, page_object, data_scope_sql, is_page + ) + allocated_list = PageModel[UserInfoModel]( + **{ + **query_user_list.model_dump(by_alias=True), + 'rows': [UserInfoModel(**row) for row in query_user_list.rows], + } + ) + + return allocated_list + + @classmethod + async def get_role_user_unallocated_list_services( + cls, + query_db: AsyncSession, + page_object: UserRolePageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel: + """ + 根据角色id获取未分配用户列表 + + :param query_db: orm对象 + :param page_object: 用户关联角色对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 未分配用户列表 + """ + query_user_list = await UserDao.get_user_role_unallocated_list_by_role_id( + query_db, page_object, data_scope_sql, is_page + ) + unallocated_list = PageModel[UserInfoModel]( + **{ + **query_user_list.model_dump(by_alias=True), + 'rows': [UserInfoModel(**row) for row in query_user_list.rows], + } + ) + + return unallocated_list diff --git a/shierfeng-fastapi-backend/module_admin/service/server_service.py b/shierfeng-fastapi-backend/module_admin/service/server_service.py new file mode 100644 index 0000000..ca84090 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/server_service.py @@ -0,0 +1,103 @@ +import os +import platform +import socket +import time + +import anyio +import psutil + +from module_admin.entity.vo.server_vo import CpuInfo, MemoryInfo, PyInfo, ServerMonitorModel, SysFiles, SysInfo +from utils.common_util import bytes2human + + +class ServerService: + """ + 服务监控模块服务层 + """ + + @staticmethod + async def get_server_monitor_info() -> ServerMonitorModel: + # CPU信息 + # 获取CPU总核心数 + cpu_num = psutil.cpu_count(logical=True) + cpu_usage_percent = psutil.cpu_times_percent() + cpu_used = cpu_usage_percent.user + cpu_sys = cpu_usage_percent.system + cpu_free = cpu_usage_percent.idle + cpu = CpuInfo(cpuNum=cpu_num, used=cpu_used, sys=cpu_sys, free=cpu_free) + + # 内存信息 + memory_info = psutil.virtual_memory() + memory_total = bytes2human(memory_info.total) + memory_used = bytes2human(memory_info.used) + memory_free = bytes2human(memory_info.free) + memory_usage = memory_info.percent + mem = MemoryInfo(total=memory_total, used=memory_used, free=memory_free, usage=memory_usage) + + # 主机信息 + # 获取主机名 + hostname = socket.gethostname() + # 获取IP + computer_ip = socket.gethostbyname(hostname) + os_name = platform.platform() + computer_name = platform.node() + os_arch = platform.machine() + user_dir = str(await anyio.Path.cwd()) + sys = SysInfo( + computerIp=computer_ip, computerName=computer_name, osArch=os_arch, osName=os_name, userDir=user_dir + ) + + # python解释器信息 + current_pid = os.getpid() + current_process = psutil.Process(current_pid) + python_name = current_process.name() + python_version = platform.python_version() + python_home = current_process.exe() + start_time_stamp = current_process.create_time() + start_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time_stamp)) + current_time_stamp = time.time() + difference = current_time_stamp - start_time_stamp + # 将时间差转换为天、小时和分钟数 + days = int(difference // (24 * 60 * 60)) # 每天的秒数 + hours = int((difference % (24 * 60 * 60)) // (60 * 60)) # 每小时的秒数 + minutes = int((difference % (60 * 60)) // 60) # 每分钟的秒数 + run_time = f'{days}天{hours}小时{minutes}分钟' + # 获取当前Python程序的pid + pid = os.getpid() + # 获取该进程的内存信息 + current_process_memory_info = psutil.Process(pid).memory_info() + py = PyInfo( + name=python_name, + version=python_version, + startTime=start_time, + runTime=run_time, + home=python_home, + total=bytes2human(memory_info.available), + used=bytes2human(current_process_memory_info.rss), + free=bytes2human(memory_info.available - current_process_memory_info.rss), + usage=round((current_process_memory_info.rss / memory_info.available) * 100, 2), + ) + + # 磁盘信息 + io = psutil.disk_partitions() + sys_files = [] + for i in io: + try: + o = psutil.disk_usage(i.device) + disk_data = SysFiles( + dirName=i.device, + sysTypeName=i.fstype, + typeName='本地固定磁盘(' + i.mountpoint.replace('\\', '') + ')', + total=bytes2human(o.total), + used=bytes2human(o.used), + free=bytes2human(o.free), + usage=f'{psutil.disk_usage(i.device).percent}%', + ) + sys_files.append(disk_data) + except Exception: # noqa: PERF203 + # 忽略所有异常,跳过有问题的磁盘 + continue + + result = ServerMonitorModel(cpu=cpu, mem=mem, sys=sys, py=py, sysFiles=sys_files) + + return result diff --git a/shierfeng-fastapi-backend/module_admin/service/transport_crypto_service.py b/shierfeng-fastapi-backend/module_admin/service/transport_crypto_service.py new file mode 100644 index 0000000..f581314 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/transport_crypto_service.py @@ -0,0 +1,44 @@ +from fastapi import Request + +from module_admin.entity.vo.transport_crypto_vo import ( + TransportCryptoFrontendConfigModel, + TransportCryptoMonitorModel, + TransportCryptoPublicKeyModel, +) +from utils.transport_crypto_util import TransportCryptoMonitorUtil, TransportCryptoUtil + + +class TransportCryptoService: + """ + 传输加密模块服务层 + """ + + @classmethod + async def get_transport_frontend_config_services(cls) -> TransportCryptoFrontendConfigModel: + """ + 获取前端传输加密运行配置service + + :return: 前端传输加密运行配置 + """ + return TransportCryptoFrontendConfigModel.model_validate(TransportCryptoUtil.build_frontend_config_payload()) + + @classmethod + async def get_transport_public_key_services(cls) -> TransportCryptoPublicKeyModel: + """ + 获取传输加密公钥service + + :return: 传输加密公钥信息 + """ + return TransportCryptoPublicKeyModel.model_validate(TransportCryptoUtil.build_public_key_payload()) + + @classmethod + async def get_transport_crypto_monitor_info_services(cls, request: Request) -> TransportCryptoMonitorModel: + """ + 获取传输加密监控信息service + + :param request: Request对象 + :return: 传输加密监控信息 + """ + transport_crypto_monitor_info = await TransportCryptoMonitorUtil.get_snapshot(request.app) + + return TransportCryptoMonitorModel.model_validate(transport_crypto_monitor_info) diff --git a/shierfeng-fastapi-backend/module_admin/service/user_service.py b/shierfeng-fastapi-backend/module_admin/service/user_service.py new file mode 100644 index 0000000..5d35780 --- /dev/null +++ b/shierfeng-fastapi-backend/module_admin/service/user_service.py @@ -0,0 +1,679 @@ +import io +from datetime import datetime +from typing import Any + +import pandas as pd +from fastapi import Request, UploadFile +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_admin.dao.user_dao import UserDao +from module_admin.entity.do.user_do import SysUserRole +from module_admin.entity.vo.post_vo import PostPageQueryModel +from module_admin.entity.vo.user_vo import ( + AddUserModel, + CrudUserRoleModel, + CurrentUserModel, + DeleteUserModel, + EditUserModel, + ResetUserModel, + SelectedRoleModel, + UserDetailModel, + UserInfoModel, + UserModel, + UserPageQueryModel, + UserPostModel, + UserProfileModel, + UserRoleModel, + UserRoleQueryModel, + UserRoleResponseModel, + UserRowModel, +) +from module_admin.service.config_service import ConfigService +from module_admin.service.dept_service import DeptService +from module_admin.service.post_service import PostService +from module_admin.service.role_service import RoleService +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil +from utils.pwd_util import PwdUtil + + +class UserService: + """ + 用户管理模块服务层 + """ + + @classmethod + async def get_user_list_services( + cls, + query_db: AsyncSession, + query_object: UserPageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel[UserRowModel] | list[dict[str, Any]]: + """ + 获取用户列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: 用户列表信息对象 + """ + query_result = await UserDao.get_user_list(query_db, query_object, data_scope_sql, is_page) + if is_page: + user_list_result = PageModel[UserRowModel]( + **{ + **query_result.model_dump(by_alias=True), + 'rows': [{**row[0], 'dept': row[1]} for row in query_result.rows], + } + ) + else: + user_list_result = [] + if query_result: + user_list_result = [{**row[0], 'dept': row[1]} for row in query_result] + + return user_list_result + + @classmethod + async def check_user_allowed_services(cls, check_user: UserModel) -> CrudResponseModel: + """ + 校验用户是否允许操作service + + :param check_user: 用户信息 + :return: 校验结果 + """ + if check_user.admin: + raise ServiceException(message='不允许操作超级管理员用户') + return CrudResponseModel(is_success=True, message='校验通过') + + @classmethod + async def check_user_data_scope_services( + cls, query_db: AsyncSession, user_id: int, data_scope_sql: ColumnElement + ) -> CrudResponseModel: + """ + 校验用户数据权限service + + :param query_db: orm对象 + :param user_id: 用户id + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 校验结果 + """ + users = await UserDao.get_user_list(query_db, UserPageQueryModel(userId=user_id), data_scope_sql, is_page=False) + if users: + return CrudResponseModel(is_success=True, message='校验通过') + raise ServiceException(message='没有权限访问用户数据') + + @classmethod + async def check_user_name_unique_services(cls, query_db: AsyncSession, page_object: UserModel) -> bool: + """ + 校验用户名是否唯一service + + :param query_db: orm对象 + :param page_object: 用户对象 + :return: 校验结果 + """ + user_id = -1 if page_object.user_id is None else page_object.user_id + user = await UserDao.get_user_by_info(query_db, UserModel(userName=page_object.user_name)) + if user and user.user_id != user_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def check_phonenumber_unique_services(cls, query_db: AsyncSession, page_object: UserModel) -> bool: + """ + 校验用户手机号是否唯一service + + :param query_db: orm对象 + :param page_object: 用户对象 + :return: 校验结果 + """ + user_id = -1 if page_object.user_id is None else page_object.user_id + user = await UserDao.get_user_by_info(query_db, UserModel(phonenumber=page_object.phonenumber)) + if user and user.user_id != user_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def check_email_unique_services(cls, query_db: AsyncSession, page_object: UserModel) -> bool: + """ + 校验用户邮箱是否唯一service + + :param query_db: orm对象 + :param page_object: 用户对象 + :return: 校验结果 + """ + user_id = -1 if page_object.user_id is None else page_object.user_id + user = await UserDao.get_user_by_info(query_db, UserModel(email=page_object.email)) + if user and user.user_id != user_id: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + + @classmethod + async def add_user_services(cls, query_db: AsyncSession, page_object: AddUserModel) -> CrudResponseModel: + """ + 新增用户信息service + + :param query_db: orm对象 + :param page_object: 新增用户对象 + :return: 新增用户校验结果 + """ + add_user = UserModel(**page_object.model_dump(by_alias=True)) + if not await cls.check_user_name_unique_services(query_db, page_object): + raise ServiceException(message=f'新增用户{page_object.user_name}失败,登录账号已存在') + if page_object.phonenumber and not await cls.check_phonenumber_unique_services(query_db, page_object): + raise ServiceException(message=f'新增用户{page_object.user_name}失败,手机号码已存在') + if page_object.email and not await cls.check_email_unique_services(query_db, page_object): + raise ServiceException(message=f'新增用户{page_object.user_name}失败,邮箱账号已存在') + try: + add_result = await UserDao.add_user_dao(query_db, add_user) + user_id = add_result.user_id + if page_object.role_ids: + for role in page_object.role_ids: + await UserDao.add_user_role_dao(query_db, UserRoleModel(userId=user_id, roleId=role)) + if page_object.post_ids: + for post in page_object.post_ids: + await UserDao.add_user_post_dao(query_db, UserPostModel(userId=user_id, postId=post)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + def _deal_edit_user(cls, page_object: EditUserModel, edit_user: dict[str, Any]) -> None: + """ + 处理编辑用户字典 + + :param page_object: 编辑用户对象 + :param edit_user: 编辑用户字典 + :return: None + """ + if page_object.type not in ['status', 'avatar', 'pwd']: + del edit_user['role_ids'] + del edit_user['post_ids'] + del edit_user['role'] + else: + del edit_user['type'] + + @classmethod + async def edit_user_services(cls, query_db: AsyncSession, page_object: EditUserModel) -> CrudResponseModel: + """ + 编辑用户信息service + + :param query_db: orm对象 + :param page_object: 编辑用户对象 + :return: 编辑用户校验结果 + """ + edit_user = page_object.model_dump(exclude_unset=True, exclude={'admin'}) + cls._deal_edit_user(page_object, edit_user) + user_info = await cls.user_detail_services(query_db, edit_user.get('user_id')) + if user_info.data and user_info.data.user_id: + if page_object.type not in ['status', 'avatar', 'pwd']: + if not await cls.check_user_name_unique_services(query_db, page_object): + raise ServiceException(message=f'修改用户{page_object.user_name}失败,登录账号已存在') + if page_object.phonenumber and not await cls.check_phonenumber_unique_services(query_db, page_object): + raise ServiceException(message=f'修改用户{page_object.user_name}失败,手机号码已存在') + if page_object.email and not await cls.check_email_unique_services(query_db, page_object): + raise ServiceException(message=f'修改用户{page_object.user_name}失败,邮箱账号已存在') + try: + await UserDao.edit_user_dao(query_db, edit_user) + if page_object.type not in {'status', 'avatar', 'pwd'}: + await UserDao.delete_user_role_dao(query_db, UserRoleModel(userId=page_object.user_id)) + await UserDao.delete_user_post_dao(query_db, UserPostModel(userId=page_object.user_id)) + if page_object.role_ids: + for role in page_object.role_ids: + await UserDao.add_user_role_dao( + query_db, UserRoleModel(userId=page_object.user_id, roleId=role) + ) + if page_object.post_ids: + for post in page_object.post_ids: + await UserDao.add_user_post_dao( + query_db, UserPostModel(userId=page_object.user_id, postId=post) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='用户不存在') + + @classmethod + async def delete_user_services(cls, query_db: AsyncSession, page_object: DeleteUserModel) -> CrudResponseModel: + """ + 删除用户信息service + + :param query_db: orm对象 + :param page_object: 删除用户对象 + :return: 删除用户校验结果 + """ + if page_object.user_ids: + user_id_list = page_object.user_ids.split(',') + try: + for user_id in user_id_list: + user_id_dict = { + 'userId': user_id, + 'updateBy': page_object.update_by, + 'updateTime': page_object.update_time, + } + await UserDao.delete_user_role_dao(query_db, UserRoleModel(**user_id_dict)) + await UserDao.delete_user_post_dao(query_db, UserPostModel(**user_id_dict)) + await UserDao.delete_user_dao(query_db, UserModel(**user_id_dict)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入用户id为空') + + @classmethod + async def user_detail_services(cls, query_db: AsyncSession, user_id: int | str) -> UserDetailModel: + """ + 获取用户详细信息service + + :param query_db: orm对象 + :param user_id: 用户id + :return: 用户id对应的信息 + """ + posts = await PostService.get_post_list_services(query_db, PostPageQueryModel(), is_page=False) + roles = await RoleService.get_role_select_option_services(query_db) + if user_id != '': + query_user = await UserDao.get_user_detail_by_id(query_db, user_id=user_id) + post_ids = ','.join([str(row.post_id) for row in query_user.get('user_post_info')]) + post_ids_list = [row.post_id for row in query_user.get('user_post_info')] + role_ids = ','.join([str(row.role_id) for row in query_user.get('user_role_info')]) + role_ids_list = [row.role_id for row in query_user.get('user_role_info')] + + return UserDetailModel( + data=UserInfoModel( + **CamelCaseUtil.transform_result(query_user.get('user_basic_info')), + postIds=post_ids, + roleIds=role_ids, + dept=CamelCaseUtil.transform_result(query_user.get('user_dept_info')), + role=CamelCaseUtil.transform_result(query_user.get('user_role_info')), + ), + postIds=post_ids_list, + posts=posts, + roleIds=role_ids_list, + roles=roles, + ) + + return UserDetailModel(posts=posts, roles=roles) + + @classmethod + async def user_profile_services(cls, query_db: AsyncSession, user_id: int) -> UserProfileModel: + """ + 获取用户个人详细信息service + + :param query_db: orm对象 + :param user_id: 用户id + :return: 用户id对应的信息 + """ + query_user = await UserDao.get_user_detail_by_id(query_db, user_id=user_id) + post_ids = ','.join([str(row.post_id) for row in query_user.get('user_post_info')]) + post_group = ','.join([row.post_name for row in query_user.get('user_post_info')]) + role_ids = ','.join([str(row.role_id) for row in query_user.get('user_role_info')]) + role_group = ','.join([row.role_name for row in query_user.get('user_role_info')]) + + return UserProfileModel( + data=UserInfoModel( + **CamelCaseUtil.transform_result(query_user.get('user_basic_info')), + postIds=post_ids, + roleIds=role_ids, + dept=CamelCaseUtil.transform_result(query_user.get('user_dept_info')), + role=CamelCaseUtil.transform_result(query_user.get('user_role_info')), + ), + postGroup=post_group, + roleGroup=role_group, + ) + + @classmethod + async def reset_user_services(cls, query_db: AsyncSession, page_object: ResetUserModel) -> CrudResponseModel: + """ + 重置用户密码service + + :param query_db: orm对象 + :param page_object: 重置用户对象 + :return: 重置用户校验结果 + """ + reset_user = page_object.model_dump(exclude_unset=True, exclude={'admin'}) + if page_object.old_password: + user = (await UserDao.get_user_detail_by_id(query_db, user_id=page_object.user_id)).get('user_basic_info') + if not PwdUtil.verify_password(page_object.old_password, user.password): + raise ServiceException(message='修改密码失败,旧密码错误') + if PwdUtil.verify_password(page_object.password, user.password): + raise ServiceException(message='新密码不能与旧密码相同') + del reset_user['old_password'] + if page_object.sms_code and page_object.session_id: + del reset_user['sms_code'] + del reset_user['session_id'] + try: + reset_user['password'] = PwdUtil.get_password_hash(page_object.password) + await UserDao.edit_user_dao(query_db, reset_user) + await query_db.commit() + return CrudResponseModel(is_success=True, message='重置成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + def _set_row_sex_value(cls, row: pd.Series) -> None: + """ + 设置行性别值 + + :param row: 行数据 + :return: None + """ + if row['sex'] == '男': + row['sex'] = '0' + if row['sex'] == '女': + row['sex'] = '1' + if row['sex'] == '未知': + row['sex'] = '2' + + @classmethod + def _set_row_status_value(cls, row: pd.Series) -> None: + """ + 设置行状态值 + + :param row: 行数据 + :return: None + """ + if row['status'] == '正常': + row['status'] = '0' + if row['status'] == '停用': + row['status'] = '1' + + @classmethod + async def batch_import_user_services( + cls, + request: Request, + query_db: AsyncSession, + file: UploadFile, + update_support: bool, + current_user: CurrentUserModel, + user_data_scope_sql: ColumnElement, + dept_data_scope_sql: ColumnElement, + ) -> CrudResponseModel: + """ + 批量导入用户service + + :param request: Request对象 + :param query_db: orm对象 + :param file: 用户导入文件对象 + :param update_support: 用户存在时是否更新 + :param current_user: 当前用户对象 + :param user_data_scope_sql: 用户数据权限sql + :param dept_data_scope_sql: 部门数据权限sql + :return: 批量导入用户结果 + """ + header_dict = { + '部门编号': 'dept_id', + '登录名称': 'user_name', + '用户名称': 'nick_name', + '用户邮箱': 'email', + '手机号码': 'phonenumber', + '用户性别': 'sex', + '帐号状态': 'status', + } + contents = await file.read() + df = pd.read_excel(io.BytesIO(contents)) + await file.close() + df.rename(columns=header_dict, inplace=True) + add_error_result = [] + count = 0 + try: + for _index, row in df.iterrows(): + count = count + 1 + cls._set_row_sex_value(row) + cls._set_row_status_value(row) + add_user = UserModel( + deptId=row['dept_id'], + userName=row['user_name'], + password=PwdUtil.get_password_hash( + await ConfigService.query_config_list_from_cache_services( + request.app.state.redis, 'sys.user.initPassword' + ) + ), + nickName=row['nick_name'], + email=row['email'], + phonenumber=str(row['phonenumber']), + sex=row['sex'], + status=row['status'], + createBy=current_user.user.user_name, + createTime=datetime.now(), + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + ) + user_info = await UserDao.get_user_by_info(query_db, UserModel(userName=row['user_name'])) + if user_info: + if update_support: + edit_user_model = UserModel( + userId=user_info.user_id, + deptId=row['dept_id'], + userName=row['user_name'], + nickName=row['nick_name'], + email=row['email'], + phonenumber=str(row['phonenumber']), + sex=row['sex'], + status=row['status'], + updateBy=current_user.user.user_name, + updateTime=datetime.now(), + ) + edit_user_model.validate_fields() + await cls.check_user_allowed_services(edit_user_model) + if not current_user.user.admin: + await cls.check_user_data_scope_services( + query_db, edit_user_model.user_id, user_data_scope_sql + ) + await DeptService.check_dept_data_scope_services( + query_db, edit_user_model.dept_id, dept_data_scope_sql + ) + edit_user = edit_user_model.model_dump(exclude_unset=True) + await UserDao.edit_user_dao(query_db, edit_user) + else: + add_error_result.append(f'{count}.用户账号{row["user_name"]}已存在') + else: + add_user.validate_fields() + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services( + query_db, add_user.dept_id, dept_data_scope_sql + ) + await UserDao.add_user_dao(query_db, add_user) + await query_db.commit() + return CrudResponseModel(is_success=True, message='\n'.join(add_error_result)) + except Exception as e: + await query_db.rollback() + raise e + + @staticmethod + async def get_user_import_template_services() -> bytes: + """ + 获取用户导入模板service + + :return: 用户导入模板excel的二进制数据 + """ + header_list = ['部门编号', '登录名称', '用户名称', '用户邮箱', '手机号码', '用户性别', '帐号状态'] + selector_header_list = ['用户性别', '帐号状态'] + option_list = [{'用户性别': ['男', '女', '未知']}, {'帐号状态': ['正常', '停用']}] + binary_data = ExcelUtil.get_excel_template( + header_list=header_list, selector_header_list=selector_header_list, option_list=option_list + ) + + return binary_data + + @staticmethod + async def export_user_list_services(user_list: list) -> bytes: + """ + 导出用户信息service + + :param user_list: 用户信息列表 + :return: 用户信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + 'userId': '用户编号', + 'userName': '用户名称', + 'nickName': '用户昵称', + 'deptName': '部门', + 'email': '邮箱地址', + 'phonenumber': '手机号码', + 'sex': '性别', + 'status': '状态', + 'createBy': '创建者', + 'createTime': '创建时间', + 'updateBy': '更新者', + 'updateTime': '更新时间', + 'remark': '备注', + } + + for item in user_list: + item['deptName'] = item.get('dept').get('deptName') + if item.get('status') == '0': + item['status'] = '正常' + else: + item['status'] = '停用' + if item.get('sex') == '0': + item['sex'] = '男' + elif item.get('sex') == '1': + item['sex'] = '女' + else: + item['sex'] = '未知' + binary_data = ExcelUtil.export_list2excel(user_list, mapping_dict) + + return binary_data + + @classmethod + async def get_user_role_allocated_list_services( + cls, query_db: AsyncSession, page_object: UserRoleQueryModel + ) -> UserRoleResponseModel: + """ + 根据用户id获取已分配角色列表 + + :param query_db: orm对象 + :param page_object: 用户关联角色对象 + :return: 已分配角色列表 + """ + query_user = await UserDao.get_user_detail_by_id(query_db, page_object.user_id) + post_ids = ','.join([str(row.post_id) for row in query_user.get('user_post_info')]) + role_ids = ','.join([str(row.role_id) for row in query_user.get('user_role_info')]) + user = UserInfoModel( + **CamelCaseUtil.transform_result(query_user.get('user_basic_info')), + postIds=post_ids, + roleIds=role_ids, + dept=CamelCaseUtil.transform_result(query_user.get('user_dept_info')), + role=CamelCaseUtil.transform_result(query_user.get('user_role_info')), + ) + query_role_list = [ + SelectedRoleModel(**row) for row in await RoleService.get_role_select_option_services(query_db) + ] + for model_a in query_role_list: + for model_b in user.role: + if model_a.role_id == model_b.role_id: + model_a.flag = True + result = UserRoleResponseModel(roles=query_role_list, user=user) + + return result + + @classmethod + async def add_user_role_services(cls, query_db: AsyncSession, page_object: CrudUserRoleModel) -> CrudResponseModel: + """ + 新增用户关联角色信息service + + :param query_db: orm对象 + :param page_object: 新增用户关联角色对象 + :return: 新增用户关联角色校验结果 + """ + if page_object.user_id and page_object.role_ids: + role_id_list = page_object.role_ids.split(',') + try: + await UserDao.delete_user_role_by_user_and_role_dao(query_db, UserRoleModel(userId=page_object.user_id)) + for role_id in role_id_list: + await UserDao.add_user_role_dao(query_db, UserRoleModel(userId=page_object.user_id, roleId=role_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='分配成功') + except Exception as e: + await query_db.rollback() + raise e + elif page_object.user_id and not page_object.role_ids: + try: + await UserDao.delete_user_role_by_user_and_role_dao(query_db, UserRoleModel(userId=page_object.user_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='分配成功') + except Exception as e: + await query_db.rollback() + raise e + elif page_object.user_ids and page_object.role_id: + user_id_list = page_object.user_ids.split(',') + try: + for user_id in user_id_list: + user_role = await cls.detail_user_role_services( + query_db, UserRoleModel(userId=user_id, roleId=page_object.role_id) + ) + if user_role: + continue + await UserDao.add_user_role_dao(query_db, UserRoleModel(userId=user_id, roleId=page_object.role_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='不满足新增条件') + + @classmethod + async def delete_user_role_services( + cls, query_db: AsyncSession, page_object: CrudUserRoleModel + ) -> CrudResponseModel: + """ + 删除用户关联角色信息service + + :param query_db: orm对象 + :param page_object: 删除用户关联角色对象 + :return: 删除用户关联角色校验结果 + """ + if (page_object.user_id and page_object.role_id) or (page_object.user_ids and page_object.role_id): + if page_object.user_id and page_object.role_id: + try: + await UserDao.delete_user_role_by_user_and_role_dao( + query_db, UserRoleModel(userId=page_object.user_id, roleId=page_object.role_id) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + elif page_object.user_ids and page_object.role_id: + user_id_list = page_object.user_ids.split(',') + try: + for user_id in user_id_list: + await UserDao.delete_user_role_by_user_and_role_dao( + query_db, UserRoleModel(userId=user_id, roleId=page_object.role_id) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='不满足删除条件') + else: + raise ServiceException(message='传入用户角色关联信息为空') + + @classmethod + async def detail_user_role_services(cls, query_db: AsyncSession, page_object: UserRoleModel) -> SysUserRole | None: + """ + 获取用户关联角色详细信息service + + :param query_db: orm对象 + :param page_object: 用户关联角色对象 + :return: 用户关联角色详细信息 + """ + user_role = await UserDao.get_user_role_detail(query_db, page_object) + + return user_role diff --git a/shierfeng-fastapi-backend/module_ai/controller/ai_chat_controller.py b/shierfeng-fastapi-backend/module_ai/controller/ai_chat_controller.py new file mode 100644 index 0000000..4255c7b --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/controller/ai_chat_controller.py @@ -0,0 +1,164 @@ +from typing import Annotated + +from fastapi import Body, Path, Request, Response +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, ResponseBaseModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_ai.entity.vo.ai_chat_vo import ( + AiChatConfigModel, + AiChatRequestModel, + AiChatSessionBaseModel, + AiChatSessionModel, +) +from module_ai.service.ai_chat_service import AiChatService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +ai_chat_controller = APIRouterPro( + prefix='/ai/chat', order_num=19, tags=['AI管理-AI对话'], dependencies=[PreAuthDependency()] +) + + +@ai_chat_controller.post( + '/send', + summary='发送对话消息', + description='流式返回对话结果', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回对话结果', + 'content': { + 'text/event-stream': {}, + }, + } + }, +) +@ApiRateLimit(namespace=ApiNamespace.AI_CHAT_SEND, preset=ApiRateLimitPreset.USER_INTERACTIVE_HIGH_FREQ) +async def send_chat_message( + request: Request, + chat_req: AiChatRequestModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> StreamingResponse: + user_id = current_user.user.user_id if current_user and current_user.user else 1 + chat_stream = AiChatService.chat_services(query_db, chat_req, user_id) + logger.info(f'用户{user_id}发送对话消息成功') + + return StreamingResponse(content=chat_stream, media_type='text/event-stream') + + +@ai_chat_controller.get( + '/config', + summary='获取用户对话配置', + description='获取当前用户的AI对话配置', + response_model=DataResponseModel[AiChatConfigModel], +) +@ApiCache(namespace=ApiNamespace.AI_CHAT_CONFIG) +async def get_user_chat_config( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + user_id = current_user.user.user_id + ai_chat_config_detail_result = await AiChatService.ai_chat_config_detail_services(query_db, user_id) + logger.info(f'获取user_id为{user_id}的对话配置成功') + + return ResponseUtil.success(data=ai_chat_config_detail_result) + + +@ai_chat_controller.put( + '/config', + summary='保存用户对话配置', + description='保存当前用户的AI对话配置', + response_model=DataResponseModel[AiChatConfigModel], +) +@ApiCacheEvict(namespaces=ApiGroup.AI_CHAT_CONFIG_MUTATION) +@Log(title='AI对话配置管理', business_type=BusinessType.INSERT) +async def save_user_chat_config( + request: Request, + ai_chat_config: AiChatConfigModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + user_id = current_user.user.user_id if current_user and current_user.user else 1 + save_ai_chat_config_result = await AiChatService.save_ai_chat_config_services(query_db, user_id, ai_chat_config) + logger.info(save_ai_chat_config_result.message) + + return ResponseUtil.success(msg=save_ai_chat_config_result.message) + + +@ai_chat_controller.get( + '/session/list', + summary='获取会话列表', + description='获取用户的会话列表', + response_model=DataResponseModel[list[AiChatSessionBaseModel]], +) +async def get_chat_session_list( + request: Request, + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + result = await AiChatService.get_chat_session_list_services(current_user.user.user_id) + logger.info('获取成功') + + return ResponseUtil.success(data=result) + + +@ai_chat_controller.delete( + '/session/{session_id}', + summary='删除会话', + description='删除指定会话', + response_model=ResponseBaseModel, +) +@Log(title='AI对话会话管理', business_type=BusinessType.DELETE) +async def delete_chat_session( + request: Request, + session_id: Annotated[str, Path(description='会话ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_chat_session_result = await AiChatService.delete_chat_session_services(session_id) + logger.info(delete_chat_session_result.message) + + return ResponseUtil.success(msg=delete_chat_session_result.message) + + +@ai_chat_controller.get( + '/session/{session_id}', + summary='获取会话消息详情', + description='获取指定会话的消息详情', + response_model=DataResponseModel[AiChatSessionModel], +) +async def get_chat_session_detail( + request: Request, + session_id: Annotated[str, Path(description='会话ID')], +) -> Response: + chat_session_detail_result = await AiChatService.get_chat_session_detail_services(session_id) + logger.info(f'获取session_id为{session_id}的信息成功') + + return ResponseUtil.success(data=chat_session_detail_result) + + +@ai_chat_controller.post( + '/cancel', + summary='取消对话', + description='取消正在进行的对话', + response_model=ResponseBaseModel, +) +@ApiRateLimit(namespace=ApiNamespace.AI_CHAT_CANCEL, preset=ApiRateLimitPreset.USER_INTERACTIVE_HIGH_FREQ) +async def cancel_chat_run( + request: Request, + run_id: Annotated[str, Body(embed=True, description='运行ID', alias='runId')], +) -> Response: + cancel_result = await AiChatService.cancel_run_services(run_id) + logger.info(cancel_result.message) + + return ResponseUtil.success(msg=cancel_result.message) diff --git a/shierfeng-fastapi-backend/module_ai/controller/ai_model_controller.py b/shierfeng-fastapi-backend/module_ai/controller/ai_model_controller.py new file mode 100644 index 0000000..60292ab --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/controller/ai_model_controller.py @@ -0,0 +1,188 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Path, Query, Request, Response +from pydantic_validation_decorator import ValidateFields +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log, RequestLogFieldRoot +from common.aspect.data_scope import DataScopeDependency +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_ai.entity.do.ai_model_do import AiModels +from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel +from module_ai.service.ai_model_service import AiModelService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +ai_model_controller = APIRouterPro( + prefix='/ai/model', order_num=18, tags=['AI管理-模型管理'], dependencies=[PreAuthDependency()] +) + + +@ai_model_controller.get( + '/list', + summary='获取AI模型分页列表接口', + description='用于获取AI模型分页列表', + response_model=PageResponseModel[AiModelModel], + dependencies=[UserInterfaceAuthDependency('ai:model:list')], +) +@ApiCache(namespace=ApiNamespace.AI_MODEL_LIST) +async def get_ai_model_list( + request: Request, + ai_model_page_query: Annotated[AiModelPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)], +) -> Response: + # 获取分页数据 + result = await AiModelService.get_ai_model_list_services( + query_db, ai_model_page_query, data_scope_sql, is_page=True + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=result) + + +@ai_model_controller.get( + '/all', + summary='获取AI模型不分页列表接口', + description='用于获取AI模型不分页列表', + response_model=DataResponseModel[AiModelModel], +) +@ApiCache(namespace=ApiNamespace.AI_MODEL_ALL) +async def get_ai_model_all( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)], +) -> Response: + # 获取不分页数据 + ai_model_page_query = AiModelPageQueryModel(status='0') + result = await AiModelService.get_ai_model_list_services( + query_db, ai_model_page_query, data_scope_sql, is_page=False + ) + logger.info('获取成功') + + return ResponseUtil.success(data=result) + + +@ai_model_controller.post( + '', + summary='新增AI模型接口', + description='用于新增AI模型', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('ai:model:add')], +) +@ValidateFields(validate_model='add_ai_model') +@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION) +@Log( + title='AI模型管理', + business_type=BusinessType.INSERT, + request_log_mode='exclude', + request_exclude_fields=(RequestLogFieldRoot.JSON_BODY.field('api_key'),), +) +async def add_ai_model( + request: Request, + add_ai_model: AiModelModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + add_ai_model.user_id = current_user.user.user_id + add_ai_model.dept_id = current_user.user.dept_id + add_ai_model.create_by = current_user.user.user_name + add_ai_model.create_time = datetime.now() + add_ai_model.update_by = current_user.user.user_name + add_ai_model.update_time = datetime.now() + add_ai_model_result = await AiModelService.add_ai_model_services(query_db, add_ai_model) + logger.info(add_ai_model_result.message) + + return ResponseUtil.success(msg=add_ai_model_result.message) + + +@ai_model_controller.put( + '', + summary='编辑AI模型接口', + description='用于编辑AI模型', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('ai:model:edit')], +) +@ValidateFields(validate_model='edit_ai_model') +@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION) +@Log( + title='AI模型管理', + business_type=BusinessType.UPDATE, + request_log_mode='exclude', + request_exclude_fields=(RequestLogFieldRoot.JSON_BODY.field('api_key'),), +) +async def edit_ai_model( + request: Request, + edit_ai_model: AiModelModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)], +) -> Response: + if not current_user.user.admin: + await AiModelService.check_ai_model_data_scope_services(query_db, edit_ai_model.model_id, data_scope_sql) + edit_ai_model.update_by = current_user.user.user_name + edit_ai_model.update_time = datetime.now() + edit_ai_model_result = await AiModelService.edit_ai_model_services(query_db, edit_ai_model) + logger.info(edit_ai_model_result.message) + + return ResponseUtil.success(msg=edit_ai_model_result.message) + + +@ai_model_controller.delete( + '/{model_ids}', + summary='删除AI模型接口', + description='用于删除AI模型', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('ai:model:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION) +@Log(title='AI模型管理', business_type=BusinessType.DELETE) +async def delete_ai_model( + request: Request, + model_ids: Annotated[str, Path(description='需要删除的模型ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)], +) -> Response: + model_id_list = model_ids.split(',') + for model_id in model_id_list: + if not current_user.user.admin: + await AiModelService.check_ai_model_data_scope_services(query_db, int(model_id), data_scope_sql) + delete_ai_model = DeleteAiModelModel(modelIds=model_ids) + delete_ai_model_result = await AiModelService.delete_ai_model_services(query_db, delete_ai_model) + logger.info(delete_ai_model_result.message) + + return ResponseUtil.success(msg=delete_ai_model_result.message) + + +@ai_model_controller.get( + '/{model_id}', + summary='获取AI模型详情接口', + description='用于获取指定AI模型的详细信息', + response_model=DataResponseModel[AiModelModel], + dependencies=[UserInterfaceAuthDependency('ai:model:query')], +) +@ApiCache(namespace=ApiNamespace.AI_MODEL_DETAIL) +async def get_ai_model_detail( + request: Request, + model_id: Annotated[int, Path(description='模型ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)], +) -> Response: + if not current_user.user.admin: + await AiModelService.check_ai_model_data_scope_services(query_db, model_id, data_scope_sql) + ai_model_detail_result = await AiModelService.ai_model_detail_services(query_db, model_id) + logger.info(f'获取model_id为{model_id}的信息成功') + + return ResponseUtil.success(data=ai_model_detail_result) diff --git a/shierfeng-fastapi-backend/module_ai/dao/ai_chat_dao.py b/shierfeng-fastapi-backend/module_ai/dao/ai_chat_dao.py new file mode 100644 index 0000000..b7677e1 --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/dao/ai_chat_dao.py @@ -0,0 +1,52 @@ +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from module_ai.entity.do.ai_chat_do import AiChatConfig +from module_ai.entity.vo.ai_chat_vo import AiChatConfigModel + + +class AiChatConfigDao: + """ + AI对话配置数据库操作层 + """ + + @classmethod + async def get_chat_config_detail_by_user_id(cls, db: AsyncSession, user_id: int) -> AiChatConfig | None: + """ + 根据用户ID获取配置 + + :param db: orm对象 + :param user_id: 用户ID + :return: 配置对象 + """ + ai_chat_config = ( + (await db.execute(select(AiChatConfig).where(AiChatConfig.user_id == user_id))).scalars().first() + ) + + return ai_chat_config + + @classmethod + async def add_chat_config_dao(cls, db: AsyncSession, chat_config: AiChatConfigModel) -> AiChatConfig: + """ + 新增对话配置数据库操作 + + :param db: orm对象 + :param chat_config: 对话配置对象 + :return: 配置对象 + """ + db_chat_config = AiChatConfig(**chat_config.model_dump(exclude_unset=True)) + db.add(db_chat_config) + await db.flush() + + return db_chat_config + + @classmethod + async def edit_chat_config_dao(cls, db: AsyncSession, chat_config: dict) -> None: + """ + 编辑对话配置数据库操作 + + :param db: orm对象 + :param chat_config: 需要更新的对话配置字典 + :return: + """ + await db.execute(update(AiChatConfig), [chat_config]) diff --git a/shierfeng-fastapi-backend/module_ai/dao/ai_model_dao.py b/shierfeng-fastapi-backend/module_ai/dao/ai_model_dao.py new file mode 100644 index 0000000..60aa59f --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/dao/ai_model_dao.py @@ -0,0 +1,96 @@ +from typing import Any + +from sqlalchemy import ColumnElement, delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_ai.entity.do.ai_model_do import AiModels +from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel +from utils.page_util import PageUtil + + +class AiModelDao: + """ + AI模型管理数据库操作层 + """ + + @classmethod + async def get_ai_model_detail_by_id(cls, db: AsyncSession, model_id: int) -> AiModels | None: + """ + 根据AI模型id获取AI模型详细信息 + + :param db: orm对象 + :param model_id: AI模型id + :return: AI模型信息对象 + """ + ai_model_info = (await db.execute(select(AiModels).where(AiModels.model_id == model_id))).scalars().first() + + return ai_model_info + + @classmethod + async def get_ai_model_list( + cls, db: AsyncSession, query_object: AiModelPageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取AI模型列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: AI模型列表信息对象 + """ + query = ( + select(AiModels) + .where( + AiModels.model_id == query_object.model_id if query_object.model_id else True, + AiModels.model_name.like(f'%{query_object.model_name}%') if query_object.model_name else True, + AiModels.model_code.like(f'%{query_object.model_code}%') if query_object.model_code else True, + AiModels.provider == query_object.provider if query_object.provider else True, + AiModels.status == query_object.status if query_object.status else True, + data_scope_sql, + ) + .order_by(AiModels.model_sort) + ) + ai_model_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return ai_model_list + + @classmethod + async def add_ai_model_dao(cls, db: AsyncSession, ai_model: AiModelModel) -> AiModels: + """ + 新增AI模型数据库操作 + + :param db: orm对象 + :param ai_model: AI模型对象 + :return: AI模型信息对象 + """ + db_model = AiModels(**ai_model.model_dump(exclude_unset=True)) + db.add(db_model) + await db.flush() + + return db_model + + @classmethod + async def edit_ai_model_dao(cls, db: AsyncSession, ai_model: dict) -> None: + """ + 编辑AI模型数据库操作 + + :param db: orm对象 + :param ai_model: 需要更新的AI模型字典 + :return: + """ + await db.execute(update(AiModels), [ai_model]) + + @classmethod + async def delete_ai_model_dao(cls, db: AsyncSession, ai_model: AiModelModel) -> None: + """ + 删除AI模型数据库操作 + + :param db: orm对象 + :param ai_model: AI模型对象 + :return: + """ + await db.execute(delete(AiModels).where(AiModels.model_id.in_([ai_model.model_id]))) diff --git a/shierfeng-fastapi-backend/module_ai/entity/do/ai_chat_do.py b/shierfeng-fastapi-backend/module_ai/entity/do/ai_chat_do.py new file mode 100644 index 0000000..a07584a --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/entity/do/ai_chat_do.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Float, Integer, Text + +from config.database import Base + + +class AiChatConfig(Base): + """ + AI对话配置表 + """ + + __tablename__ = 'ai_chat_config' + __table_args__ = {'comment': 'AI对话配置表'} + + chat_config_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='配置主键') + user_id = Column(BigInteger, nullable=False, unique=True, comment='用户ID') + temperature = Column(Float, nullable=True, comment='默认温度') + add_history_to_context = Column(CHAR(1), server_default='0', comment='是否添加历史记录(0是, 1否)') + num_history_runs = Column(Integer, nullable=True, comment='历史记录条数') + system_prompt = Column(Text, nullable=True, comment='系统提示词') + metrics_default_visible = Column(CHAR(1), server_default='0', comment='默认显示指标(0是, 1否)') + vision_enabled = Column(CHAR(1), server_default='1', comment='是否开启视觉(0是, 1否)') + image_max_size_mb = Column(Integer, nullable=True, comment='图片最大大小(MB)') + create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') + update_time = Column(DateTime, nullable=True, default=datetime.now, comment='更新时间') diff --git a/shierfeng-fastapi-backend/module_ai/entity/do/ai_model_do.py b/shierfeng-fastapi-backend/module_ai/entity/do/ai_model_do.py new file mode 100644 index 0000000..c13c279 --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/entity/do/ai_model_do.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Float, Integer, String + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class AiModels(Base): + """ + AI模型表 + """ + + __tablename__ = 'ai_models' + __table_args__ = {'comment': 'AI模型表'} + + model_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='模型主键') + model_code = Column(String(100), nullable=False, comment='模型编码') + model_name = Column( + String(100), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='模型名称', + ) + provider = Column(String(50), nullable=False, comment='提供商') + model_sort = Column(Integer, nullable=False, comment='显示顺序') + api_key = Column( + String(255), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='API Key', + ) + base_url = Column( + String(255), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='Base URL', + ) + model_type = Column( + String(50), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='模型类型', + ) + max_tokens = Column(Integer, nullable=True, comment='最大输出token') + temperature = Column(Float, nullable=True, comment='默认温度') + support_reasoning = Column(CHAR(1), server_default='N', comment='是否支持推理') + support_images = Column(CHAR(1), server_default='N', comment='是否支持图片') + status = Column(CHAR(1), server_default='0', comment='模型状态') + user_id = Column(BigInteger, nullable=True, comment='用户ID') + dept_id = Column(BigInteger, nullable=True, comment='部门ID') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) diff --git a/shierfeng-fastapi-backend/module_ai/entity/vo/ai_chat_vo.py b/shierfeng-fastapi-backend/module_ai/entity/vo/ai_chat_vo.py new file mode 100644 index 0000000..e9d95c3 --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/entity/vo/ai_chat_vo.py @@ -0,0 +1,159 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class AiChatRequestModel(BaseModel): + """ + AI对话请求模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + session_id: str | None = Field(default=None, description='会话ID') + model_id: int = Field(description='模型ID') + message: str = Field(description='用户消息') + is_reasoning: bool | None = Field(default=None, description='本次是否开启深度思考') + images: list[str] | None = Field(default=None, description='图片URL列表') + + +class AiChatConfigModel(BaseModel): + """ + AI对话配置模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + chat_config_id: int | None = Field(default=None, description='配置主键') + user_id: int | None = Field(default=None, description='用户ID') + temperature: float | None = Field(default=None, description='默认温度') + add_history_to_context: Literal['0', '1'] | None = Field(default=None, description='是否添加历史记录') + num_history_runs: int | None = Field(default=3, description='历史记录条数') + system_prompt: str | None = Field(default=None, description='系统提示词') + metrics_default_visible: Literal['0', '1'] | None = Field(default=None, description='默认显示指标') + vision_enabled: Literal['0', '1'] | None = Field(default=None, description='是否开启视觉') + image_max_size_mb: int | None = Field(default=None, description='图片最大大小(MB)') + create_time: datetime | None = Field(default=None, description='创建时间') + update_time: datetime | None = Field(default=None, description='更新时间') + + +class AiChatSessionBaseModel(BaseModel): + """ + AI对话会话基础模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + session_id: str = Field(description='会话ID') + session_title: str | None = Field(default=None, description='会话标题') + session_type: str | None = Field(default=None, description='会话类型') + user_id: str | None = Field(default=None, description='用户ID') + created_at: datetime | None = Field(default=None, description='创建时间') + updated_at: datetime | None = Field(default=None, description='更新时间') + + +class ModelInfoModel(BaseModel): + """ + 对话会话数据模型-模型信息模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + id: str | None = Field(default=None, description='模型ID') + name: str | None = Field(default=None, description='模型名称') + provider: str | None = Field(default=None, description='模型提供者') + temperature: float | None = Field(default=None, description='模型温度') + + +class AgentDataModel(BaseModel): + """ + 对话会话数据模型-智能体数据模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + model: ModelInfoModel | None = Field(default=None, description='模型信息') + agent_id: str | None = Field(default=None, description='智能体ID') + + +class SessionMetricsModel(BaseModel): + """ + 对话会话数据模型-会话指标模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + cost: float | None = Field(default=None, description='成本(元)') + timer: float | None = Field(default=None, description='运行时长(秒)') + duration: float | None = Field(default=None, description='运行时长(秒)') + input_tokens: int | None = Field(default=None, description='输入Token数量') + total_tokens: int | None = Field(default=None, description='总Token数量') + output_tokens: int | None = Field(default=None, description='输出Token数量') + provider_metrics: dict | None = Field(default=None, description='提供者指标') + reasoning_tokens: int | None = Field(default=None, description='推理Token数量') + cache_read_tokens: int | None = Field(default=None, description='缓存读取Token数量') + additional_metrics: dict | None = Field(default=None, description='其他指标') + audio_input_tokens: int | None = Field(default=None, description='音频输入Token数量') + audio_total_tokens: int | None = Field(default=None, description='音频总Token数量') + cache_write_tokens: int | None = Field(default=None, description='缓存写入Token数量') + audio_output_tokens: int | None = Field(default=None, description='音频输出Token数量') + time_to_first_token: float | None = Field(default=None, description='到第一个Token的时间(秒)') + + +class SessionDataModel(BaseModel): + """ + 对话会话数据模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + session_state: dict | None = Field(default=None, description='会话状态') + session_metrics: SessionMetricsModel | None = Field(default=None, description='会话指标') + + +class MessageMetrics(BaseModel): + """ + 对话消息模型-消息指标模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + input_tokens: int | None = Field(default=None, description='输入Token数量') + total_tokens: int | None = Field(default=None, description='总Token数量') + output_tokens: int | None = Field(default=None, description='输出Token数量') + reasoning_tokens: int | None = Field(default=None, description='推理Token数量') + duration: float | None = Field(default=None, description='运行时长(秒)') + time_to_first_token: float | None = Field(default=None, description='到第一个Token的时间(秒)') + + +class ChatMessageModel(BaseModel): + """ + 对话消息模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + id: str | None = Field(default=None, description='消息ID') + role: str | None = Field(default=None, description='角色') + content: str | None = Field(default=None, description='内容') + images: list[str] | None = Field(default=None, description='图片列表') + metrics: MessageMetrics | None = Field(default=None, description='Token使用统计') + created_at: datetime | None = Field(default=None, description='创建时间') + from_history: bool | None = Field(default=None, description='是否来自历史记录') + reasoning_content: str | None = Field(default=None, description='推理/思考内容') + stop_after_tool_call: bool | None = Field(default=None, description='是否在工具调用后停止') + + +class AiChatSessionModel(AiChatSessionBaseModel): + """ + AI对话会话模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + agent_id: str | None = Field(default=None, description='智能体ID') + session_data: SessionDataModel | None = Field(default=None, description='会话数据') + agent_data: AgentDataModel | None = Field(default=None, description='智能体数据') + messages: list[ChatMessageModel] | None = Field(default=None, description='消息列表') diff --git a/shierfeng-fastapi-backend/module_ai/entity/vo/ai_model_vo.py b/shierfeng-fastapi-backend/module_ai/entity/vo/ai_model_vo.py new file mode 100644 index 0000000..ffc3a19 --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/entity/vo/ai_model_vo.py @@ -0,0 +1,64 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank, Size + + +class AiModelModel(BaseModel): + """ + AI模型表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + model_id: int | None = Field(default=None, description='模型主键') + model_code: str | None = Field(default=None, description='模型编码') + model_name: str | None = Field(default=None, description='模型名称') + provider: str | None = Field(default=None, description='提供商') + model_sort: int | None = Field(default=None, description='显示顺序') + api_key: str | None = Field(default=None, description='API Key') + base_url: str | None = Field(default=None, description='Base URL') + max_tokens: int | None = Field(default=None, description='最大输出token') + temperature: float | None = Field(default=None, description='默认温度') + support_reasoning: Literal['Y', 'N'] | None = Field(default=None, description='是否支持推理(深度思考)') + support_images: Literal['Y', 'N'] | None = Field(default=None, description='是否支持图片') + model_type: str | None = Field(default=None, description='模型类型') + status: Literal['0', '1'] | None = Field(default=None, description='模型状态') + user_id: int | None = Field(default=None, description='用户ID') + dept_id: int | None = Field(default=None, description='部门ID') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='model_code', message='模型编码不能为空') + @Size(field_name='model_code', min_length=0, max_length=100, message='模型编码长度不能超过100个字符') + def get_model_code(self) -> str | None: + return self.model_code + + @NotBlank(field_name='base_url', message='Base URL不能为空') + @Size(field_name='base_url', min_length=0, max_length=255, message='Base URL长度不能超过255个字符') + def get_base_url(self) -> str | None: + return self.base_url + + +class AiModelPageQueryModel(AiModelModel): + """ + AI模型管理分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteAiModelModel(BaseModel): + """ + 删除AI模型模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + model_ids: str = Field(description='需要删除的模型主键') diff --git a/shierfeng-fastapi-backend/module_ai/service/ai_chat_service.py b/shierfeng-fastapi-backend/module_ai/service/ai_chat_service.py new file mode 100644 index 0000000..dfc5406 --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/service/ai_chat_service.py @@ -0,0 +1,483 @@ +import json +import os +import uuid +from collections.abc import AsyncGenerator, AsyncIterator +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from agno.agent import Agent +from agno.db.base import SessionType +from agno.media import Image +from agno.run.agent import RunEvent, RunOutput, RunOutputEvent +from agno.run.cancel import acancel_run +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import CrudResponseModel +from config.env import UploadConfig +from exceptions.exception import ServiceException +from module_ai.dao.ai_chat_dao import AiChatConfigDao +from module_ai.dao.ai_model_dao import AiModelDao +from module_ai.entity.do.ai_chat_do import AiChatConfig +from module_ai.entity.vo.ai_chat_vo import ( + AgentDataModel, + AiChatConfigModel, + AiChatRequestModel, + AiChatSessionBaseModel, + AiChatSessionModel, + ChatMessageModel, + MessageMetrics, + SessionDataModel, + SessionMetricsModel, +) +from module_ai.entity.vo.ai_model_vo import AiModelModel +from utils.ai_util import AiUtil +from utils.common_util import CamelCaseUtil +from utils.crypto_util import CryptoUtil + +if TYPE_CHECKING: + from agno.models.message import Message + from agno.run.team import TeamRunOutput + from agno.run.workflow import WorkflowRunOutput + from agno.session import Session + + +class AiChatService: + """ + AI对话服务层 + """ + + @classmethod + def _resolve_temperature(cls, user_config: AiChatConfigModel, model_config: AiModelModel) -> float: + """ + 解析温度配置,优先级为 用户配置 > 模型配置 + + :param user_config: 用户配置对象 + :param model_config: 模型配置对象 + :return: 解析后的温度值 + """ + temperature = user_config.temperature or model_config.temperature + return temperature + + @classmethod + def _resolve_is_reasoning(cls, chat_req: AiChatRequestModel, model_config: AiModelModel) -> bool: + """ + 解析深度思考开关,结合请求参数与模型配置确定最终是否开启 + + :param chat_req: 对话请求对象 + :param model_config: 模型配置对象 + :return: 是否开启深度思考 + """ + if model_config.support_reasoning != 'Y': + return False + return bool(chat_req.is_reasoning) + + @classmethod + def _resolve_history_config(cls, user_config: AiChatConfigModel) -> tuple[bool, int]: + """ + 解析历史消息配置,确定是否附带历史以及轮数 + + :param user_config: 用户配置对象 + :return: (是否附带历史, 历史轮数) + """ + add_history = user_config.add_history_to_context == '0' + num_history = user_config.num_history_runs or 3 + + return bool(add_history), int(num_history) + + @classmethod + def _build_agent( + cls, + model_config: AiModelModel, + temperature: float, + system_prompt: str | None, + user_id: int, + session_id: str, + add_history: bool, + num_history: int, + ) -> Agent: + """ + 构建对话Agent对象 + + :param model_config: 模型配置对象 + :param temperature: 对话温度 + :param system_prompt: 系统提示词 + :param user_id: 用户ID + :param session_id: 会话ID + :param add_history: 是否附带历史消息 + :param num_history: 历史消息轮数 + :return: Agent对象 + """ + real_api_key = CryptoUtil.decrypt(model_config.api_key) + + model = AiUtil.get_model_from_factory( + provider=model_config.provider, + model_code=model_config.model_code, + model_name=model_config.model_name, + api_key=real_api_key, + base_url=model_config.base_url, + temperature=temperature, + max_tokens=model_config.max_tokens, + ) + storage = AiUtil.get_storage_engine() + return Agent( + model=model, + id='chat-agent', + description=system_prompt or 'You are a helpful AI assistant.', + db=storage, + user_id=str(user_id), + session_id=session_id, + add_history_to_context=add_history, + num_history_runs=num_history, + markdown=True, + ) + + @classmethod + def _build_run_kwargs( + cls, + chat_req: AiChatRequestModel, + user_config: AiChatConfigModel, + ) -> dict[str, Any]: + """ + 构造Agent运行参数 + + :param chat_req: 对话请求对象 + :param user_config: 用户配置对象 + :return: 运行参数字典 + """ + run_kwargs: dict[str, Any] = {'stream': True, 'stream_events': True} + if not chat_req.images or not user_config.vision_enabled: + return run_kwargs + + processed_images: list[Image] = [] + for img in chat_req.images: + if img and img.startswith(UploadConfig.UPLOAD_PREFIX): + relative_path = img[len(UploadConfig.UPLOAD_PREFIX) :] + if relative_path.startswith('/'): + relative_path = relative_path[1:] + file_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path) + abs_path = os.path.abspath(file_path) + if os.path.exists(abs_path): + processed_images.append(Image(filepath=abs_path)) + run_kwargs['images'] = processed_images + return run_kwargs + + @classmethod + def _convert_images_to_upload_paths(cls, images: list[Image] | None) -> list[str] | None: + """ + 将Agno Image对象列表转换为前端可访问的上传路径列表 + + :param images: Image对象列表 + :return: 上传路径列表 + """ + if not images: + return None + + result = [] + for img in images: + # 如果是本地文件路径 + if hasattr(img, 'filepath') and img.filepath: + try: + # 使用 abspath 确保路径标准化 + abs_filepath = os.path.abspath(img.filepath) + abs_upload_path = os.path.abspath(UploadConfig.UPLOAD_PATH) + + if abs_filepath.startswith(abs_upload_path): + relative_path = os.path.relpath(abs_filepath, abs_upload_path) + # 转换路径分隔符为URL格式 + url_path = relative_path.replace(os.sep, '/') + # 拼接前缀 + full_url = f'{UploadConfig.UPLOAD_PREFIX}/{url_path}'.replace('//', '/') + result.append(full_url) + else: + result.append(img.filepath) + except Exception: + result.append(img.filepath) + # 如果是URL + elif hasattr(img, 'url') and img.url: + result.append(img.url) + + return result if result else None + + @classmethod + async def _stream_agent( + cls, + agent: Agent, + chat_req: AiChatRequestModel, + run_kwargs: dict[str, Any], + is_reasoning: bool, + session_id: str, + ) -> AsyncGenerator[str, None]: + """ + 将Agent输出流式转换为前端SSE消息 + + :param agent: Agent实例 + :param chat_req: 对话请求对象 + :param run_kwargs: 运行参数字典 + :param is_reasoning: 是否输出推理内容 + :param session_id: 会话ID + :return: SSE消息生成器 + """ + full_response = '' + full_reasoning = '' + try: + yield json.dumps({'session_id': session_id, 'type': 'meta'}) + '\n' + + response_stream: AsyncIterator[RunOutputEvent] = agent.arun(chat_req.message, **run_kwargs) + + async for chunk in response_stream: + content = None + reasoning = None + + if chunk.event == RunEvent.run_started and chunk.run_id: + yield json.dumps({'run_id': chunk.run_id, 'type': 'run_info'}) + '\n' + + if chunk.event == RunEvent.run_content: + content = chunk.content + if hasattr(chunk, 'reasoning_content') and chunk.reasoning_content: + reasoning = chunk.reasoning_content + + if reasoning and is_reasoning: + full_reasoning += reasoning + yield json.dumps({'content': reasoning, 'type': 'reasoning'}) + '\n' + + if chunk.event == RunEvent.run_completed and chunk.metrics: + yield ( + json.dumps( + {'metrics': CamelCaseUtil.transform_result(chunk.metrics.to_dict()), 'type': 'metrics'} + ) + + '\n' + ) + + if content: + full_response += content + yield json.dumps({'content': content, 'type': 'content'}) + '\n' + except Exception as e: + yield json.dumps({'error': str(e), 'type': 'error'}) + '\n' + + @classmethod + async def chat_services( + cls, query_db: AsyncSession, chat_req: AiChatRequestModel, user_id: int + ) -> AsyncGenerator[str, None]: + """ + 流式对话 + + :param query_db: orm对象 + :param chat_req: 对话请求对象 + :param user_id: 用户ID + :return: 对话响应流 + """ + ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, chat_req.model_id) + model_config = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel() + if not model_config: + raise ServiceException(message='模型不存在') + + user_config = await cls.ai_chat_config_detail_services(query_db, user_id) + + session_id = chat_req.session_id + if not session_id: + session_id = str(uuid.uuid4()) + + temperature = cls._resolve_temperature(user_config, model_config) + is_reasoning = cls._resolve_is_reasoning(chat_req, model_config) + add_history, num_history = cls._resolve_history_config(user_config) + system_prompt = user_config.system_prompt + + agent = cls._build_agent( + model_config=model_config, + temperature=temperature, + system_prompt=system_prompt, + user_id=user_id, + session_id=session_id, + add_history=add_history, + num_history=num_history, + ) + run_kwargs = cls._build_run_kwargs(chat_req, user_config) + async for chunk in cls._stream_agent( + agent=agent, + chat_req=chat_req, + run_kwargs=run_kwargs, + is_reasoning=is_reasoning, + session_id=session_id, + ): + yield chunk + + @classmethod + async def ai_chat_config_detail_services(cls, query_db: AsyncSession, user_id: int) -> AiChatConfigModel: + """ + 获取用户配置 + + :param query_db: orm对象 + :param user_id: 用户ID + :return: 配置模型 + """ + chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id) + result = AiChatConfigModel(**CamelCaseUtil.transform_result(chat_config)) if chat_config else AiChatConfig() + + return result + + @classmethod + async def save_ai_chat_config_services( + cls, query_db: AsyncSession, user_id: int, page_object: AiChatConfigModel + ) -> CrudResponseModel: + """ + 保存用户配置 + + :param query_db: orm对象 + :param user_id: 用户ID + :param page_object: AI对话配置对象 + :return: 更新后的配置模型 + """ + chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id) + if page_object.user_id is None: + page_object.user_id = user_id + + try: + if chat_config: + if chat_config.chat_config_id != page_object.chat_config_id: + raise ServiceException(message='只允许修改当前用户的配置') + page_object.update_time = datetime.now() + edit_ai_chat_config = page_object.model_dump(exclude_unset=True) + await AiChatConfigDao.edit_chat_config_dao(query_db, edit_ai_chat_config) + else: + page_object.create_time = datetime.now() + await AiChatConfigDao.add_chat_config_dao(query_db, page_object) + + await query_db.commit() + except Exception as e: + await query_db.rollback() + raise e + + return CrudResponseModel(is_success=True, message='保存成功') + + @classmethod + async def get_chat_session_list_services(cls, user_id: int) -> list[AiChatSessionBaseModel]: + """ + 获取用户会话列表 + + :param user_id: 用户ID + :return: 用户会话列表 + """ + # 获取Agno会话列表 + storage = AiUtil.get_storage_engine() + sessions: list[Session] = await storage.get_sessions( + user_id=str(user_id), + component_id='chat-agent', + session_type=SessionType.AGENT, + ) + + result = [] + for s in sessions: + created_at = datetime.fromtimestamp(s.created_at) if s.created_at else None + updated_at = datetime.fromtimestamp(s.updated_at) if s.updated_at else None + + title_limit = 20 + session_title = s.runs[0].input.input_content[:title_limit] + '...' if s.runs else '' + + result.append( + AiChatSessionBaseModel( + sessionId=s.session_id, + sessionTitle=session_title if len(session_title) <= title_limit else session_title[:title_limit], + userId=s.user_id, + createdAt=created_at, + updatedAt=updated_at, + ) + ) + return result + + @classmethod + async def delete_chat_session_services(cls, session_id: str) -> CrudResponseModel: + """ + 删除会话 + + :param session_id: 会话ID + :return: 删除结果 + """ + storage = AiUtil.get_storage_engine() + delete_result = await storage.delete_session(session_id=session_id) + if not delete_result: + raise ServiceException(message='删除会话失败') + return CrudResponseModel(is_success=True, message='删除成功') + + @classmethod + async def get_chat_session_detail_services(cls, session_id: str) -> AiChatSessionModel: + """ + 获取会话消息详情 + + :param session_id: 会话ID + :return: 会话消息详情 + """ + storage = AiUtil.get_storage_engine() + session: Session | None = await storage.get_session(session_id=session_id, session_type=SessionType.AGENT) + + if not session: + raise ServiceException(message='会话不存在') + + session_data: dict[str, Any] = session.session_data + agent_data: dict[str, Any] = session.agent_data + runs: list[RunOutput | TeamRunOutput | WorkflowRunOutput] = session.runs + messages: list[Message] = session.get_messages(skip_roles=['system']) + + run_metrics_map = {} + if runs: + for run in runs: + if run.model_provider_data and (provider_id := run.model_provider_data.get('id')): + run_metrics_map[provider_id] = run.metrics + + chat_messages = [] + for m in messages: + if hasattr(m, 'provider_data') and m.provider_data: + provider_id = m.provider_data.get('id') + if provider_id and provider_id in run_metrics_map: + m.metrics = run_metrics_map[provider_id] + + metrics_model = None + if getattr(m, 'metrics', None) and hasattr(m.metrics, 'to_dict'): + metrics_dict = m.metrics.to_dict() + if metrics_dict: + metrics_model = MessageMetrics(**CamelCaseUtil.transform_result(metrics_dict)) + + chat_messages.append( + ChatMessageModel( + id=m.id, + role=m.role, + content=m.content, + images=cls._convert_images_to_upload_paths(m.images), + metrics=metrics_model, + createdAt=datetime.fromtimestamp(m.created_at) if m.created_at else None, + reasoningContent=m.reasoning_content, + fromHistory=m.from_history, + stopAfterToolCall=m.stop_after_tool_call, + ) + ) + + session_detail = AiChatSessionModel( + sessionId=session.session_id, + sessionTitle=session.runs[0].input.input_content[:20] + '...' if session.runs else '', + userId=session.user_id, + createdAt=datetime.fromtimestamp(session.created_at) if session.created_at else None, + updatedAt=datetime.fromtimestamp(session.updated_at) if session.updated_at else None, + agentId=session.agent_id, + sessionData=SessionDataModel( + sessionState=session_data.get('session_state'), + sessionMetrics=SessionMetricsModel( + **CamelCaseUtil.transform_result(session_data.get('session_metrics')) + ), + ), + agentData=AgentDataModel(**CamelCaseUtil.transform_result(agent_data)), + messages=chat_messages, + ) + + return session_detail + + @classmethod + async def cancel_run_services(cls, run_id: str) -> CrudResponseModel: + """ + 取消运行 + + :param run_id: 运行ID + :return: 取消结果 + """ + cancel_result = await acancel_run(run_id) + if not cancel_result: + raise ServiceException(message='取消运行失败') + return CrudResponseModel(is_success=True, message='取消成功') diff --git a/shierfeng-fastapi-backend/module_ai/service/ai_model_service.py b/shierfeng-fastapi-backend/module_ai/service/ai_model_service.py new file mode 100644 index 0000000..15fc608 --- /dev/null +++ b/shierfeng-fastapi-backend/module_ai/service/ai_model_service.py @@ -0,0 +1,155 @@ +from typing import Any + +from sqlalchemy import ColumnElement +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from module_ai.dao.ai_model_dao import AiModelDao +from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel +from utils.common_util import CamelCaseUtil +from utils.crypto_util import CryptoUtil + + +class AiModelService: + """ + AI模型管理服务层 + """ + + @classmethod + async def get_ai_model_list_services( + cls, + query_db: AsyncSession, + query_object: AiModelPageQueryModel, + data_scope_sql: ColumnElement, + is_page: bool = False, + ) -> PageModel | list[dict[str, Any]]: + """ + 获取AI模型列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param data_scope_sql: 数据权限对应的查询sql语句 + :param is_page: 是否开启分页 + :return: AI模型列表信息对象 + """ + ai_model_list_result = await AiModelDao.get_ai_model_list(query_db, query_object, data_scope_sql, is_page) + rows = ai_model_list_result.rows if isinstance(ai_model_list_result, PageModel) else ai_model_list_result + + for row in rows: + if 'apiKey' in row: + row['apiKey'] = '********' * 3 + + return ai_model_list_result + + @classmethod + async def check_ai_model_data_scope_services( + cls, + query_db: AsyncSession, + model_id: int, + data_scope_sql: ColumnElement, + ) -> CrudResponseModel: + """ + 校验用户是否有AI模型数据权限service + + :param query_db: orm对象 + :param model_id: 模型主键 + :param data_scope_sql: 数据权限对应的查询sql语句 + :return: 校验结果 + """ + ai_models = await AiModelDao.get_ai_model_list( + query_db, AiModelModel(modelId=model_id), data_scope_sql, is_page=False + ) + if ai_models: + return CrudResponseModel(is_success=True, message='校验通过') + raise ServiceException(message='没有权限访问AI模型数据') + + @classmethod + async def add_ai_model_services(cls, query_db: AsyncSession, page_object: AiModelModel) -> CrudResponseModel: + """ + 新增AI模型信息service + + :param request: Request对象 + :param query_db: orm对象 + :param page_object: 新增AI模型对象 + :return: 新增AI模型校验结果 + """ + try: + if page_object.api_key: + page_object.api_key = CryptoUtil.encrypt(page_object.api_key) + await AiModelDao.add_ai_model_dao(query_db, page_object) + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_ai_model_services(cls, query_db: AsyncSession, page_object: AiModelModel) -> CrudResponseModel: + """ + 编辑AI模型信息service + + :param query_db: orm对象 + :param page_object: 编辑AI模型对象 + :return: 编辑AI模型校验结果 + """ + edit_ai_model = page_object.model_dump(exclude_unset=True) + if page_object.api_key: + if page_object.api_key == '********' * 3: + if 'api_key' in edit_ai_model: + del edit_ai_model['api_key'] + else: + edit_ai_model['api_key'] = CryptoUtil.encrypt(page_object.api_key) + + ai_model_info = await cls.ai_model_detail_services(query_db, page_object.model_id) + if ai_model_info.model_id: + try: + await AiModelDao.edit_ai_model_dao(query_db, edit_ai_model) + await query_db.commit() + return CrudResponseModel(is_success=True, message='修改成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='AI模型不存在') + + @classmethod + async def delete_ai_model_services( + cls, query_db: AsyncSession, page_object: DeleteAiModelModel + ) -> CrudResponseModel: + """ + 删除AI模型信息service + + :param query_db: orm对象 + :param page_object: 删除AI模型对象 + :return: 删除AI模型校验结果 + """ + if page_object.model_ids: + model_id_list = page_object.model_ids.split(',') + try: + for model_id in model_id_list: + await AiModelDao.delete_ai_model_dao(query_db, AiModelModel(modelId=model_id)) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入AI模型id为空') + + @classmethod + async def ai_model_detail_services(cls, query_db: AsyncSession, model_id: int) -> AiModelModel: + """ + 获取AI模型详细信息service + + :param query_db: orm对象 + :param model_id: AI模型id + :return: AI模型id对应的信息 + """ + ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, model_id=model_id) + result = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel() + + if result.api_key: + result.api_key = '********' * 3 + + return result diff --git a/shierfeng-fastapi-backend/module_generator/controller/gen_controller.py b/shierfeng-fastapi-backend/module_generator/controller/gen_controller.py new file mode 100644 index 0000000..a62e82c --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/controller/gen_controller.py @@ -0,0 +1,297 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitBypassConfig, ApiRateLimitPreset +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import RoleInterfaceAuthDependency, UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import ApiGroup, ApiNamespace +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from config.env import GenConfig +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_generator.entity.vo.gen_vo import ( + DeleteGenTableModel, + EditGenTableModel, + GenTableDbRowModel, + GenTableDetailModel, + GenTablePageQueryModel, + GenTableRowModel, +) +from module_generator.service.gen_service import GenTableColumnService, GenTableService +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +gen_controller = APIRouterPro(prefix='/tool/gen', order_num=17, tags=['代码生成'], dependencies=[PreAuthDependency()]) + + +@gen_controller.get( + '/list', + summary='获取代码生成表分页列表接口', + description='用于获取代码生成表分页列表', + response_model=PageResponseModel[GenTableRowModel], + dependencies=[UserInterfaceAuthDependency('tool:gen:list')], +) +@ApiCache(namespace=ApiNamespace.TOOL_GEN_LIST) +async def get_gen_table_list( + request: Request, + gen_page_query: Annotated[GenTablePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + gen_page_query_result = await GenTableService.get_gen_table_list_services(query_db, gen_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content=gen_page_query_result) + + +@gen_controller.get( + '/db/list', + summary='获取数据库表分页列表接口', + description='用于获取数据库表分页列表', + response_model=PageResponseModel[GenTableDbRowModel], + dependencies=[UserInterfaceAuthDependency('tool:gen:list')], +) +@ApiCache(namespace=ApiNamespace.TOOL_GEN_DB_LIST) +async def get_gen_db_table_list( + request: Request, + gen_page_query: Annotated[GenTablePageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取分页数据 + gen_page_query_result = await GenTableService.get_gen_db_table_list_services(query_db, gen_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content=gen_page_query_result) + + +@gen_controller.post( + '/importTable', + summary='导入数据库表接口', + description='用于导入数据库表', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('tool:gen:import')], +) +@ApiRateLimit( + namespace=ApiNamespace.TOOL_GEN_IMPORT_TABLE, + preset=ApiRateLimitPreset.USER_RESOURCE_GENERATE, + bypass=ApiRateLimitBypassConfig(roles=('admin',)), +) +@ApiCacheEvict(namespaces=ApiGroup.GEN_MUTATION) +@Log(title='代码生成', business_type=BusinessType.IMPORT) +async def import_gen_table( + request: Request, + tables: Annotated[str, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + table_names = tables.split(',') if tables else [] + add_gen_table_list = await GenTableService.get_gen_db_table_list_by_name_services(query_db, table_names) + add_gen_table_result = await GenTableService.import_gen_table_services(query_db, add_gen_table_list, current_user) + logger.info(add_gen_table_result.message) + + return ResponseUtil.success(msg=add_gen_table_result.message) + + +@gen_controller.put( + '', + summary='编辑代码生成表接口', + description='用于编辑代码生成表', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('tool:gen:edit')], +) +@ValidateFields(validate_model='edit_gen_table') +@ApiCacheEvict(namespaces=ApiGroup.GEN_MUTATION) +@Log(title='代码生成', business_type=BusinessType.UPDATE) +async def edit_gen_table( + request: Request, + edit_gen_table: EditGenTableModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + edit_gen_table.update_by = current_user.user.user_name + edit_gen_table.update_time = datetime.now() + await GenTableService.validate_edit(edit_gen_table) + edit_gen_result = await GenTableService.edit_gen_table_services(query_db, edit_gen_table) + logger.info(edit_gen_result.message) + + return ResponseUtil.success(msg=edit_gen_result.message) + + +@gen_controller.delete( + '/{table_ids}', + summary='删除代码生成表接口', + description='用于删除代码生成表', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('tool:gen:remove')], +) +@ApiCacheEvict(namespaces=ApiGroup.GEN_MUTATION) +@Log(title='代码生成', business_type=BusinessType.DELETE) +async def delete_gen_table( + request: Request, + table_ids: Annotated[str, Path(description='需要删除的代码生成业务表ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_gen_table = DeleteGenTableModel(tableIds=table_ids) + delete_gen_table_result = await GenTableService.delete_gen_table_services(query_db, delete_gen_table) + logger.info(delete_gen_table_result.message) + + return ResponseUtil.success(msg=delete_gen_table_result.message) + + +@gen_controller.post( + '/createTable', + summary='创建数据库表接口', + description='用于创建数据库表', + response_model=ResponseBaseModel, + dependencies=[RoleInterfaceAuthDependency('admin')], +) +@ApiRateLimit( + namespace=ApiNamespace.TOOL_GEN_CREATE_TABLE, + preset=ApiRateLimitPreset.USER_RESOURCE_GENERATE, + bypass=ApiRateLimitBypassConfig(roles=('admin',)), +) +@ApiCacheEvict(namespaces=ApiGroup.GEN_MUTATION) +@Log(title='创建表', business_type=BusinessType.OTHER) +async def create_table( + request: Request, + sql: Annotated[str, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + create_table_result = await GenTableService.create_table_services(query_db, sql, current_user) + logger.info(create_table_result.message) + + return ResponseUtil.success(msg=create_table_result.message) + + +@gen_controller.get( + '/batchGenCode', + summary='生成代码文件接口', + description='用于生成代码文件', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回生成的代码文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('tool:gen:code')], +) +@ApiRateLimit( + namespace=ApiNamespace.TOOL_GEN_BATCH_GEN_CODE, + preset=ApiRateLimitPreset.USER_RESOURCE_DOWNLOAD, + bypass=ApiRateLimitBypassConfig(roles=('admin',)), +) +@Log(title='代码生成', business_type=BusinessType.GENCODE) +async def batch_gen_code( + request: Request, + tables: Annotated[str, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + table_names = tables.split(',') if tables else [] + batch_gen_code_result = await GenTableService.batch_gen_code_services(query_db, table_names) + logger.info('生成代码成功') + + return ResponseUtil.streaming(data=bytes2file_response(batch_gen_code_result)) + + +@gen_controller.get( + '/genCode/{table_name}', + summary='生成代码文件到本地接口', + description='用于生成代码文件到本地', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('tool:gen:code')], +) +@ApiRateLimit( + namespace=ApiNamespace.TOOL_GEN_GEN_CODE_LOCAL, + preset=ApiRateLimitPreset.USER_RESOURCE_GENERATE, + bypass=ApiRateLimitBypassConfig(roles=('admin',)), +) +@ApiCacheEvict(namespaces=ApiGroup.GEN_MUTATION) +@Log(title='代码生成', business_type=BusinessType.GENCODE) +async def gen_code_local( + request: Request, + table_name: Annotated[str, Path(description='表名称')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + if not GenConfig.allow_overwrite: + logger.error('【系统预设】不允许生成文件覆盖到本地') + return ResponseUtil.error('【系统预设】不允许生成文件覆盖到本地') + gen_code_local_result = await GenTableService.generate_code_services(query_db, table_name) + logger.info(gen_code_local_result.message) + + return ResponseUtil.success(msg=gen_code_local_result.message) + + +@gen_controller.get( + '/{table_id}', + summary='获取代码生成表详情接口', + description='用于获取指定代码生成表的详细信息', + response_model=DataResponseModel[GenTableDetailModel], + dependencies=[UserInterfaceAuthDependency('tool:gen:query')], +) +@ApiCache(namespace=ApiNamespace.TOOL_GEN_DETAIL) +async def query_detail_gen_table( + request: Request, + table_id: Annotated[int, Path(description='表编号')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + gen_table = await GenTableService.get_gen_table_by_id_services(query_db, table_id) + gen_tables = await GenTableService.get_gen_table_all_services(query_db) + gen_columns = await GenTableColumnService.get_gen_table_column_list_by_table_id_services(query_db, table_id) + gen_table_detail_result = {'info': gen_table, 'rows': gen_columns, 'tables': gen_tables} + logger.info(f'获取table_id为{table_id}的信息成功') + + return ResponseUtil.success(data=gen_table_detail_result) + + +@gen_controller.get( + '/preview/{table_id}', + summary='预览生成的代码接口', + description='用于预览指定代码生成表生成的代码', + response_model=DataResponseModel[dict[str, str]], + dependencies=[UserInterfaceAuthDependency('tool:gen:preview')], +) +@ApiCache(namespace=ApiNamespace.TOOL_GEN_PREVIEW) +async def preview_code( + request: Request, + table_id: Annotated[int, Path(description='表编号')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + preview_code_result = await GenTableService.preview_code_services(query_db, table_id) + logger.info('获取预览代码成功') + + return ResponseUtil.success(data=preview_code_result) + + +@gen_controller.get( + '/synchDb/{table_name}', + summary='同步数据库接口', + description='用于同步指定数据库信息到指定代码生成表', + response_model=DataResponseModel[str], + dependencies=[UserInterfaceAuthDependency('tool:gen:edit')], +) +@ApiRateLimit(namespace=ApiNamespace.TOOL_GEN_SYNC_DB, preset=ApiRateLimitPreset.USER_RESOURCE_SYNC) +@ApiCacheEvict(namespaces=ApiGroup.GEN_MUTATION) +@Log(title='代码生成', business_type=BusinessType.UPDATE) +async def sync_db( + request: Request, + table_name: Annotated[str, Path(description='表名称')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + sync_db_result = await GenTableService.sync_db_services(query_db, table_name) + logger.info(sync_db_result.message) + + return ResponseUtil.success(data=sync_db_result.message) diff --git a/shierfeng-fastapi-backend/module_generator/dao/gen_dao.py b/shierfeng-fastapi-backend/module_generator/dao/gen_dao.py new file mode 100644 index 0000000..f26c071 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/dao/gen_dao.py @@ -0,0 +1,404 @@ +from collections.abc import Sequence +from datetime import datetime, time +from typing import Any + +from sqlalchemy import Row, delete, func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlglot.expressions import Expression + +from common.vo import PageModel +from config.env import DataBaseConfig +from module_generator.entity.do.gen_do import GenTable, GenTableColumn +from module_generator.entity.vo.gen_vo import ( + GenTableBaseModel, + GenTableColumnBaseModel, + GenTableColumnModel, + GenTableModel, + GenTablePageQueryModel, +) +from utils.page_util import PageUtil + + +class GenTableDao: + """ + 代码生成业务表模块数据库操作层 + """ + + @classmethod + async def get_gen_table_by_id(cls, db: AsyncSession, table_id: int) -> GenTable | None: + """ + 根据业务表id获取需要生成的业务表信息 + + :param db: orm对象 + :param table_id: 业务表id + :return: 需要生成的业务表信息对象 + """ + gen_table_info = ( + ( + await db.execute( + select(GenTable).options(selectinload(GenTable.columns)).where(GenTable.table_id == table_id) + ) + ) + .scalars() + .first() + ) + + return gen_table_info + + @classmethod + async def get_gen_table_by_name(cls, db: AsyncSession, table_name: str) -> GenTable | None: + """ + 根据业务表名称获取需要生成的业务表信息 + + :param db: orm对象 + :param table_name: 业务表名称 + :return: 需要生成的业务表信息对象 + """ + gen_table_info = ( + ( + await db.execute( + select(GenTable).options(selectinload(GenTable.columns)).where(GenTable.table_name == table_name) + ) + ) + .scalars() + .first() + ) + + return gen_table_info + + @classmethod + async def get_gen_table_all(cls, db: AsyncSession) -> Sequence[GenTable]: + """ + 获取所有业务表信息 + + :param db: orm对象 + :return: 所有业务表信息 + """ + gen_table_all = (await db.execute(select(GenTable).options(selectinload(GenTable.columns)))).scalars().all() + + return gen_table_all + + @classmethod + async def create_table_by_sql_dao(cls, db: AsyncSession, sql_statements: list[Expression]) -> None: + """ + 根据sql语句创建表结构 + + :param db: orm对象 + :param sql_statements: sql语句的ast列表 + :return: + """ + for sql_statement in sql_statements: + sql = sql_statement.sql(dialect=DataBaseConfig.sqlglot_parse_dialect) + await db.execute(text(sql)) + + @classmethod + async def get_gen_table_list( + cls, db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取代码生成业务表列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 代码生成业务表列表信息对象 + """ + query = ( + select(GenTable) + .options(selectinload(GenTable.columns)) + .where( + func.lower(GenTable.table_name).like(f'%{query_object.table_name.lower()}%') + if query_object.table_name + else True, + func.lower(GenTable.table_comment).like(f'%{query_object.table_comment.lower()}%') + if query_object.table_comment + else True, + GenTable.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_time and query_object.end_time + else True, + ) + .distinct() + ) + gen_table_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return gen_table_list + + @classmethod + async def get_gen_db_table_list( + cls, db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取数据库列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 数据库列表信息对象 + """ + if DataBaseConfig.db_type == 'postgresql': + query_sql = """ + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + list_table + where + table_name not like 'apscheduler_%' + and table_name not like 'gen_%' + and table_name not in (select table_name from gen_table) + """ + else: + query_sql = r""" + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + information_schema.tables + where + table_schema = (select database()) + and table_name not like 'apscheduler\_%' + and table_name not like 'gen\_%' + and table_name not in (select table_name from gen_table) + """ + if query_object.table_name: + query_sql += """and lower(table_name) like lower(concat('%', :table_name, '%'))""" + if query_object.table_comment: + query_sql += """and lower(table_comment) like lower(concat('%', :table_comment, '%'))""" + if query_object.begin_time: + if DataBaseConfig.db_type == 'postgresql': + query_sql += """and create_time::date >= to_date(:begin_time, 'yyyy-MM-dd')""" + else: + query_sql += """and date_format(create_time, '%Y%m%d') >= date_format(:begin_time, '%Y%m%d')""" + if query_object.end_time: + if DataBaseConfig.db_type == 'postgresql': + query_sql += """and create_time::date <= to_date(:end_time, 'yyyy-MM-dd')""" + else: + query_sql += """and date_format(create_time, '%Y%m%d') >= date_format(:end_time, '%Y%m%d')""" + query_sql += """order by create_time desc""" + query = select( + text(query_sql).bindparams(**query_object.model_dump(exclude_none=True, exclude={'page_num', 'page_size'})) + ) + gen_db_table_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return gen_db_table_list + + @classmethod + async def get_gen_db_table_list_by_names(cls, db: AsyncSession, table_names: list[str]) -> Sequence[Row]: + """ + 根据业务表名称组获取数据库列表信息 + + :param db: orm对象 + :param table_names: 业务表名称组 + :return: 数据库列表信息对象 + """ + if DataBaseConfig.db_type == 'postgresql': + query_sql = """ + select + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + list_table + where + table_name not like 'qrtz_%' + and table_name not like 'gen_%' + and table_name = any(:table_names) + """ + else: + query_sql = r""" + select + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + information_schema.tables + where + table_name not like 'qrtz\_%' + and table_name not like 'gen\_%' + and table_schema = (select database()) + and table_name in :table_names + """ + query = text(query_sql).bindparams(table_names=tuple(table_names)) + gen_db_table_list = (await db.execute(query)).fetchall() + + return gen_db_table_list + + @classmethod + async def add_gen_table_dao(cls, db: AsyncSession, gen_table: GenTableModel) -> GenTable: + """ + 新增业务表数据库操作 + + :param db: orm对象 + :param gen_table: 业务表对象 + :return: + """ + db_gen_table = GenTable(**GenTableBaseModel(**gen_table.model_dump(by_alias=True)).model_dump()) + db.add(db_gen_table) + await db.flush() + + return db_gen_table + + @classmethod + async def edit_gen_table_dao(cls, db: AsyncSession, gen_table: dict) -> None: + """ + 编辑业务表数据库操作 + + :param db: orm对象 + :param gen_table: 需要更新的业务表字典 + :return: + """ + await db.execute(update(GenTable), [GenTableBaseModel(**gen_table).model_dump()]) + + @classmethod + async def delete_gen_table_dao(cls, db: AsyncSession, gen_table: GenTableModel) -> None: + """ + 删除业务表数据库操作 + + :param db: orm对象 + :param gen_table: 业务表对象 + :return: + """ + await db.execute(delete(GenTable).where(GenTable.table_id.in_([gen_table.table_id]))) + + +class GenTableColumnDao: + """ + 代码生成业务表字段模块数据库操作层 + """ + + @classmethod + async def get_gen_table_column_list_by_table_id(cls, db: AsyncSession, table_id: int) -> GenTableColumn: + """ + 根据业务表id获取需要生成的业务表字段列表信息 + + :param db: orm对象 + :param table_id: 业务表id + :return: 需要生成的业务表字段列表信息对象 + """ + gen_table_column_list = ( + ( + await db.execute( + select(GenTableColumn).where(GenTableColumn.table_id == table_id).order_by(GenTableColumn.sort) + ) + ) + .scalars() + .all() + ) + + return gen_table_column_list + + @classmethod + async def get_gen_db_table_columns_by_name(cls, db: AsyncSession, table_name: str) -> Sequence[Row]: + """ + 根据业务表名称获取业务表字段列表信息 + + :param db: orm对象 + :param table_name: 业务表名称 + :return: 业务表字段列表信息对象 + """ + if DataBaseConfig.db_type == 'postgresql': + query_sql = """ + select + column_name, is_required, is_pk, sort, column_comment, is_increment, column_type + from + list_column + where + table_name = :table_name + """ + else: + query_sql = """ + select + column_name as column_name, + case + when is_nullable = 'no' and column_key != 'PRI' then '1' + else '0' + end as is_required, + case + when column_key = 'PRI' then '1' + else '0' + end as is_pk, + ordinal_position as sort, + column_comment as column_comment, + case + when extra = 'auto_increment' then '1' + else '0' + end as is_increment, + column_type as column_type + from + information_schema.columns + where + table_schema = (select database()) + and table_name = :table_name + order by + ordinal_position + """ + query = text(query_sql).bindparams(table_name=table_name) + gen_db_table_columns = (await db.execute(query)).fetchall() + + return gen_db_table_columns + + @classmethod + async def add_gen_table_column_dao(cls, db: AsyncSession, gen_table_column: GenTableColumnModel) -> GenTableColumn: + """ + 新增业务表字段数据库操作 + + :param db: orm对象 + :param gen_table_column: 岗位对象 + :return: + """ + db_gen_table_column = GenTableColumn( + **GenTableColumnBaseModel(**gen_table_column.model_dump(by_alias=True)).model_dump() + ) + db.add(db_gen_table_column) + await db.flush() + + return db_gen_table_column + + @classmethod + async def edit_gen_table_column_dao(cls, db: AsyncSession, gen_table_column: dict) -> None: + """ + 编辑业务表字段数据库操作 + + :param db: orm对象 + :param gen_table_column: 需要更新的业务表字段字典 + :return: + """ + await db.execute(update(GenTableColumn), [GenTableColumnBaseModel(**gen_table_column).model_dump()]) + + @classmethod + async def delete_gen_table_column_by_table_id_dao( + cls, db: AsyncSession, gen_table_column: GenTableColumnModel + ) -> None: + """ + 通过业务表id删除业务表字段数据库操作 + + :param db: orm对象 + :param gen_table_column: 业务表字段对象 + :return: + """ + await db.execute(delete(GenTableColumn).where(GenTableColumn.table_id.in_([gen_table_column.table_id]))) + + @classmethod + async def delete_gen_table_column_by_column_id_dao( + cls, db: AsyncSession, gen_table_column: GenTableColumnModel + ) -> None: + """ + 通过业务字段id删除业务表字段数据库操作 + + :param db: orm对象 + :param post: 业务表字段对象 + :return: + """ + await db.execute(delete(GenTableColumn).where(GenTableColumn.column_id.in_([gen_table_column.column_id]))) diff --git a/shierfeng-fastapi-backend/module_generator/entity/do/gen_do.py b/shierfeng-fastapi-backend/module_generator/entity/do/gen_do.py new file mode 100644 index 0000000..90b25e0 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/entity/do/gen_do.py @@ -0,0 +1,106 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String +from sqlalchemy.orm import foreign, relationship + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class GenTable(Base): + """ + 代码生成业务表 + """ + + __tablename__ = 'gen_table' + __table_args__ = {'comment': '代码生成业务表'} + + table_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='编号') + table_name = Column(String(200), nullable=True, server_default="''", comment='表名称') + table_comment = Column(String(500), nullable=True, server_default="''", comment='表描述') + sub_table_name = Column( + String(64), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='关联子表的表名', + ) + sub_table_fk_name = Column( + String(64), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='子表关联的外键名', + ) + class_name = Column(String(100), nullable=True, server_default="''", comment='实体类名称') + tpl_category = Column( + String(200), nullable=True, server_default='crud', comment='使用的模板(crud单表操作 tree树表操作)' + ) + tpl_web_type = Column( + String(30), nullable=True, server_default="''", comment='前端模板类型(element-ui模版 element-plus模版)' + ) + package_name = Column(String(100), nullable=True, comment='生成包路径') + module_name = Column(String(30), nullable=True, comment='生成模块名') + business_name = Column(String(30), nullable=True, comment='生成业务名') + function_name = Column(String(50), nullable=True, comment='生成功能名') + function_author = Column(String(50), nullable=True, comment='生成功能作者') + gen_type = Column(CHAR(1), nullable=True, server_default='0', comment='生成代码方式(0zip压缩包 1自定义路径)') + gen_path = Column(String(200), nullable=True, server_default='/', comment='生成路径(不填默认项目路径)') + options = Column(String(1000), nullable=True, comment='其它生成选项') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) + + columns = relationship( + 'GenTableColumn', + primaryjoin=lambda: GenTable.table_id == foreign(GenTableColumn.table_id), + order_by='GenTableColumn.sort', + back_populates='tables', + ) + + +class GenTableColumn(Base): + """ + 代码生成业务表字段 + """ + + __tablename__ = 'gen_table_column' + __table_args__ = {'comment': '代码生成业务表字段'} + + column_id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False, comment='编号') + table_id = Column(BigInteger, nullable=True, comment='归属表编号') + column_name = Column(String(200), nullable=True, comment='列名称') + column_comment = Column(String(500), nullable=True, comment='列描述') + column_type = Column(String(100), nullable=True, comment='列类型') + python_type = Column(String(500), nullable=True, comment='PYTHON类型') + python_field = Column(String(200), nullable=True, comment='PYTHON字段名') + is_pk = Column(CHAR(1), nullable=True, comment='是否主键(1是)') + is_increment = Column(CHAR(1), nullable=True, comment='是否自增(1是)') + is_required = Column(CHAR(1), nullable=True, comment='是否必填(1是)') + is_unique = Column(CHAR(1), nullable=True, comment='是否唯一(1是)') + is_insert = Column(CHAR(1), nullable=True, comment='是否为插入字段(1是)') + is_edit = Column(CHAR(1), nullable=True, comment='是否编辑字段(1是)') + is_list = Column(CHAR(1), nullable=True, comment='是否列表字段(1是)') + is_query = Column(CHAR(1), nullable=True, comment='是否查询字段(1是)') + query_type = Column( + String(200), nullable=True, server_default='EQ', comment='查询方式(等于、不等于、大于、小于、范围)' + ) + html_type = Column( + String(200), nullable=True, comment='显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)' + ) + dict_type = Column(String(200), nullable=True, server_default="''", comment='字典类型') + sort = Column(Integer, nullable=True, comment='排序') + create_by = Column(String(64), server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间') + update_by = Column(String(64), server_default="''", comment='更新者') + update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间') + + tables = relationship( + 'GenTable', primaryjoin=lambda: foreign(GenTableColumn.table_id) == GenTable.table_id, back_populates='columns' + ) diff --git a/shierfeng-fastapi-backend/module_generator/entity/vo/gen_vo.py b/shierfeng-fastapi-backend/module_generator/entity/vo/gen_vo.py new file mode 100644 index 0000000..2d655ba --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/entity/vo/gen_vo.py @@ -0,0 +1,294 @@ +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic.alias_generators import to_camel +from pydantic_validation_decorator import NotBlank + +from common.constant import GenConstant +from utils.string_util import StringUtil + + +class GenTableBaseModel(BaseModel): + """ + 代码生成业务表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + table_id: int | None = Field(default=None, description='编号') + table_name: str | None = Field(default=None, description='表名称') + table_comment: str | None = Field(default=None, description='表描述') + sub_table_name: str | None = Field(default=None, description='关联子表的表名') + sub_table_fk_name: str | None = Field(default=None, description='子表关联的外键名') + class_name: str | None = Field(default=None, description='实体类名称') + tpl_category: str | None = Field(default=None, description='使用的模板(crud单表操作 tree树表操作)') + tpl_web_type: str | None = Field(default=None, description='前端模板类型(element-ui模版 element-plus模版)') + package_name: str | None = Field(default=None, description='生成包路径') + module_name: str | None = Field(default=None, description='生成模块名') + business_name: str | None = Field(default=None, description='生成业务名') + function_name: str | None = Field(default=None, description='生成功能名') + function_author: str | None = Field(default=None, description='生成功能作者') + gen_type: Literal['0', '1'] | None = Field(default=None, description='生成代码方式(0zip压缩包 1自定义路径)') + gen_path: str | None = Field(default=None, description='生成路径(不填默认项目路径)') + options: str | None = Field(default=None, description='其它生成选项') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + + @NotBlank(field_name='table_name', message='表名称不能为空') + def get_table_name(self) -> str | None: + return self.table_name + + @NotBlank(field_name='table_comment', message='表描述不能为空') + def get_table_comment(self) -> str | None: + return self.table_comment + + @NotBlank(field_name='class_name', message='实体类名称不能为空') + def get_class_name(self) -> str | None: + return self.class_name + + @NotBlank(field_name='package_name', message='生成包路径不能为空') + def get_package_name(self) -> str | None: + return self.package_name + + @NotBlank(field_name='module_name', message='生成模块名不能为空') + def get_module_name(self) -> str | None: + return self.module_name + + @NotBlank(field_name='business_name', message='生成业务名不能为空') + def get_business_name(self) -> str | None: + return self.business_name + + @NotBlank(field_name='function_name', message='生成功能名不能为空') + def get_function_name(self) -> str | None: + return self.function_name + + @NotBlank(field_name='function_author', message='生成功能作者不能为空') + def get_function_author(self) -> str | None: + return self.function_author + + def validate_fields(self) -> None: + self.get_table_name() + self.get_table_comment() + self.get_class_name() + self.get_package_name() + self.get_module_name() + self.get_business_name() + self.get_function_name() + self.get_function_author() + + +class GenTableRowModel(GenTableBaseModel): + """ + 代码生成业务表行数据模型 + """ + + columns: list['GenTableColumnBaseModel'] | None = Field(default=None, description='表列信息') + + +class GenTableDbRowModel(BaseModel): + """ + 代码生成业务表数据库行数据模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + table_name: str | None = Field(default=None, description='表名称') + table_comment: str | None = Field(default=None, description='表描述') + create_time: datetime | None = Field(default=None, description='创建时间') + update_time: datetime | None = Field(default=None, description='更新时间') + + +class GenTableModel(GenTableBaseModel): + """ + 代码生成业务表模型 + """ + + pk_column: Optional['GenTableColumnModel'] = Field(default=None, description='主键信息') + sub_table: Optional['GenTableModel'] = Field(default=None, description='子表信息') + columns: list['GenTableColumnModel'] | None = Field(default=None, description='表列信息') + tree_code: str | None = Field(default=None, description='树编码字段') + tree_parent_code: str | None = Field(default=None, description='树父编码字段') + tree_name: str | None = Field(default=None, description='树名称字段') + parent_menu_id: int | None = Field(default=None, description='上级菜单ID字段') + parent_menu_name: str | None = Field(default=None, description='上级菜单名称字段') + sub: bool | None = Field(default=None, description='是否为子表') + tree: bool | None = Field(default=None, description='是否为树表') + crud: bool | None = Field(default=None, description='是否为单表') + + @model_validator(mode='after') + def check_some_is(self) -> 'GenTableModel': + self.sub = bool(self.tpl_category and self.tpl_category == GenConstant.TPL_SUB) + self.tree = bool(self.tpl_category and self.tpl_category == GenConstant.TPL_TREE) + self.crud = bool(self.tpl_category and self.tpl_category == GenConstant.TPL_CRUD) + return self + + +class EditGenTableModel(GenTableModel): + """ + 修改代码生成业务表模型 + """ + + params: Optional['GenTableParamsModel'] = Field(default=None, description='业务表参数') + + +class GenTableParamsModel(BaseModel): + """ + 代码生成业务表参数模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + tree_code: str | None = Field(default=None, description='树编码字段') + tree_parent_code: str | None = Field(default=None, description='树父编码字段') + tree_name: str | None = Field(default=None, description='树名称字段') + parent_menu_id: int | None = Field(default=None, description='上级菜单ID字段') + + +class GenTableQueryModel(GenTableBaseModel): + """ + 代码生成业务表不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class GenTablePageQueryModel(GenTableQueryModel): + """ + 代码生成业务表分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class GenTableDetailModel(BaseModel): + """ + 代码生成业务表详情模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + info: GenTableModel | None = Field(default=None, description='业务表信息') + rows: list['GenTableColumnModel'] | None = Field(default=None, description='表列信息') + tables: list['GenTableModel'] | None = Field(default=None, description='所有业务表信息') + + +class DeleteGenTableModel(BaseModel): + """ + 删除代码生成业务表模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + table_ids: str = Field(description='需要删除的代码生成业务表ID') + + +class GenTableColumnBaseModel(BaseModel): + """ + 代码生成业务表字段对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + column_id: int | None = Field(default=None, description='编号') + table_id: int | None = Field(default=None, description='归属表编号') + column_name: str | None = Field(default=None, description='列名称') + column_comment: str | None = Field(default=None, description='列描述') + column_type: str | None = Field(default=None, description='列类型') + python_type: str | None = Field(default=None, description='PYTHON类型') + python_field: str | None = Field(default=None, description='PYTHON字段名') + is_pk: str | None = Field(default=None, description='是否主键(1是)') + is_increment: str | None = Field(default=None, description='是否自增(1是)') + is_required: str | None = Field(default=None, description='是否必填(1是)') + is_unique: str | None = Field(default=None, description='是否唯一(1是)') + is_insert: str | None = Field(default=None, description='是否为插入字段(1是)') + is_edit: str | None = Field(default=None, description='是否编辑字段(1是)') + is_list: str | None = Field(default=None, description='是否列表字段(1是)') + is_query: str | None = Field(default=None, description='是否查询字段(1是)') + query_type: str | None = Field(default=None, description='查询方式(等于、不等于、大于、小于、范围)') + html_type: str | None = Field( + default=None, description='显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)' + ) + dict_type: str | None = Field(default=None, description='字典类型') + sort: int | None = Field(default=None, description='排序') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + + @NotBlank(field_name='python_field', message='Python属性不能为空') + def get_python_field(self) -> str | None: + return self.python_field + + def validate_fields(self) -> None: + self.get_python_field() + + +class GenTableColumnModel(GenTableColumnBaseModel): + """ + 代码生成业务表字段模型 + """ + + cap_python_field: str | None = Field(default=None, description='字段大写形式') + pk: bool | None = Field(default=None, description='是否主键') + increment: bool | None = Field(default=None, description='是否自增') + required: bool | None = Field(default=None, description='是否必填') + unique: bool | None = Field(default=None, description='是否唯一') + insert: bool | None = Field(default=None, description='是否为插入字段') + edit: bool | None = Field(default=None, description='是否编辑字段') + list: bool | None = Field(default=None, description='是否列表字段') + query: bool | None = Field(default=None, description='是否查询字段') + super_column: bool | None = Field(default=None, description='是否为基类字段') + usable_column: bool | None = Field(default=None, description='是否为基类字段白名单') + + @model_validator(mode='after') + def check_some_is(self) -> 'GenTableModel': + self.cap_python_field = self.python_field[0].upper() + self.python_field[1:] if self.python_field else None + self.pk = self.is_pk and self.is_pk == '1' + self.increment = bool(self.is_increment and self.is_increment == '1') + self.required = bool(self.is_required and self.is_required == '1') + self.unique = bool(self.is_unique and self.is_unique == '1') + self.insert = bool(self.is_insert and self.is_insert == '1') + self.edit = bool(self.is_edit and self.is_edit == '1') + self.list = bool(self.is_list and self.is_list == '1') + self.query = bool(self.is_query and self.is_query == '1') + self.super_column = bool( + StringUtil.equals_any_ignore_case(self.python_field, GenConstant.TREE_ENTITY + GenConstant.BASE_ENTITY) + ) + self.usable_column = bool( + StringUtil.equals_any_ignore_case(self.python_field, ['parentId', 'orderNum', 'remark']) + ) + return self + + +class GenTableColumnQueryModel(GenTableColumnBaseModel): + """ + 代码生成业务表字段不分页查询模型 + """ + + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class GenTableColumnPageQueryModel(GenTableColumnQueryModel): + """ + 代码生成业务表字段分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class DeleteGenTableColumnModel(BaseModel): + """ + 删除代码生成业务表字段模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + column_ids: str = Field(description='需要删除的代码生成业务表字段ID') diff --git a/shierfeng-fastapi-backend/module_generator/service/gen_service.py b/shierfeng-fastapi-backend/module_generator/service/gen_service.py new file mode 100644 index 0000000..1ef9bc5 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/service/gen_service.py @@ -0,0 +1,506 @@ +import io +import json +import os +import zipfile +from datetime import datetime +from typing import Any + +import aiofiles +from sqlalchemy.ext.asyncio import AsyncSession +from sqlglot import parse as sqlglot_parse +from sqlglot.expressions import Add, Alter, Create, Delete, Drop, Expression, Insert, Table, TruncateTable, Update + +from common.constant import GenConstant +from common.vo import CrudResponseModel, PageModel +from config.env import DataBaseConfig, GenConfig +from exceptions.exception import ServiceException +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_generator.dao.gen_dao import GenTableColumnDao, GenTableDao +from module_generator.entity.vo.gen_vo import ( + DeleteGenTableModel, + EditGenTableModel, + GenTableColumnModel, + GenTableModel, + GenTablePageQueryModel, +) +from utils.common_util import CamelCaseUtil +from utils.gen_util import GenUtils +from utils.template_util import TemplateInitializer, TemplateUtils + + +class GenTableService: + """ + 代码生成业务表服务层 + """ + + @classmethod + async def get_gen_table_list_services( + cls, query_db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取代码生成业务表列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 代码生成业务列表信息对象 + """ + gen_table_list_result = await GenTableDao.get_gen_table_list(query_db, query_object, is_page) + + return gen_table_list_result + + @classmethod + async def get_gen_db_table_list_services( + cls, query_db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取数据库列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: 数据库列表信息对象 + """ + gen_db_table_list_result = await GenTableDao.get_gen_db_table_list(query_db, query_object, is_page) + + return gen_db_table_list_result + + @classmethod + async def get_gen_db_table_list_by_name_services( + cls, query_db: AsyncSession, table_names: list[str] + ) -> list[GenTableModel]: + """ + 根据表名称组获取数据库列表信息service + + :param query_db: orm对象 + :param table_names: 表名称组 + :return: 数据库列表信息对象 + """ + gen_db_table_list_result = await GenTableDao.get_gen_db_table_list_by_names(query_db, table_names) + + return [GenTableModel(**gen_table) for gen_table in CamelCaseUtil.transform_result(gen_db_table_list_result)] + + @classmethod + async def import_gen_table_services( + cls, query_db: AsyncSession, gen_table_list: list[GenTableModel], current_user: CurrentUserModel + ) -> CrudResponseModel: + """ + 导入表结构service + + :param query_db: orm对象 + :param gen_table_list: 导入表列表 + :param current_user: 当前用户信息对象 + :return: 导入结果 + """ + try: + for table in gen_table_list: + table_name = table.table_name + GenUtils.init_table(table, current_user.user.user_name) + add_gen_table = await GenTableDao.add_gen_table_dao(query_db, table) + if add_gen_table: + table.table_id = add_gen_table.table_id + gen_table_columns = await GenTableColumnDao.get_gen_db_table_columns_by_name(query_db, table_name) + for column in [ + GenTableColumnModel(**gen_table_column) + for gen_table_column in CamelCaseUtil.transform_result(gen_table_columns) + ]: + GenUtils.init_column_field(column, table) + await GenTableColumnDao.add_gen_table_column_dao(query_db, column) + await query_db.commit() + return CrudResponseModel(is_success=True, message='导入成功') + except Exception as e: + await query_db.rollback() + raise ServiceException(message=f'导入失败, {e}') from e + + @classmethod + async def edit_gen_table_services(cls, query_db: AsyncSession, page_object: EditGenTableModel) -> CrudResponseModel: + """ + 编辑业务表信息service + + :param query_db: orm对象 + :param page_object: 编辑业务表对象 + :return: 编辑业务表校验结果 + """ + edit_gen_table = page_object.model_dump(exclude_unset=True, by_alias=True) + gen_table_info = await cls.get_gen_table_by_id_services(query_db, page_object.table_id) + if gen_table_info.table_id: + try: + edit_gen_table['options'] = json.dumps(edit_gen_table.get('params')) + await GenTableDao.edit_gen_table_dao(query_db, edit_gen_table) + for gen_table_column in page_object.columns: + gen_table_column.update_by = page_object.update_by + gen_table_column.update_time = datetime.now() + await GenTableColumnDao.edit_gen_table_column_dao( + query_db, gen_table_column.model_dump(by_alias=True) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='业务表不存在') + + @classmethod + async def delete_gen_table_services( + cls, query_db: AsyncSession, page_object: DeleteGenTableModel + ) -> CrudResponseModel: + """ + 删除业务表信息service + + :param query_db: orm对象 + :param page_object: 删除业务表对象 + :return: 删除业务表校验结果 + """ + if page_object.table_ids: + table_id_list = page_object.table_ids.split(',') + try: + for table_id in table_id_list: + await GenTableDao.delete_gen_table_dao(query_db, GenTableModel(tableId=table_id)) + await GenTableColumnDao.delete_gen_table_column_by_table_id_dao( + query_db, GenTableColumnModel(tableId=table_id) + ) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入业务表id为空') + + @classmethod + async def get_gen_table_by_id_services(cls, query_db: AsyncSession, table_id: int) -> GenTableModel: + """ + 获取需要生成的业务表详细信息service + + :param query_db: orm对象 + :param table_id: 需要生成的业务表id + :return: 需要生成的业务表id对应的信息 + """ + gen_table = await GenTableDao.get_gen_table_by_id(query_db, table_id) + result = await cls.set_table_from_options(GenTableModel(**CamelCaseUtil.transform_result(gen_table))) + + return result + + @classmethod + async def get_gen_table_all_services(cls, query_db: AsyncSession) -> list[GenTableModel]: + """ + 获取所有业务表信息service + + :param query_db: orm对象 + :return: 所有业务表信息 + """ + gen_table_all = await GenTableDao.get_gen_table_all(query_db) + result = [GenTableModel(**gen_table) for gen_table in CamelCaseUtil.transform_result(gen_table_all)] + + return result + + @classmethod + async def create_table_services( + cls, query_db: AsyncSession, sql: str, current_user: CurrentUserModel + ) -> CrudResponseModel: + """ + 创建表结构service + + :param query_db: orm对象 + :param sql: 建表语句 + :param current_user: 当前用户信息对象 + :return: 创建表结构结果 + """ + sql_statements = sqlglot_parse(sql, dialect=DataBaseConfig.sqlglot_parse_dialect) + if cls.__is_valid_create_table(sql_statements): + try: + table_names = cls.__get_table_names(sql_statements) + await GenTableDao.create_table_by_sql_dao(query_db, sql_statements) + gen_table_list = await cls.get_gen_db_table_list_by_name_services(query_db, table_names) + await cls.import_gen_table_services(query_db, gen_table_list, current_user) + + return CrudResponseModel(is_success=True, message='创建表结构成功') + except Exception as e: + raise ServiceException(message=f'创建表结构异常,详细错误信息:{e}') from e + else: + raise ServiceException(message='建表语句不合法') + + @classmethod + def __is_valid_create_table(cls, sql_statements: list[Expression]) -> bool: + """ + 校验sql语句是否为合法的建表语句 + + :param sql_statements: sql语句的ast列表 + :return: 校验结果 + """ + validate_create = [isinstance(sql_statement, Create) for sql_statement in sql_statements] + validate_forbidden_keywords = [ + isinstance( + sql_statement, + (Add, Alter, Delete, Drop, Insert, TruncateTable, Update), + ) + for sql_statement in sql_statements + ] + return not (not any(validate_create) or any(validate_forbidden_keywords)) + + @classmethod + def __get_table_names(cls, sql_statements: list[Expression]) -> list[str]: + """ + 获取sql语句中所有的建表表名 + + :param sql_statements: sql语句的ast列表 + :return: 建表表名列表 + """ + table_names = [ + sql_statement.find(Table).name for sql_statement in sql_statements if isinstance(sql_statement, Create) + ] + + return table_names + + @classmethod + async def preview_code_services(cls, query_db: AsyncSession, table_id: int) -> dict[str, str]: + """ + 预览代码service + + :param query_db: orm对象 + :param table_id: 业务表id + :return: 预览数据列表 + """ + gen_table = GenTableModel( + **CamelCaseUtil.transform_result(await GenTableDao.get_gen_table_by_id(query_db, table_id)) + ) + await cls.set_sub_table(query_db, gen_table) + await cls.set_pk_column(gen_table) + env = TemplateInitializer.init_jinja2() + context = TemplateUtils.prepare_context(gen_table) + template_list = TemplateUtils.get_template_list(gen_table.tpl_category, gen_table.tpl_web_type) + preview_code_result = {} + for template in template_list: + render_content = env.get_template(template).render(**context) + preview_code_result[template] = render_content + return preview_code_result + + @classmethod + async def generate_code_services(cls, query_db: AsyncSession, table_name: str) -> CrudResponseModel: + """ + 生成代码至指定路径service + + :param query_db: orm对象 + :param table_name: 业务表名称 + :return: 生成代码结果 + """ + env = TemplateInitializer.init_jinja2() + render_info = await cls.__get_gen_render_info(query_db, table_name) + try: + for template in render_info[0]: + render_content = env.get_template(template).render(**render_info[2]) + gen_path = cls.__get_gen_path(render_info[3], template) + os.makedirs(os.path.dirname(gen_path), exist_ok=True) + async with aiofiles.open(gen_path, 'w', encoding='utf-8') as f: + await f.write(render_content) + except Exception as e: + raise ServiceException(message=f'渲染模板失败,表名:{render_info[3].table_name},详细错误信息:{e}') from e + + return CrudResponseModel(is_success=True, message='生成代码成功') + + @classmethod + async def batch_gen_code_services(cls, query_db: AsyncSession, table_names: list[str]) -> bytes: + """ + 批量生成代码service + + :param query_db: orm对象 + :param table_names: 业务表名称组 + :return: 下载代码结果 + """ + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for table_name in table_names: + env = TemplateInitializer.init_jinja2() + render_info = await cls.__get_gen_render_info(query_db, table_name) + for template_file, output_file in zip(render_info[0], render_info[1], strict=False): + render_content = env.get_template(template_file).render(**render_info[2]) + zip_file.writestr(output_file, render_content) + + zip_data = zip_buffer.getvalue() + zip_buffer.close() + return zip_data + + @classmethod + async def __get_gen_render_info(cls, query_db: AsyncSession, table_name: str) -> list: + """ + 获取生成代码渲染模板相关信息 + + :param query_db: orm对象 + :param table_name: 业务表名称 + :return: 生成代码渲染模板相关信息 + """ + gen_table = GenTableModel( + **CamelCaseUtil.transform_result(await GenTableDao.get_gen_table_by_name(query_db, table_name)) + ) + await cls.set_sub_table(query_db, gen_table) + await cls.set_pk_column(gen_table) + context = TemplateUtils.prepare_context(gen_table) + template_list = TemplateUtils.get_template_list(gen_table.tpl_category, gen_table.tpl_web_type) + output_files = [TemplateUtils.get_file_name(template, gen_table) for template in template_list] + + return [template_list, output_files, context, gen_table] + + @classmethod + def __get_gen_path(cls, gen_table: GenTableModel, template: str) -> str: + """ + 根据GenTableModel对象和模板名称生成路径 + + :param gen_table: GenTableModel对象 + :param template: 模板名称 + :return: 生成的路径 + """ + gen_path = gen_table.gen_path + if gen_path == '/': + return os.path.join(os.getcwd(), GenConfig.GEN_PATH, TemplateUtils.get_file_name(template, gen_table)) + + return os.path.join(gen_path, TemplateUtils.get_file_name(template, gen_table)) + + @classmethod + async def sync_db_services(cls, query_db: AsyncSession, table_name: str) -> CrudResponseModel: + """ + 同步数据库service + + :param query_db: orm对象 + :param table_name: 业务表名称 + :return: 同步数据库结果 + """ + gen_table = await GenTableDao.get_gen_table_by_name(query_db, table_name) + table = GenTableModel(**CamelCaseUtil.transform_result(gen_table)) + table_columns = table.columns + table_column_map = {column.column_name: column for column in table_columns} + query_db_table_columns = await GenTableColumnDao.get_gen_db_table_columns_by_name(query_db, table_name) + db_table_columns = [ + GenTableColumnModel(**column) for column in CamelCaseUtil.transform_result(query_db_table_columns) + ] + if not db_table_columns: + raise ServiceException('同步数据失败,原表结构不存在') + db_table_column_names = [column.column_name for column in db_table_columns] + try: + for column in db_table_columns: + GenUtils.init_column_field(column, table) + if column.column_name in table_column_map: + prev_column = table_column_map[column.column_name] + column.column_id = prev_column.column_id + if column.list: + column.dict_type = prev_column.dict_type + column.query_type = prev_column.query_type + if ( + prev_column.is_required != '' + and not column.pk + and (column.insert or column.edit) + and (column.usable_column or column.super_column) + ): + column.is_required = prev_column.is_required + column.html_type = prev_column.html_type + await GenTableColumnDao.edit_gen_table_column_dao(query_db, column.model_dump(by_alias=True)) + else: + await GenTableColumnDao.add_gen_table_column_dao(query_db, column) + del_columns = [column for column in table_columns if column.column_name not in db_table_column_names] + if del_columns: + for column in del_columns: + await GenTableColumnDao.delete_gen_table_column_by_column_id_dao(query_db, column) + await query_db.commit() + return CrudResponseModel(is_success=True, message='同步成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def set_sub_table(cls, query_db: AsyncSession, gen_table: GenTableModel) -> None: + """ + 设置主子表信息 + + :param query_db: orm对象 + :param gen_table: 业务表信息 + :return: + """ + if gen_table.sub_table_name: + sub_table = await GenTableDao.get_gen_table_by_name(query_db, gen_table.sub_table_name) + gen_table.sub_table = GenTableModel(**CamelCaseUtil.transform_result(sub_table)) + + @classmethod + async def set_pk_column(cls, gen_table: GenTableModel) -> None: + """ + 设置主键列信息 + + :param gen_table: 业务表信息 + :return: + """ + for column in gen_table.columns: + if column.pk: + gen_table.pk_column = column + break + if gen_table.pk_column is None: + gen_table.pk_column = gen_table.columns[0] + if gen_table.tpl_category == GenConstant.TPL_SUB: + for column in gen_table.sub_table.columns: + if column.pk: + gen_table.sub_table.pk_column = column + break + if gen_table.sub_table.columns is None: + gen_table.sub_table.pk_column = gen_table.sub_table.columns[0] + + @classmethod + async def set_table_from_options(cls, gen_table: GenTableModel) -> GenTableModel: + """ + 设置代码生成其他选项值 + + :param gen_table: 生成对象 + :return: 设置后的生成对象 + """ + params_obj = json.loads(gen_table.options) if gen_table.options else None + if params_obj: + gen_table.tree_code = params_obj.get(GenConstant.TREE_CODE) + gen_table.tree_parent_code = params_obj.get(GenConstant.TREE_PARENT_CODE) + gen_table.tree_name = params_obj.get(GenConstant.TREE_NAME) + gen_table.parent_menu_id = params_obj.get(GenConstant.PARENT_MENU_ID) + gen_table.parent_menu_name = params_obj.get(GenConstant.PARENT_MENU_NAME) + + return gen_table + + @classmethod + async def validate_edit(cls, edit_gen_table: EditGenTableModel) -> None: + """ + 编辑保存参数校验 + + :param edit_gen_table: 编辑业务表对象 + """ + if edit_gen_table.tpl_category == GenConstant.TPL_TREE: + params_obj = edit_gen_table.params.model_dump(by_alias=True) + + if GenConstant.TREE_CODE not in params_obj: + raise ServiceException(message='树编码字段不能为空') + if GenConstant.TREE_PARENT_CODE not in params_obj: + raise ServiceException(message='树父编码字段不能为空') + if GenConstant.TREE_NAME not in params_obj: + raise ServiceException(message='树名称字段不能为空') + if edit_gen_table.tpl_category == GenConstant.TPL_SUB: + if not edit_gen_table.sub_table_name: + raise ServiceException(message='关联子表的表名不能为空') + if not edit_gen_table.sub_table_fk_name: + raise ServiceException(message='子表关联的外键名不能为空') + + +class GenTableColumnService: + """ + 代码生成业务表字段服务层 + """ + + @classmethod + async def get_gen_table_column_list_by_table_id_services( + cls, query_db: AsyncSession, table_id: int + ) -> list[GenTableColumnModel]: + """ + 获取业务表字段列表信息service + + :param query_db: orm对象 + :param table_id: 业务表格id + :return: 业务表字段列表信息对象 + """ + gen_table_column_list_result = await GenTableColumnDao.get_gen_table_column_list_by_table_id(query_db, table_id) + + return [ + GenTableColumnModel(**gen_table_column) + for gen_table_column in CamelCaseUtil.transform_result(gen_table_column_list_result) + ] diff --git a/shierfeng-fastapi-backend/module_generator/templates/js/api.js.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/js/api.js.jinja2 new file mode 100644 index 0000000..3a2a5a9 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/js/api.js.jinja2 @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 查询{{ functionName }}列表 +export function list{{ BusinessName }}(query) { + return request({ + url: '/{{ moduleName }}/{{ businessName }}/list', + method: 'get', + params: query + }) +} + +// 查询{{ functionName }}详细 +export function get{{ BusinessName }}({{ pkColumn.python_field }}) { + return request({ + url: '/{{ moduleName }}/{{ businessName }}/' + {{ pkColumn.python_field }}, + method: 'get' + }) +} + +// 新增{{ functionName }} +export function add{{ BusinessName }}(data) { + return request({ + url: '/{{ moduleName }}/{{ businessName }}', + method: 'post', + data: data + }) +} + +// 修改{{ functionName }} +export function update{{ BusinessName }}(data) { + return request({ + url: '/{{ moduleName }}/{{ businessName }}', + method: 'put', + data: data + }) +} + +// 删除{{ functionName }} +export function del{{ BusinessName }}({{ pkColumn.python_field }}) { + return request({ + url: '/{{ moduleName }}/{{ businessName }}/' + {{ pkColumn.python_field }}, + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-backend/module_generator/templates/python/controller.py.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/python/controller.py.jinja2 new file mode 100644 index 0000000..b4340ea --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/python/controller.py.jinja2 @@ -0,0 +1,191 @@ +{% set pkField = pkColumn.python_field %} +{% set pk_field = pkColumn.python_field | camel_to_snake %} +{% set pkParentheseIndex = pkColumn.column_comment.find("(") %} +{% set pk_field_comment = pkColumn.column_comment[:pkParentheseIndex] if pkParentheseIndex != -1 else pkColumn.column_comment %} +{% set need_import_datetime = namespace(has_datetime=False) %} +{% for column in columns %} +{% if column.python_field in ["createTime", "updateTime"] %} +{% set need_import_datetime.has_datetime = True %} +{% endif %} +{% endfor %} +{% if need_import_datetime.has_datetime %} +from datetime import datetime +{% endif %} +from typing import Annotated + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic_validation_decorator import ValidateFields +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.log_annotation import Log +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from {{ packageName }}.service.{{ businessName }}_service import {{ BusinessName }}Service +from {{ packageName }}.entity.vo.{{ businessName }}_vo import Delete{{ BusinessName }}Model, {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + + +{{ businessName }}_controller = APIRouterPro( + prefix='/{{ moduleName }}/{{ businessName }}', order_num=50, tags=['{{ functionName }}'], dependencies=[PreAuthDependency()] +) + + +@{{ businessName }}_controller.get( + '/list', + summary='获取{{ functionName }}分页列表接口', + description='用于获取{{ functionName }}分页列表', + response_model=PageResponseModel[{{ BusinessName }}Model], + dependencies=[UserInterfaceAuthDependency('{{ permissionPrefix }}:list')], +) +async def get_{{ moduleName }}_{{ businessName }}_list( + request: Request, + {% if table.crud or table.sub %}{{ businessName }}_page_query{% elif table.tree %}{{ businessName }}_query{% endif %}: Annotated[{{ BusinessName }}PageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + {% if table.crud or table.sub %} + # 获取分页数据 + {{ businessName }}_page_query_result = await {{ BusinessName }}Service.get_{{ businessName }}_list_services(query_db, {{ businessName }}_page_query, is_page=True) + logger.info('获取成功') + + return ResponseUtil.success(model_content={{ businessName }}_page_query_result) + {% elif table.tree %} + {{ businessName }}_query_result = await {{ BusinessName }}Service.get_{{ businessName }}_list_services(query_db, {{ businessName }}_query) + logger.info('获取成功') + + return ResponseUtil.success(data={{ businessName }}_query_result) + {% endif %} + + +@{{ businessName }}_controller.post( + '', + summary='新增{{ functionName }}接口', + description='用于新增{{ functionName }}', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('{{ permissionPrefix }}:add')], +) +@ValidateFields(validate_model='add_{{ businessName }}') +@Log(title='{{ functionName }}', business_type=BusinessType.INSERT) +async def add_{{ moduleName }}_{{ businessName }}( + request: Request, + add_{{ businessName }}: {{ BusinessName }}Model, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + {% for column in columns %} + {% if column.python_field == "createBy" %} + add_{{ businessName }}.create_by = current_user.user.user_name + {% elif column.python_field == "createTime" %} + add_{{ businessName }}.create_time = datetime.now() + {% elif column.python_field == "updateBy" %} + add_{{ businessName }}.update_by = current_user.user.user_name + {% elif column.python_field == "updateTime" %} + add_{{ businessName }}.update_time = datetime.now() + {% endif %} + {% endfor %} + add_{{ businessName }}_result = await {{ BusinessName }}Service.add_{{ businessName }}_services(query_db, add_{{ businessName }}) + logger.info(add_{{ businessName }}_result.message) + + return ResponseUtil.success(msg=add_{{ businessName }}_result.message) + + +@{{ businessName }}_controller.put( + '', + summary='编辑{{ functionName }}接口', + description='用于编辑{{ functionName }}', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('{{ permissionPrefix }}:edit')], +) +@ValidateFields(validate_model='edit_{{ businessName }}') +@Log(title='{{ functionName }}', business_type=BusinessType.UPDATE) +async def edit_{{ moduleName }}_{{ businessName }}( + request: Request, + edit_{{ businessName }}: {{ BusinessName }}Model, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + {% for column in columns %} + {% if column.python_field == "updateBy" %} + edit_{{ businessName }}.update_by = current_user.user.user_name + {% elif column.python_field == "updateTime" %} + edit_{{ businessName }}.update_time = datetime.now() + {% endif %} + {% endfor %} + edit_{{ businessName }}_result = await {{ BusinessName }}Service.edit_{{ businessName }}_services(query_db, edit_{{ businessName }}) + logger.info(edit_{{ businessName }}_result.message) + + return ResponseUtil.success(msg=edit_{{ businessName }}_result.message) + + +@{{ businessName }}_controller.delete( + '/{% raw %}{{% endraw %}{{ pk_field }}s{% raw %}}{% endraw %}', + summary='删除{{ functionName }}接口', + description='用于删除{{ functionName }}', + response_model=ResponseBaseModel, + dependencies=[UserInterfaceAuthDependency('{{ permissionPrefix }}:remove')], +) +@Log(title='{{ functionName }}', business_type=BusinessType.DELETE) +async def delete_{{ moduleName }}_{{ businessName }}( + request: Request, + {{ pk_field }}s: Annotated[str, Path(description='需要删除的{{ pk_field_comment }}')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + delete_{{ businessName }} = Delete{{ BusinessName }}Model({{ pkField }}s={{ pk_field }}s) + delete_{{ businessName }}_result = await {{ BusinessName }}Service.delete_{{ businessName }}_services(query_db, delete_{{ businessName }}) + logger.info(delete_{{ businessName }}_result.message) + + return ResponseUtil.success(msg=delete_{{ businessName }}_result.message) + + +@{{ businessName }}_controller.get( + '/{% raw %}{{% endraw %}{{ pk_field }}{% raw %}}{% endraw %}', + summary='获取{{ functionName }}详情接口', + description='用于获取指定{{ functionName }}的详细信息', + response_model=DataResponseModel[{{ BusinessName }}Model], + dependencies=[UserInterfaceAuthDependency('{{ permissionPrefix }}:query')] +) +async def query_detail_{{ moduleName }}_{{ businessName }}( + request: Request, + {{ pk_field }}: Annotated[{{ pkColumn.python_type }}, Path(description='{{ pk_field_comment }}')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + {{ businessName }}_detail_result = await {{ BusinessName }}Service.{{ businessName }}_detail_services(query_db, {{ pk_field }}) + logger.info(f'获取{{ pk_field }}为{% raw %}{{% endraw %}{{ pk_field }}{% raw %}}{% endraw %}的信息成功') + + return ResponseUtil.success(data={{ businessName }}_detail_result) + + +@{{ businessName }}_controller.post( + '/export', + summary='导出{{ functionName }}列表接口', + description='用于导出当前符合查询条件的{{ functionName }}列表数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回{{ functionName }}列表excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('{{ permissionPrefix }}:export')], +) +@Log(title='{{ functionName }}', business_type=BusinessType.EXPORT) +async def export_{{ moduleName }}_{{ businessName }}_list( + request: Request, + {{ businessName }}_page_query: Annotated[{{ BusinessName }}PageQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + # 获取全量数据 + {{ businessName }}_query_result = await {{ BusinessName }}Service.get_{{ businessName }}_list_services(query_db, {{ businessName }}_page_query, is_page=False) + {{ businessName }}_export_result = await {{ BusinessName }}Service.export_{{ businessName }}_list_services({% if dicts %}request, {% endif %}{{ businessName }}_query_result) + logger.info('导出成功') + + return ResponseUtil.streaming(data=bytes2file_response({{ businessName }}_export_result)) diff --git a/shierfeng-fastapi-backend/module_generator/templates/python/dao.py.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/python/dao.py.jinja2 new file mode 100644 index 0000000..3337e37 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/python/dao.py.jinja2 @@ -0,0 +1,221 @@ +{% set pkField = pkColumn.python_field %} +{% set pk_field = pkColumn.python_field | camel_to_snake %} +{% set pkParentheseIndex = pkColumn.column_comment.find("(") %} +{% set pk_field_comment = pkColumn.column_comment[:pkParentheseIndex] if pkParentheseIndex != -1 else pkColumn.column_comment %} +{% for column in columns %} +{% if column.query and column.query_type == 'BETWEEN' and column.python_field == "createTime" %} +from datetime import datetime, time +{% endif %} +{% endfor %} +from typing import Any + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession +{% if table.sub %} +from sqlalchemy.orm import selectinload +{% endif %} + +from common.vo import PageModel +{% if table.sub %} +from {{ packageName }}.entity.do.{{ businessName }}_do import {{ ClassName }}, {{ subClassName }} +from {{ packageName }}.entity.vo.{{ businessName }}_vo import {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel, {{ subTable.business_name | capitalize }}Model +{% else %} +from {{ packageName }}.entity.do.{{ businessName }}_do import {{ ClassName }} +from {{ packageName }}.entity.vo.{{ businessName }}_vo import {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel +{% endif %} +from utils.page_util import PageUtil + + +class {{ BusinessName }}Dao: + """ + {{ functionName }}模块数据库操作层 + """ + + @classmethod + async def get_{{ businessName }}_detail_by_id(cls, db: AsyncSession, {{ pk_field }}: int) -> {{ ClassName }} | None: + """ + 根据{{ pk_field_comment }}获取{{ functionName }}详细信息 + + :param db: orm对象 + :param {{ pk_field }}: {{ pk_field_comment }} + :return: {{ functionName }}信息对象 + """ + {{ businessName }}_info = ( + ( + await db.execute( + {% if table.sub %} + select({{ ClassName }}) + .options(selectinload({{ ClassName }}.{{ subclassName }}_list)) + {% else %} + select({{ ClassName }}) + {% endif %} + .where( + {{ ClassName }}.{{ pk_field }} == {{ pk_field }} + ) + ) + ) + .scalars() + .first() + ) + + return {{ businessName }}_info + + @classmethod + async def get_{{ businessName }}_detail_by_info(cls, db: AsyncSession, {{ businessName }}: {{ BusinessName }}Model) -> {{ ClassName }} | None: + """ + 根据{{ functionName }}参数获取{{ functionName }}信息 + + :param db: orm对象 + :param {{ businessName }}: {{ functionName }}参数对象 + :return: {{ functionName }}信息对象 + """ + {{ businessName }}_info = ( + ( + await db.execute( + select({{ ClassName }}).where( + {% for column in columns %} + {% if column.unique %} + {{ ClassName }}.{{ column.python_field | camel_to_snake }} == {{ businessName }}.{{ column.python_field | camel_to_snake }} if {{ businessName }}.{{ column.python_field | camel_to_snake }} else True, + {% endif %} + {% endfor %} + ) + ) + ) + .scalars() + .first() + ) + + return {{ businessName }}_info + + @classmethod + async def get_{{ businessName }}_list( + cls, db: AsyncSession, query_object: {{ BusinessName }}PageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取{{ functionName }}列表信息 + + :param db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: {{ functionName }}列表信息对象 + """ + query = ( + {% if table.sub %} + select({{ ClassName }}) + .options(selectinload({{ ClassName }}.{{ subclassName }}_list)) + {% else %} + select({{ ClassName }}) + {% endif %} + .where( + {% for column in columns %} + {% set field = column.python_field | camel_to_snake %} + {% if column.query %} + {% if column.query_type == "EQ" %} + {{ ClassName }}.{{ field }} == query_object.{{ field }} if query_object.{{ field }} else True, + {% elif column.query_type == "NE" %} + {{ ClassName }}.{{ field }} != query_object.{{ field }} if query_object.{{ field }} else True, + {% elif column.query_type == "GT" %} + {{ ClassName }}.{{ field }} > query_object.{{ field }} if query_object.{{ field }} else True, + {% elif column.query_type == "GTE" %} + {{ ClassName }}.{{ field }} >= query_object.{{ field }} if query_object.{{ field }} else True, + {% elif column.query_type == "LT" %} + {{ ClassName }}.{{ field }} < query_object.{{ field }} if query_object.{{ field }} else True, + {% elif column.query_type == "LTE" %} + {{ ClassName }}.{{ field }} <= query_object.{{ field }} if query_object.{{ field }} else True, + {% elif column.query_type == "LIKE" %} + {{ ClassName }}.{{ field }}.like(f'%{% raw %}{{% endraw %}query_object.{{ field }}{% raw %}}{% endraw %}%') if query_object.{{ field }} else True, + {% elif column.html_type == "datetime" and column.query_type == "BETWEEN" %} + {{ ClassName }}.{{ field }}.between( + datetime.combine(datetime.strptime(query_object.begin_{{ column.column_name }}, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_{{ column.column_name }}, '%Y-%m-%d'), time(23, 59, 59)), + ) + if query_object.begin_{{ column.column_name }} and query_object.end_{{ column.column_name }} + else True, + {% endif %} + {% endif %} + {% endfor %} + ) + .order_by({{ ClassName }}.{{ pk_field }}) + .distinct() + ) + {{ businessName }}_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, query, query_object.page_num, query_object.page_size, is_page + ) + + return {{ businessName }}_list + + @classmethod + async def add_{{ businessName }}_dao(cls, db: AsyncSession, {{ businessName }}: {{ BusinessName }}Model) -> {{ ClassName }}: + """ + 新增{{ functionName }}数据库操作 + + :param db: orm对象 + :param {{ businessName }}: {{ functionName }}对象 + :return: + """ + db_{{ businessName }} = {{ ClassName }}(**{{ businessName }}.model_dump(exclude={% raw %}{{% endraw %}{% if table.sub %}'{{ subclassName }}_list', {% endif %}{% for column in columns %}{% if not column.insert and column.column_name not in column_not_add_show + column_not_edit_show %}'{{ column.python_field | camel_to_snake }}'{% if not loop.last %}, {% endif %}{% endif %}{% endfor %}{% raw %}}{% endraw %})) + db.add(db_{{ businessName }}) + await db.flush() + + return db_{{ businessName }} + + @classmethod + async def edit_{{ businessName }}_dao(cls, db: AsyncSession, {{ businessName }}: dict) -> None: + """ + 编辑{{ functionName }}数据库操作 + + :param db: orm对象 + :param {{ businessName }}: 需要更新的{{ functionName }}字典 + :return: + """ + await db.execute(update({{ ClassName }}), [{{ businessName }}]) + + @classmethod + async def delete_{{ businessName }}_dao(cls, db: AsyncSession, {{ businessName }}: {{ BusinessName }}Model) -> None: + """ + 删除{{ functionName }}数据库操作 + + :param db: orm对象 + :param {{ businessName }}: {{ functionName }}对象 + :return: + """ + await db.execute(delete({{ ClassName }}).where({{ ClassName }}.{{ pk_field }}.in_([{{ businessName }}.{{ pk_field }}]))) + + {% if table.sub %} + @classmethod + async def add_{{ subTable.business_name }}_dao(cls, db: AsyncSession, {{ subTable.business_name }}: {{ subTable.business_name | capitalize }}Model) -> {{ subClassName }}: + """ + 新增{{ subTable.function_name }}数据库操作 + + :param db: orm对象 + :param {{ subTable.business_name }}: {{ subTable.function_name }}对象 + :return: + """ + db_{{ subTable.business_name }} = {{ subClassName }}(**{{ subTable.business_name }}.model_dump()) + db.add(db_{{ subTable.business_name }}) + await db.flush() + + return db_{{ subTable.business_name }} + + @classmethod + async def edit_{{ subTable.business_name }}_dao(cls, db: AsyncSession, {{ subTable.business_name }}: dict) -> None: + """ + 编辑{{ subTable.function_name }}数据库操作 + + :param db: orm对象 + :param {{ subTable.business_name }}: 需要更新的{{ subTable.function_name }}字典 + :return: + """ + await db.execute(update({{ subClassName }}), [{{ subTable.business_name }}]) + + @classmethod + async def delete_{{ subTable.business_name }}_dao(cls, db: AsyncSession, {{ subTable.business_name }}: {{ subTable.business_name | capitalize }}Model) -> None: + """ + 删除{{ subTable.function_name }}数据库操作 + + :param db: orm对象 + :param {{ subTable.business_name }}: {{ subTable.function_name }}对象 + :return: + """ + await db.execute(delete({{ subClassName }}).where({{ subClassName }}.{{ subTable.pk_column.python_field | camel_to_snake }}.in_([{{ subTable.business_name }}.{{ subTable.pk_column.python_field | camel_to_snake }}]))) + {% endif %} diff --git a/shierfeng-fastapi-backend/module_generator/templates/python/do.py.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/python/do.py.jinja2 new file mode 100644 index 0000000..76137e3 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/python/do.py.jinja2 @@ -0,0 +1,43 @@ +{% for do_import in doImportList %} +{{ do_import }} +{% endfor %} +{% if table.sub %} +from sqlalchemy.orm import relationship +{% endif %} + +from config.database import Base + + +class {{ ClassName }}(Base): + """ + {{ functionName }}表 + """ + + __tablename__ = '{{ tableName }}' + __table_args__ = {'comment': '{{ tableComment }}'} + + {% for column in columns %} + {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type }}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {% endfor %} + + {% if table.sub %} + {{ subclassName }}_list = relationship('{{ subClassName }}', back_populates='{{ businessName }}') + {% endif %} + + +{% if table.sub %} +class {{ subClassName }}(Base): + """ + {{ subTable.function_name }}表 + """ + + __tablename__ = '{{ subTableName }}' + + {% for column in subTable.columns %} + {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type }}, {% if column.column_name == subTableFkName %}ForeignKey('{{ tableName }}.{{ subTableFkName }}'), {% endif %}{% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required %}nullable=True{% else %}nullable=False{% endif %}, comment='{{ column.column_comment }}') + {% endfor %} + + {% if table.sub %} + {{ businessName }} = relationship('{{ ClassName }}', back_populates='{{ subclassName }}_list') + {% endif %} +{% endif %} diff --git a/shierfeng-fastapi-backend/module_generator/templates/python/service.py.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/python/service.py.jinja2 new file mode 100644 index 0000000..8349699 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/python/service.py.jinja2 @@ -0,0 +1,210 @@ +{% set pkField = pkColumn.python_field %} +{% set pk_field = pkColumn.python_field | camel_to_snake %} +{% set pkParentheseIndex = pkColumn.column_comment.find("(") %} +{% set pk_field_comment = pkColumn.column_comment[:pkParentheseIndex] if pkParentheseIndex != -1 else pkColumn.column_comment %} +from typing import Any + +{% if dicts %} +from fastapi import Request +{% endif %} +from sqlalchemy.ext.asyncio import AsyncSession + +from common.constant import CommonConstant +from common.vo import CrudResponseModel, PageModel +from exceptions.exception import ServiceException +from {{ packageName }}.dao.{{ businessName }}_dao import {{ BusinessName }}Dao +from {{ packageName }}.entity.vo.{{ businessName }}_vo import Delete{{ BusinessName }}Model, {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel +{% if dicts %} +from module_admin.service.dict_service import DictDataService +{% endif %} +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil + + +class {{ BusinessName }}Service: + """ + {{ functionName }}模块服务层 + """ + + @classmethod + async def get_{{ businessName }}_list_services( + cls, query_db: AsyncSession, query_object: {{ BusinessName }}PageQueryModel, is_page: bool = False + ) -> PageModel | list[dict[str, Any]]: + """ + 获取{{ functionName }}列表信息service + + :param query_db: orm对象 + :param query_object: 查询参数对象 + :param is_page: 是否开启分页 + :return: {{ functionName }}列表信息对象 + """ + {{ businessName }}_list_result = await {{ BusinessName }}Dao.get_{{ businessName }}_list(query_db, query_object, is_page) + + return {{ businessName }}_list_result + + {% for column in columns %} + {% set parentheseIndex = column.column_comment.find("(") %} + {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} + {% if column.unique %} + @classmethod + async def check_{{ column.python_field | camel_to_snake }}_unique_services(cls, query_db: AsyncSession, page_object: {{ BusinessName }}Model) -> bool: + """ + 检查{{ comment }}是否唯一service + + :param query_db: orm对象 + :param page_object: {{ functionName }}对象 + :return: 校验结果 + """ + {{ pk_field }} = -1 if page_object.{{ pk_field }} is None else page_object.{{ pk_field }} + {{ businessName }} = await {{ BusinessName }}Dao.get_{{ businessName }}_detail_by_info(query_db, {{ BusinessName }}Model({{ column.python_field }}=page_object.{{ column.python_field | camel_to_snake }})) + if {{ businessName }} and {{ businessName }}.{{ pk_field }} != {{ pk_field }}: + return CommonConstant.NOT_UNIQUE + return CommonConstant.UNIQUE + {% if not loop.last %}{{ "\n" }}{% endif %} + {% endif %} + {% endfor %} + + @classmethod + async def add_{{ businessName }}_services(cls, query_db: AsyncSession, page_object: {{ BusinessName }}Model) -> CrudResponseModel: + """ + 新增{{ functionName }}信息service + + :param query_db: orm对象 + :param page_object: 新增{{ functionName }}对象 + :return: 新增{{ functionName }}校验结果 + """ + {% for column in columns %} + {% set parentheseIndex = column.column_comment.find("(") %} + {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} + {% if column.unique %} + if not await cls.check_{{ column.python_field | camel_to_snake }}_unique_services(query_db, page_object): + raise ServiceException(message=f'新增{{ functionName }}{page_object.{{ column.python_field | camel_to_snake }}}失败,{{ comment }}已存在') + {% endif %} + {% endfor %} + try: + {% if table.sub %} + add_{{ businessName }} = await {{ BusinessName }}Dao.add_{{ businessName }}_dao(query_db, page_object) + if add_{{ businessName }}: + for sub_table in page_object.{{ subclassName }}_list: + await {{ BusinessName }}Dao.add_{{ subTable.business_name }}_dao(query_db, sub_table) + {% else %} + await {{ BusinessName }}Dao.add_{{ businessName }}_dao(query_db, page_object) + {% endif %} + await query_db.commit() + return CrudResponseModel(is_success=True, message='新增成功') + except Exception as e: + await query_db.rollback() + raise e + + @classmethod + async def edit_{{ businessName }}_services(cls, query_db: AsyncSession, page_object: {{ BusinessName }}Model) -> CrudResponseModel: + """ + 编辑{{ functionName }}信息service + + :param query_db: orm对象 + :param page_object: 编辑{{ functionName }}对象 + :return: 编辑{{ functionName }}校验结果 + """ + edit_{{ businessName }} = page_object.model_dump(exclude_unset=True, exclude={% raw %}{{% endraw %}{% if table.sub %}'{{ subclassName }}_list', {% endif %}{% for column in columns %}{% if not column.edit and not column.pk and column.column_name not in column_not_edit_show %}'{{ column.python_field | camel_to_snake }}'{% if not loop.last %}, {% endif %}{% endif %}{% endfor %}{% raw %}}{% endraw %}) + {{ businessName }}_info = await cls.{{ businessName }}_detail_services(query_db, page_object.{{ pk_field }}) + if {{ businessName }}_info.{{ pk_field }}: + {% for column in columns %} + {% set parentheseIndex = column.column_comment.find("(") %} + {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} + {% if column.unique %} + if not await cls.check_{{ column.python_field | camel_to_snake }}_unique_services(query_db, page_object): + raise ServiceException(message=f'修改{{ functionName }}{page_object.{{ column.python_field | camel_to_snake }}}失败,{{ comment }}已存在') + {% endif %} + {% endfor %} + try: + await {{ BusinessName }}Dao.edit_{{ businessName }}_dao(query_db, edit_{{ businessName }}) + {% if table.sub %} + for sub_table in {{ businessName }}_info.{{ subclassName }}_list: + await {{ BusinessName }}Dao.delete_{{ subTable.business_name }}_dao(query_db, sub_table) + for sub_table in page_object.{{ subclassName }}_list: + await {{ BusinessName }}Dao.add_{{ subTable.business_name }}_dao(query_db, sub_table) + {% endif %} + await query_db.commit() + return CrudResponseModel(is_success=True, message='更新成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='{{ functionName }}不存在') + + @classmethod + async def delete_{{ businessName }}_services(cls, query_db: AsyncSession, page_object: Delete{{ BusinessName }}Model) -> CrudResponseModel: + """ + 删除{{ functionName }}信息service + + :param query_db: orm对象 + :param page_object: 删除{{ functionName }}对象 + :return: 删除{{ functionName }}校验结果 + """ + if page_object.{{ pk_field }}s: + {{ pk_field }}_list = page_object.{{ pk_field }}s.split(',') + try: + for {{ pk_field }} in {{ pk_field }}_list: + {% if table.sub %} + {{ businessName }} = await cls.{{ businessName }}_detail_services(query_db, int({{ pk_field }})) + for sub_table in {{ businessName }}.{{ subclassName }}_list: + await {{ BusinessName }}Dao.delete_{{ subTable.business_name }}_dao(query_db, sub_table) + {% endif %} + await {{ BusinessName }}Dao.delete_{{ businessName }}_dao(query_db, {{ BusinessName }}Model({{ pkField }}={{ pk_field }})) + await query_db.commit() + return CrudResponseModel(is_success=True, message='删除成功') + except Exception as e: + await query_db.rollback() + raise e + else: + raise ServiceException(message='传入{{ pk_field_comment }}为空') + + @classmethod + async def {{ businessName }}_detail_services(cls, query_db: AsyncSession, {{ pk_field }}: int) -> {{ BusinessName }}Model: + """ + 获取{{ functionName }}详细信息service + + :param query_db: orm对象 + :param {{ pk_field }}: {{ pk_field_comment }} + :return: {{ pk_field_comment }}对应的信息 + """ + {{ businessName }} = await {{ BusinessName }}Dao.get_{{ businessName }}_detail_by_id(query_db, {{ pk_field }}={{ pk_field }}) + result = {{ BusinessName }}Model(**CamelCaseUtil.transform_result({{ businessName }})) if {{ businessName }} else {{ BusinessName }}Model() + + return result + + @staticmethod + async def export_{{ businessName }}_list_services({% if dicts %}request: Request, {% endif %}{{ businessName }}_list: list) -> bytes: + """ + 导出{{ functionName }}信息service + + :param {{ businessName }}_list: {{ functionName }}信息列表 + :return: {{ functionName }}信息对应excel的二进制数据 + """ + # 创建一个映射字典,将英文键映射到中文键 + mapping_dict = { + {% for column in columns %} + {% set parentheseIndex = column.column_comment.find("(") %} + {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} + '{{ column.python_field }}': '{{ comment }}', + {% endfor %} + } + {% if dicts %} + {% for dict_type in dicts.split(", ") %} + {{ dict_type[1:-1] }}_list = await DictDataService.query_dict_data_list_from_cache_services( + request.app.state.redis, dict_type={{ dict_type }} + ) + {{ dict_type[1:-1] }}_option = [{'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in {{ dict_type[1:-1] }}_list] + {{ dict_type[1:-1] }}_option_dict = {item.get('value'): item for item in {{ dict_type[1:-1] }}_option} + {% endfor %} + for item in {{ businessName }}_list: + {% for column in columns %} + {% if column.dict_type %} + if str(item.get('{{ column.python_field }}')) in {{ column.dict_type }}_option_dict: + item['{{ column.python_field }}'] = {{ column.dict_type }}_option_dict.get(str(item.get('{{ column.python_field }}'))).get('label') + {% endif %} + {% endfor %} + {% endif %} + binary_data = ExcelUtil.export_list2excel({{ businessName }}_list, mapping_dict) + + return binary_data diff --git a/shierfeng-fastapi-backend/module_generator/templates/python/vo.py.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/python/vo.py.jinja2 new file mode 100644 index 0000000..a21fb84 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/python/vo.py.jinja2 @@ -0,0 +1,172 @@ +{% set pkField = pkColumn.python_field %} +{% set pk_field = pkColumn.python_field | camel_to_snake %} +{% set pkParentheseIndex = pkColumn.column_comment.find("(") %} +{% set pk_field_comment = pkColumn.column_comment[:pkParentheseIndex] if pkParentheseIndex != -1 else pkColumn.column_comment %} +{% set vo_field_required = namespace(has_required=False) %} +{% set vo_field_daterange = namespace(has_daterange=False) %} +{% for column in columns %} +{% if column.required %} + {% set vo_field_required.has_required = True %} +{% endif %} +{% if column.html_type == "datetime" and column.query_type == "BETWEEN" %} + {% set vo_field_daterange.has_daterange = True %} +{% endif %} +{% endfor %} +{% set sub_vo_field_required = namespace(has_required=False) %} +{% if table.sub %} +{% for sub_column in subTable.columns %} +{% if sub_column.required %} + {% set sub_vo_field_required.has_required = True %} +{% endif %} +{% endfor %} +{% endif %} +{% for vo_import in voImportList %} +{{ vo_import }} +{% endfor %} + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +{% if vo_field_required.has_required or sub_vo_field_required.has_required %} +from pydantic_validation_decorator import NotBlank +{% endif %} + + +{% if table.sub %} +class {{ BusinessName }}BaseModel(BaseModel): + """ + {{ functionName }}表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + {% for column in columns %} + {{ column.column_name }}: {{ column.python_type }} | None = Field(default=None, description='{{ column.column_comment }}') + {% endfor %} + + {% for column in columns %} + {% if column.required %} + {% set parentheseIndex = column.column_comment.find("(") %} + {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} + @NotBlank(field_name='{{ column.column_name }}', message='{{ comment }}不能为空') + def get_{{ column.column_name }}(self) -> {{ column.python_type }} | None: + return self.{{ column.column_name }} + {% if not loop.last %}{{ "\n" }}{% endif %} + {% endif %} + {% endfor %} + + {% if vo_field_required.has_required %} + def validate_fields(self) -> None: + {% for column in columns %} + {% if column.required %} + self.get_{{ column.column_name }}() + {% endif %} + {% endfor %} + {% endif %} +{% endif %} + + +class {{ BusinessName }}Model({% if table.sub %}{{ BusinessName }}BaseModel{% else %}BaseModel{% endif %}): + """ + {{ functionName }}表对应pydantic模型 + """ + {% if not table.sub %} + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + {% for column in columns %} + {{ column.column_name }}: {{ column.python_type }} | None = Field(default=None, description='{{ column.column_comment }}') + {% endfor %} + {% endif %} + {% if table.sub %} + {{ subclassName }}_list: list['{{ subTable.business_name | capitalize }}Model'] | None = Field(default=None, description='子表列信息') + {% endif %} + + {% if not table.sub %} + {% for column in columns %} + {% if column.required %} + {% set parentheseIndex = column.column_comment.find("(") %} + {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} + @NotBlank(field_name='{{ column.column_name }}', message='{{ comment }}不能为空') + def get_{{ column.column_name }}(self) -> {{ column.python_type }} | None: + return self.{{ column.column_name }} + {% if not loop.last %}{{ "\n" }}{% endif %} + {% endif %} + {% endfor %} + + {% if vo_field_required.has_required %} + def validate_fields(self) -> None: + {% for column in columns %} + {% if column.required %} + self.get_{{ column.column_name }}() + {% endif %} + {% endfor %} + {% endif %} + {% endif %} + + +{% if table.sub %} +class {{ subTable.business_name | capitalize }}Model(BaseModel): + """ + {{ subTable.function_name }}表对应pydantic模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + {% for sub_column in subTable.columns %} + {{ sub_column.column_name }}: {{ sub_column.python_type }} | None = Field(default=None, description='{{ sub_column.column_comment}}') + {% endfor %} + + {% for sub_column in subTable.columns %} + {% if sub_column.required %} + {% set parentheseIndex = sub_column.column_comment.find("(") %} + {% set comment = sub_column.column_comment[:parentheseIndex] if parentheseIndex != -1 else sub_column.column_comment %} + @NotBlank(field_name='{{ sub_column.column_name }}', message='{{ comment }}不能为空') + def get_{{ sub_column.column_name }}(self) -> {{ sub_column.python_type }} | None: + return self.{{ sub_column.column_name }} + {% if not loop.last %}{{ "\n" }}{% endif %} + {% endif %} + {% endfor %} + + {% if sub_vo_field_required.has_required %} + def validate_fields(self) -> None: + {% for sub_column in subTable.columns %} + {% if sub_column.required %} + self.get_{{ sub_column.column_name }}() + {% endif %} + {% endfor %} + {% endif %} +{% endif %} + + +class {{ BusinessName }}QueryModel({% if table.sub %}{{ BusinessName }}BaseModel{% else %}{{ BusinessName }}Model{% endif %}): + """ + {{ functionName }}不分页查询模型 + """ + {% if vo_field_daterange.has_daterange %} + {% for column in columns %} + {% if column.html_type == "datetime" and column.query_type == "BETWEEN" %} + begin_{{ column.column_name }}: str | None = Field(default=None, description='开始{{ column.column_comment }}') + end_{{ column.column_name }}: str | None = Field(default=None, description='结束{{ column.column_comment }}') + {% endif %} + {% endfor %} + {% else %} + pass + {% endif %} + + +class {{ BusinessName }}PageQueryModel({{ BusinessName }}QueryModel): + """ + {{ functionName }}分页查询模型 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class Delete{{ BusinessName }}Model(BaseModel): + """ + 删除{{ functionName }}模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + {{ pk_field }}s: str = Field(description='需要删除的{{ pk_field_comment }}') diff --git a/shierfeng-fastapi-backend/module_generator/templates/sql/sql.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/sql/sql.jinja2 new file mode 100644 index 0000000..654216c --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/sql/sql.jinja2 @@ -0,0 +1,50 @@ +{% if dbType == 'postgresql' %} +DO $$ +DECLARE + parentId bigint; +BEGIN + -- 菜单 SQL + insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + values('{{ functionName }}', '{{ parentMenuId }}', '1', '{{ businessName }}', '{{ moduleName }}/{{ businessName }}/index', 1, 0, 'C', '0', '0', '{{ permissionPrefix }}:list', '#', 'admin', current_timestamp, '', null, '{{ functionName }}菜单') + returning menu_id into parentId; + + -- 按钮 SQL + insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + values('{{ functionName }}查询', parentId, '1', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:query', '#', 'admin', current_timestamp, '', null, ''); + + insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + values('{{ functionName }}新增', parentId, '2', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:add', '#', 'admin', current_timestamp, '', null, ''); + + insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + values('{{ functionName }}修改', parentId, '3', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:edit', '#', 'admin', current_timestamp, '', null, ''); + + insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + values('{{ functionName }}删除', parentId, '4', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:remove', '#', 'admin', current_timestamp, '', null, ''); + + insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) + values('{{ functionName }}导出', parentId, '5', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:export', '#', 'admin', current_timestamp, '', null, ''); +END $$; +{% else %} +-- 菜单 SQL +insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +values('{{ functionName }}', '{{ parentMenuId }}', '1', '{{ businessName }}', '{{ moduleName }}/{{ businessName }}/index', 1, 0, 'C', '0', '0', '{{ permissionPrefix }}:list', '#', 'admin', sysdate(), '', null, '{{ functionName }}菜单'); + +-- 按钮父菜单ID +SELECT @parentId := LAST_INSERT_ID(); + +-- 按钮 SQL +insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +values('{{ functionName }}查询', @parentId, '1', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:query', '#', 'admin', sysdate(), '', null, ''); + +insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +values('{{ functionName }}新增', @parentId, '2', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:add', '#', 'admin', sysdate(), '', null, ''); + +insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +values('{{ functionName }}修改', @parentId, '3', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:edit', '#', 'admin', sysdate(), '', null, ''); + +insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +values('{{ functionName }}删除', @parentId, '4', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:remove', '#', 'admin', sysdate(), '', null, ''); + +insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +values('{{ functionName }}导出', @parentId, '5', '#', '', 1, 0, 'F', '0', '0', '{{ permissionPrefix }}:export', '#', 'admin', sysdate(), '', null, ''); +{% endif %} \ No newline at end of file diff --git a/shierfeng-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 new file mode 100644 index 0000000..eb8ceaf --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 @@ -0,0 +1,496 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-backend/module_generator/templates/vue/index.vue.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/vue/index.vue.jinja2 new file mode 100644 index 0000000..39f861d --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/vue/index.vue.jinja2 @@ -0,0 +1,591 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-backend/module_generator/templates/vue/v3/index-tree.vue.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/vue/v3/index-tree.vue.jinja2 new file mode 100644 index 0000000..79fc220 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/vue/v3/index-tree.vue.jinja2 @@ -0,0 +1,463 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 b/shierfeng-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 new file mode 100644 index 0000000..8e2c886 --- /dev/null +++ b/shierfeng-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 @@ -0,0 +1,580 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-backend/module_task/__init__.py b/shierfeng-fastapi-backend/module_task/__init__.py new file mode 100644 index 0000000..1f4b412 --- /dev/null +++ b/shierfeng-fastapi-backend/module_task/__init__.py @@ -0,0 +1 @@ +from . import scheduler_test # noqa: F401 diff --git a/shierfeng-fastapi-backend/module_task/scheduler_test.py b/shierfeng-fastapi-backend/module_task/scheduler_test.py new file mode 100644 index 0000000..7862c6d --- /dev/null +++ b/shierfeng-fastapi-backend/module_task/scheduler_test.py @@ -0,0 +1,19 @@ +from datetime import datetime + + +def job(*args, **kwargs) -> None: + """ + 定时任务执行同步函数示例 + """ + print(args) + print(kwargs) + print(f'{datetime.now()}同步函数执行了') + + +async def async_job(*args, **kwargs) -> None: + """ + 定时任务执行异步函数示例 + """ + print(args) + print(kwargs) + print(f'{datetime.now()}异步函数执行了') diff --git a/shierfeng-fastapi-backend/pyproject.toml b/shierfeng-fastapi-backend/pyproject.toml new file mode 100644 index 0000000..04016e3 --- /dev/null +++ b/shierfeng-fastapi-backend/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "shierfeng-fastapi-backend-cli" +version = "1.9.0" +description = "ShiErFeng FastAPI backend internal CLI entrypoint" +requires-python = ">=3.10" + +[project.scripts] +shierfeng = "cli.main:main" + +[tool.setuptools.packages.find] +include = ["cli*"] diff --git a/shierfeng-fastapi-backend/requirements-pg.txt b/shierfeng-fastapi-backend/requirements-pg.txt new file mode 100644 index 0000000..43d7532 --- /dev/null +++ b/shierfeng-fastapi-backend/requirements-pg.txt @@ -0,0 +1,35 @@ +agno==2.4.8 +aiofiles==25.1.0 +alembic==1.18.3 +anthropic==0.78.0 +APScheduler==3.11.2 +async-lru==2.1.0 +asyncpg==0.31.0 +bcrypt==5.0.0 +cerebras-cloud-sdk==1.67.0 +cohere==5.20.4 +fastapi[all]==0.128.2 +google-genai==1.62.0 +groq==1.0.0 +litellm==1.81.8 +llama-api-client==0.6.0 +loguru==0.7.3 +mistralai==1.12.0 +ollama==0.6.1 +openai==2.17.0 +openpyxl==3.1.5 +pandas==2.3.3 +Pillow==12.1.1 +portkey-ai==2.1.0 +psutil==7.2.2 +psycopg2==2.9.11 +pydantic>=2.11.4 +pydantic-validation-decorator==0.1.5 +PyJWT[crypto]==2.11.0 +redis==7.1.0 +ruff==0.15.0 +SQLAlchemy[asyncio]==2.0.46 +sqlglot[rs]==28.10.0 +textual==8.2.5 +user-agents==2.2.0 +. diff --git a/shierfeng-fastapi-backend/requirements.txt b/shierfeng-fastapi-backend/requirements.txt new file mode 100644 index 0000000..5c17e51 --- /dev/null +++ b/shierfeng-fastapi-backend/requirements.txt @@ -0,0 +1,35 @@ +agno==2.4.8 +aiofiles==25.1.0 +alembic==1.18.3 +anthropic==0.78.0 +APScheduler==3.11.2 +async-lru==2.1.0 +asyncmy==0.2.11 +bcrypt==5.0.0 +cerebras-cloud-sdk==1.67.0 +cohere==5.20.4 +fastapi[all]==0.128.2 +google-genai==1.62.0 +groq==1.0.0 +litellm==1.81.8 +llama-api-client==0.6.0 +loguru==0.7.3 +mistralai==1.12.0 +ollama==0.6.1 +openai==2.17.0 +openpyxl==3.1.5 +pandas==2.3.3 +Pillow==12.1.1 +portkey-ai==2.1.0 +psutil==7.2.2 +pydantic>=2.11.4 +pydantic-validation-decorator==0.1.5 +PyJWT[crypto]==2.11.0 +PyMySQL==1.1.2 +redis==7.1.0 +ruff==0.15.0 +SQLAlchemy[asyncio]==2.0.46 +sqlglot[rs]==28.10.0 +textual==8.2.5 +user-agents==2.2.0 +. diff --git a/shierfeng-fastapi-backend/ruff.toml b/shierfeng-fastapi-backend/ruff.toml new file mode 100644 index 0000000..23d788c --- /dev/null +++ b/shierfeng-fastapi-backend/ruff.toml @@ -0,0 +1,68 @@ +line-length = 120 +show-fixes = true +target-version = "py310" +unsafe-fixes = true + +[lint] +select = [ + "FAST", # fastapi + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "PIE", # flake8-pie + "RSE", # flake8-raise + "RET", # flake8-return + "SIM", # flake8-simplify + "TC", # flake8-type-checking + "FLY", # flynt + "I", # isort + "N", # pep8-naming + "PERF", # perflint + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "PGH", # pygrep-hooks + "PL", # pylint + "UP", # pyupgrade + "RUF", # Ruff-specific rules +] +ignore = [ + "ANN002", # missing type annotation for *{name} + "ANN003", # missing type annotation for **{name} + "ANN401", # dynamically typed expressions (typing.Any) are disallowed in {name} + "B008", # do not perform function calls in argument defaults + "COM812", # trailing comma missing + "RET504", # unnecessary assignment to {name} before return statement + "SIM105", # use contextlib.suppress({exception}) instead of try-except-pass + "C901", # too complex + "N818", # exception name {name} should be named with an Error suffix + "E501", # line too long + "W191", # indentation contains tabs + "RUF001", # string contains ambiguous + "RUF002", # docstring contains ambiguous + "RUF003", # comment contains ambiguous + "RUF012", # mutable class attributes should be annotated with typing.ClassVar +] + +[lint.flake8-type-checking] +runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"] + +[lint.isort] +known-third-party = ["alembic"] + +[lint.pylint] +max-args = 10 +max-returns = 10 + +[lint.pyupgrade] +# Preserve types, even if a file imports `from __future__ import annotations`. +keep-runtime-typing = true + +[format] +docstring-code-format = true +quote-style = "single" \ No newline at end of file diff --git a/shierfeng-fastapi-backend/server.py b/shierfeng-fastapi-backend/server.py new file mode 100644 index 0000000..c635503 --- /dev/null +++ b/shierfeng-fastapi-backend/server.py @@ -0,0 +1,168 @@ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from common.constant import LockConstant +from common.router import auto_register_routers +from config.env import AppConfig +from config.get_db import close_async_engine, init_create_table +from config.get_redis import RedisUtil +from config.get_scheduler import SchedulerUtil +from exceptions.handle import handle_exception +from middlewares.handle import handle_middleware +from module_admin.service.log_service import LogAggregatorService +from sub_applications.handle import handle_sub_applications +from utils.common_util import worship +from utils.log_util import logger +from utils.server_util import APIDocsUtil, IPUtil, StartupUtil +from utils.transport_crypto_util import TransportKeyProvider + + +async def _start_background_tasks(app: FastAPI) -> None: + """ + 启动应用后台任务 + + :param app: FastAPI对象 + :return: None + """ + await SchedulerUtil.init_system_scheduler(app.state.redis) + app.state.log_aggregator_task = asyncio.create_task(LogAggregatorService.consume_stream(app.state.redis)) + + +async def _stop_background_tasks(app: FastAPI) -> None: + """ + 停止应用后台任务并释放资源 + + :param app: FastAPI对象 + :return: None + """ + log_task = getattr(app.state, 'log_aggregator_task', None) + if log_task: + log_task.cancel() + try: + await log_task + except asyncio.CancelledError: + pass + lock_task = getattr(app.state, 'lock_renewal_task', None) + if lock_task: + lock_task.cancel() + try: + await lock_task + except asyncio.CancelledError: + pass + await RedisUtil.close_redis_pool(app) + await SchedulerUtil.close_system_scheduler() + await close_async_engine() + + +# 生命周期事件 +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """ + 应用生命周期管理 + + :param app: FastAPI对象 + :return: None + """ + app.state.redis = await RedisUtil.create_redis_pool(log_enabled=False) + startup_log_enabled = await StartupUtil.acquire_startup_log_gate( + redis=app.state.redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + worker_id=SchedulerUtil._worker_id, + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + ) + app.state.startup_log_enabled = startup_log_enabled + + # 获取锁成功后立即启动锁续期任务,避免初始化时间过长导致锁过期 + if startup_log_enabled: + app.state.lock_renewal_task = StartupUtil.start_lock_renewal( + redis=app.state.redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + worker_id=SchedulerUtil._worker_id, + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + interval_seconds=LockConstant.LOCK_RENEWAL_INTERVAL, + on_lock_lost=SchedulerUtil.on_lock_lost, + ) + + with logger.contextualize(startup_phase=True, startup_log_enabled=startup_log_enabled): + logger.info(f'⏰️ {AppConfig.app_name}开始启动') + if startup_log_enabled: + worship() + TransportKeyProvider.validate_runtime_configuration() + await init_create_table() + await RedisUtil.check_redis_connection(app.state.redis, log_enabled=startup_log_enabled) + await RedisUtil.init_sys_dict(app.state.redis) + await RedisUtil.init_sys_config(app.state.redis) + await _start_background_tasks(app) + + if startup_log_enabled: + # 短暂等待确保下面的启动日志在最后打印 + await asyncio.sleep(0.5) + logger.info(f'🚀 {AppConfig.app_name}启动成功') + host = AppConfig.app_host + port = AppConfig.app_port + if host == '0.0.0.0': + local_ip = IPUtil.get_local_ip() + network_ips = IPUtil.get_network_ips() + else: + local_ip = host + network_ips = [host] + + app_links = [f'🏠 Local: http://{local_ip}:{port}'] + app_links.extend(f'📡 Network: http://{ip}:{port}' for ip in network_ips) + logger.opt(colors=True).info('💻 应用地址:\n' + '\n'.join(app_links)) + + if not AppConfig.app_disable_swagger: + swagger_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.docs_url()}'] + swagger_links.extend( + f'📡 Network: http://{ip}:{port}{APIDocsUtil.docs_url()}' for ip in network_ips + ) + logger.opt(colors=True).info('📄 Swagger文档:\n' + '\n'.join(swagger_links)) + + if not AppConfig.app_disable_redoc: + redoc_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.redoc_url()}'] + redoc_links.extend( + f'📡 Network: http://{ip}:{port}{APIDocsUtil.redoc_url()}' for ip in network_ips + ) + logger.opt(colors=True).info('📚 ReDoc文档:\n' + '\n'.join(redoc_links)) + yield + shutdown_log_enabled = getattr(app.state, 'startup_log_enabled', False) + with logger.contextualize(startup_phase=True, startup_log_enabled=shutdown_log_enabled): + await _stop_background_tasks(app) + + +def create_app() -> FastAPI: + """ + 创建FastAPI应用 + + :return: FastAPI对象 + """ + # 配置API文档静态资源 + APIDocsUtil.setup_docs_static_resources() + # 初始化FastAPI对象 + app = FastAPI( + title=AppConfig.app_name, + description=f'{AppConfig.app_name}接口文档', + version=AppConfig.app_version, + lifespan=lifespan, + openapi_url=APIDocsUtil.proxy_openapi_url(), + docs_url=APIDocsUtil.proxy_docs_url(), + redoc_url=APIDocsUtil.proxy_redoc_url(), + swagger_ui_oauth2_redirect_url=APIDocsUtil.proxy_oauth2_redirect_url(), + ) + + # 自定义API文档路由,修复无法直接通过后端地址访问文档的问题 + APIDocsUtil.custom_api_docs_router(app) + + # 挂载子应用 + handle_sub_applications(app) + # 加载中间件处理方法 + handle_middleware(app) + # 加载全局异常处理方法 + handle_exception(app) + # 自动注册路由 + auto_register_routers(app) + + return app diff --git a/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql b/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql new file mode 100644 index 0000000..7c59a84 --- /dev/null +++ b/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql @@ -0,0 +1,1180 @@ +-- ---------------------------- +-- 1、部门表 +-- ---------------------------- +drop table if exists sys_dept; +create table sys_dept ( + dept_id bigserial, + parent_id bigint default 0, + ancestors varchar(50) default '', + dept_name varchar(30) default '', + order_num int4 default 0, + leader varchar(20) default null, + phone varchar(11) default null, + email varchar(50) default null, + status char(1) default '0', + del_flag char(1) default '0', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + primary key (dept_id) +); +alter sequence sys_dept_dept_id_seq restart 200; +comment on column sys_dept.dept_id is '部门id'; +comment on column sys_dept.parent_id is '父部门id'; +comment on column sys_dept.ancestors is '祖级列表'; +comment on column sys_dept.dept_name is '部门名称'; +comment on column sys_dept.order_num is '显示顺序'; +comment on column sys_dept.leader is '负责人'; +comment on column sys_dept.phone is '联系电话'; +comment on column sys_dept.email is '邮箱'; +comment on column sys_dept.status is '部门状态(0正常 1停用)'; +comment on column sys_dept.del_flag is '删除标志(0代表存在 2代表删除)'; +comment on column sys_dept.create_by is '创建者'; +comment on column sys_dept.create_time is '创建时间'; +comment on column sys_dept.update_by is '更新者'; +comment on column sys_dept.update_time is '更新时间'; +comment on table sys_dept is '部门表'; + +-- ---------------------------- +-- 初始化-部门表数据 +-- ---------------------------- +insert into sys_dept values(100, 0, '0', '集团总公司', 0, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(101, 100, '0,100', '深圳分公司', 1, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(102, 100, '0,100', '长沙分公司', 2, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(103, 101, '0,100,101', '研发部门', 1, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(104, 101, '0,100,101', '市场部门', 2, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(105, 101, '0,100,101', '测试部门', 3, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(106, 101, '0,100,101', '财务部门', 4, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(107, 101, '0,100,101', '运维部门', 5, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(108, 102, '0,100,102', '市场部门', 1, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); +insert into sys_dept values(109, 102, '0,100,102', '财务部门', 2, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', current_timestamp, '', null); + +-- ---------------------------- +-- 2、用户信息表 +-- ---------------------------- +drop table if exists sys_user; +create table sys_user ( + user_id bigserial not null, + dept_id bigint default null, + user_name varchar(30) not null, + nick_name varchar(30) not null, + user_type varchar(2) default '00', + email varchar(50) default '', + phonenumber varchar(11) default '', + sex char(1) default '0', + avatar varchar(100) default '', + password varchar(100) default '', + status char(1) default '0', + del_flag char(1) default '0', + login_ip varchar(128) default '', + login_date timestamp(0), + pwd_update_date timestamp(0), + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (user_id) +); +alter sequence sys_user_user_id_seq restart 100; +comment on column sys_user.user_id is '用户ID'; +comment on column sys_user.dept_id is '部门ID'; +comment on column sys_user.user_name is '用户账号'; +comment on column sys_user.nick_name is '用户昵称'; +comment on column sys_user.user_type is '用户类型(00系统用户)'; +comment on column sys_user.email is '用户邮箱'; +comment on column sys_user.phonenumber is '手机号码'; +comment on column sys_user.sex is '用户性别(0男 1女 2未知)'; +comment on column sys_user.avatar is '头像地址'; +comment on column sys_user.password is '密码'; +comment on column sys_user.status is '帐号状态(0正常 1停用)'; +comment on column sys_user.del_flag is '删除标志(0代表存在 2代表删除)'; +comment on column sys_user.login_ip is '最后登录IP'; +comment on column sys_user.login_date is '最后登录时间'; +comment on column sys_user.pwd_update_date is '密码最后更新时间'; +comment on column sys_user.create_by is '创建者'; +comment on column sys_user.create_time is '创建时间'; +comment on column sys_user.update_by is '更新者'; +comment on column sys_user.update_time is '更新时间'; +comment on column sys_user.remark is '备注'; +comment on table sys_user is '用户信息表'; + +-- ---------------------------- +-- 初始化-用户信息表数据 +-- ---------------------------- +insert into sys_user values(1, 103, 'admin', '超级管理员', '00', 'niangao@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', current_timestamp, current_timestamp, 'admin', current_timestamp, '', null, '管理员'); +insert into sys_user values(2, 105, 'niangao', '年糕', '00', 'niangao@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', current_timestamp, current_timestamp, 'admin', current_timestamp, '', null, '测试员'); + +-- ---------------------------- +-- 3、岗位信息表 +-- ---------------------------- +drop table if exists sys_post; +create table sys_post ( + post_id bigserial not null, + post_code varchar(64) not null, + post_name varchar(50) not null, + post_sort int4 not null, + status char(1) not null, + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (post_id) +); +alter sequence sys_post_post_id_seq restart 5; +comment on column sys_post.post_id is '岗位ID'; +comment on column sys_post.post_code is '岗位编码'; +comment on column sys_post.post_name is '岗位名称'; +comment on column sys_post.post_sort is '显示顺序'; +comment on column sys_post.status is '状态(0正常 1停用)'; +comment on column sys_post.create_by is '创建者'; +comment on column sys_post.create_time is '创建时间'; +comment on column sys_post.update_by is '更新者'; +comment on column sys_post.update_time is '更新时间'; +comment on column sys_post.remark is '备注'; +comment on table sys_post is '岗位信息表'; + +-- ---------------------------- +-- 初始化-岗位信息表数据 +-- ---------------------------- +insert into sys_post values(1, 'ceo', '董事长', 1, '0', 'admin', current_timestamp, '', null, ''); +insert into sys_post values(2, 'se', '项目经理', 2, '0', 'admin', current_timestamp, '', null, ''); +insert into sys_post values(3, 'hr', '人力资源', 3, '0', 'admin', current_timestamp, '', null, ''); +insert into sys_post values(4, 'user', '普通员工', 4, '0', 'admin', current_timestamp, '', null, ''); + +-- ---------------------------- +-- 4、角色信息表 +-- ---------------------------- +drop table if exists sys_role; +create table sys_role ( + role_id bigserial not null, + role_name varchar(30) not null, + role_key varchar(100) not null, + role_sort int4 not null, + data_scope char(1) default '1', + menu_check_strictly smallint default 1, + dept_check_strictly smallint default 1, + status char(1) not null, + del_flag char(1) default '0', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (role_id) +); +alter sequence sys_role_role_id_seq restart 3; +comment on column sys_role.role_id is '角色ID'; +comment on column sys_role.role_name is '角色名称'; +comment on column sys_role.role_key is '角色权限字符串'; +comment on column sys_role.role_sort is '显示顺序'; +comment on column sys_role.data_scope is '数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)'; +comment on column sys_role.menu_check_strictly is '菜单树选择项是否关联显示'; +comment on column sys_role.dept_check_strictly is '部门树选择项是否关联显示'; +comment on column sys_role.status is '角色状态(0正常 1停用)'; +comment on column sys_role.del_flag is '删除标志(0代表存在 2代表删除)'; +comment on column sys_role.create_by is '创建者'; +comment on column sys_role.create_time is '创建时间'; +comment on column sys_role.update_by is '更新者'; +comment on column sys_role.update_time is '更新时间'; +comment on column sys_role.remark is '备注'; +comment on table sys_role is '角色信息表'; + +-- ---------------------------- +-- 初始化-角色信息表数据 +-- ---------------------------- +insert into sys_role values(1, '超级管理员', 'admin', 1, 1, 1, 1, '0', '0', 'admin', current_timestamp, '', null, '超级管理员'); +insert into sys_role values(2, '普通角色', 'common', 2, 2, 1, 1, '0', '0', 'admin', current_timestamp, '', null, '普通角色'); + +-- ---------------------------- +-- 5、菜单权限表 +-- ---------------------------- +drop table if exists sys_menu; +create table sys_menu ( + menu_id bigserial not null, + menu_name varchar(50) not null, + parent_id bigint default 0, + order_num int4 default 0, + path varchar(200) default '', + component varchar(255) default null, + query varchar(255) default null, + route_name varchar(50) default '', + is_frame int4 default 1, + is_cache int4 default 0, + menu_type char(1) default '', + visible char(1) default '0', + status char(1) default '0', + perms varchar(100) default null, + icon varchar(100) default '#', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default '', + primary key (menu_id) +); +alter sequence sys_menu_menu_id_seq restart 2000; +comment on column sys_menu.menu_id is '菜单ID'; +comment on column sys_menu.menu_name is '菜单名称'; +comment on column sys_menu.parent_id is '父菜单ID'; +comment on column sys_menu.order_num is '显示顺序'; +comment on column sys_menu.path is '路由地址'; +comment on column sys_menu.component is '组件路径'; +comment on column sys_menu.query is '路由参数'; +comment on column sys_menu.route_name is '路由名称'; +comment on column sys_menu.is_frame is '是否为外链(0是 1否)'; +comment on column sys_menu.is_cache is '是否缓存(0缓存 1不缓存)'; +comment on column sys_menu.menu_type is '菜单类型(M目录 C菜单 F按钮)'; +comment on column sys_menu.visible is '菜单状态(0显示 1隐藏)'; +comment on column sys_menu.status is '菜单状态(0正常 1停用)'; +comment on column sys_menu.perms is '权限标识'; +comment on column sys_menu.icon is '菜单图标'; +comment on column sys_menu.create_by is '创建者'; +comment on column sys_menu.create_time is '创建时间'; +comment on column sys_menu.update_by is '更新者'; +comment on column sys_menu.update_time is '更新时间'; +comment on column sys_menu.remark is '备注'; +comment on table sys_menu is '菜单权限表'; + +-- ---------------------------- +-- 初始化-菜单信息表数据 +-- ---------------------------- +-- 一级菜单 +insert into sys_menu values(1, '系统管理', 0, '1', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', current_timestamp, '', null, '系统管理目录'); +insert into sys_menu values(2, '系统监控', 0, '2', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', current_timestamp, '', null, '系统监控目录'); +insert into sys_menu values(3, '系统工具', 0, '3', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', current_timestamp, '', null, '系统工具目录'); +insert into sys_menu values(4, 'AI 管理', 0, '4', 'ai', null, '', '', 1, 0, 'M', '0', '0', '', 'bug', 'admin', current_timestamp, '', null, 'AI 管理目录'); +insert into sys_menu values(99, '施尔丰仓库', 0, '99', 'https://git.yujiangrubber.cn/niuniu/ShiErFeng', null, '', '', 0, 0, 'M', '0', '0', '', 'guide', 'admin', current_timestamp, '', null, '施尔丰仓库地址'); +-- 二级菜单 +insert into sys_menu values(100, '用户管理', 1, '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', current_timestamp, '', null, '用户管理菜单'); +insert into sys_menu values(101, '角色管理', 1, '2', 'role', 'system/role/index', '', '', 1, 0, 'C', '0', '0', 'system:role:list', 'peoples', 'admin', current_timestamp, '', null, '角色管理菜单'); +insert into sys_menu values(102, '菜单管理', 1, '3', 'menu', 'system/menu/index', '', '', 1, 0, 'C', '0', '0', 'system:menu:list', 'tree-table', 'admin', current_timestamp, '', null, '菜单管理菜单'); +insert into sys_menu values(103, '部门管理', 1, '4', 'dept', 'system/dept/index', '', '', 1, 0, 'C', '0', '0', 'system:dept:list', 'tree', 'admin', current_timestamp, '', null, '部门管理菜单'); +insert into sys_menu values(104, '岗位管理', 1, '5', 'post', 'system/post/index', '', '', 1, 0, 'C', '0', '0', 'system:post:list', 'post', 'admin', current_timestamp, '', null, '岗位管理菜单'); +insert into sys_menu values(105, '字典管理', 1, '6', 'dict', 'system/dict/index', '', '', 1, 0, 'C', '0', '0', 'system:dict:list', 'dict', 'admin', current_timestamp, '', null, '字典管理菜单'); +insert into sys_menu values(106, '参数设置', 1, '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', current_timestamp, '', null, '参数设置菜单'); +insert into sys_menu values(107, '通知公告', 1, '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', current_timestamp, '', null, '通知公告菜单'); +insert into sys_menu values(108, '日志管理', 1, '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', current_timestamp, '', null, '日志管理菜单'); +insert into sys_menu values(109, '在线用户', 2, '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', current_timestamp, '', null, '在线用户菜单'); +insert into sys_menu values(110, '定时任务', 2, '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', current_timestamp, '', null, '定时任务菜单'); +insert into sys_menu values(111, '数据监控', 2, '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', current_timestamp, '', null, '数据监控菜单'); +insert into sys_menu values(112, '服务监控', 2, '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', current_timestamp, '', null, '服务监控菜单'); +insert into sys_menu values(113, '缓存监控', 2, '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', current_timestamp, '', null, '缓存监控菜单'); +insert into sys_menu values(114, '缓存列表', 2, '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', current_timestamp, '', null, '缓存列表菜单'); +insert into sys_menu values(120, '传输加密', 2, '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', current_timestamp, '', null, '传输加密监控菜单'); +insert into sys_menu values(115, '表单构建', 3, '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', current_timestamp, '', null, '表单构建菜单'); +insert into sys_menu values(116, '代码生成', 3, '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', current_timestamp, '', null, '代码生成菜单'); +insert into sys_menu values(117, '系统接口', 3, '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', current_timestamp, '', null, '系统接口菜单'); +insert into sys_menu values(118, '模型管理', 4, '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'form', 'admin', current_timestamp, '', null, '模型管理菜单'); +insert into sys_menu values(119, 'AI 对话', 4, '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'wechat', 'admin', current_timestamp, '', null, 'AI 对话菜单'); +-- 三级菜单 +insert into sys_menu values(500, '操作日志', 108, '1', 'operlog', 'monitor/operlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:operlog:list', 'form', 'admin', current_timestamp, '', null, '操作日志菜单'); +insert into sys_menu values(501, '登录日志', 108, '2', 'logininfor', 'monitor/logininfor/index', '', '', 1, 0, 'C', '0', '0', 'monitor:logininfor:list', 'logininfor', 'admin', current_timestamp, '', null, '登录日志菜单'); +-- 用户管理按钮 +insert into sys_menu values(1000, '用户查询', 100, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1001, '用户新增', 100, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1002, '用户修改', 100, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1003, '用户删除', 100, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1004, '用户导出', 100, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:export', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1005, '用户导入', 100, '6', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:import', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1006, '重置密码', 100, '7', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:resetPwd', '#', 'admin', current_timestamp, '', null, ''); +-- 角色管理按钮 +insert into sys_menu values(1007, '角色查询', 101, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1008, '角色新增', 101, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1009, '角色修改', 101, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1010, '角色删除', 101, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1011, '角色导出', 101, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:export', '#', 'admin', current_timestamp, '', null, ''); +-- 菜单管理按钮 +insert into sys_menu values(1012, '菜单查询', 102, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1013, '菜单新增', 102, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1014, '菜单修改', 102, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1015, '菜单删除', 102, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:remove', '#', 'admin', current_timestamp, '', null, ''); +-- 部门管理按钮 +insert into sys_menu values(1016, '部门查询', 103, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1017, '部门新增', 103, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1018, '部门修改', 103, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1019, '部门删除', 103, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:remove', '#', 'admin', current_timestamp, '', null, ''); +-- 岗位管理按钮 +insert into sys_menu values(1020, '岗位查询', 104, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1021, '岗位新增', 104, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1022, '岗位修改', 104, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1023, '岗位删除', 104, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1024, '岗位导出', 104, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:export', '#', 'admin', current_timestamp, '', null, ''); +-- 字典管理按钮 +insert into sys_menu values(1025, '字典查询', 105, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1026, '字典新增', 105, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1027, '字典修改', 105, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1028, '字典删除', 105, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1029, '字典导出', 105, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:export', '#', 'admin', current_timestamp, '', null, ''); +-- 参数设置按钮 +insert into sys_menu values(1030, '参数查询', 106, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1031, '参数新增', 106, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1032, '参数修改', 106, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1033, '参数删除', 106, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1034, '参数导出', 106, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:export', '#', 'admin', current_timestamp, '', null, ''); +-- 通知公告按钮 +insert into sys_menu values(1035, '公告查询', 107, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1036, '公告新增', 107, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1037, '公告修改', 107, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1038, '公告删除', 107, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:remove', '#', 'admin', current_timestamp, '', null, ''); +-- 操作日志按钮 +insert into sys_menu values(1039, '操作查询', 500, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1040, '操作删除', 500, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1041, '日志导出', 500, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:export', '#', 'admin', current_timestamp, '', null, ''); +-- 登录日志按钮 +insert into sys_menu values(1042, '登录查询', 501, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1043, '登录删除', 501, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1044, '日志导出', 501, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:export', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1045, '账户解锁', 501, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:unlock', '#', 'admin', current_timestamp, '', null, ''); +-- 在线用户按钮 +insert into sys_menu values(1046, '在线查询', 109, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:online:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1047, '批量强退', 109, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:online:batchLogout', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1048, '单条强退', 109, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:online:forceLogout', '#', 'admin', current_timestamp, '', null, ''); +-- 定时任务按钮 +insert into sys_menu values(1049, '任务查询', 110, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1050, '任务新增', 110, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1051, '任务修改', 110, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1052, '任务删除', 110, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1053, '状态修改', 110, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:changeStatus', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1054, '任务导出', 110, '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:export', '#', 'admin', current_timestamp, '', null, ''); +-- 代码生成按钮 +insert into sys_menu values(1055, '生成查询', 116, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1056, '生成修改', 116, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1057, '生成删除', 116, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1058, '导入代码', 116, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:import', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1059, '预览代码', 116, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:preview', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1060, '生成代码', 116, '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:code', '#', 'admin', current_timestamp, '', null, ''); +-- 模型管理按钮 +insert into sys_menu values(1061, '模型查询', 118, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1062, '模型新增', 118, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:add', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1063, '模型修改', 118, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1064, '模型删除', 118, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:remove', '#', 'admin', current_timestamp, '', null, ''); + +-- ---------------------------- +-- 6、用户和角色关联表 用户N-1角色 +-- ---------------------------- +drop table if exists sys_user_role; +create table sys_user_role ( + user_id bigint not null, + role_id bigint not null, + primary key (user_id, role_id) +); +comment on column sys_user_role.user_id is '用户ID'; +comment on column sys_user_role.role_id is '角色ID'; +comment on table sys_user_role is '用户和角色关联表'; + +-- ---------------------------- +-- 初始化-用户和角色关联表数据 +-- ---------------------------- +insert into sys_user_role values (1, 1); +insert into sys_user_role values (2, 2); + +-- ---------------------------- +-- 7、角色和菜单关联表 角色1-N菜单 +-- ---------------------------- +drop table if exists sys_role_menu; +create table sys_role_menu ( + role_id bigint not null, + menu_id bigint not null, + primary key (role_id, menu_id) +); +comment on column sys_role_menu.role_id is '角色ID'; +comment on column sys_role_menu.menu_id is '菜单ID'; +comment on table sys_role_menu is '角色和菜单关联表'; + +-- ---------------------------- +-- 初始化-角色和菜单关联表数据 +-- ---------------------------- +insert into sys_role_menu values (2, 1); +insert into sys_role_menu values (2, 2); +insert into sys_role_menu values (2, 3); +insert into sys_role_menu values (2, 4); +insert into sys_role_menu values (2, 100); +insert into sys_role_menu values (2, 101); +insert into sys_role_menu values (2, 102); +insert into sys_role_menu values (2, 103); +insert into sys_role_menu values (2, 104); +insert into sys_role_menu values (2, 105); +insert into sys_role_menu values (2, 106); +insert into sys_role_menu values (2, 107); +insert into sys_role_menu values (2, 108); +insert into sys_role_menu values (2, 109); +insert into sys_role_menu values (2, 110); +insert into sys_role_menu values (2, 111); +insert into sys_role_menu values (2, 112); +insert into sys_role_menu values (2, 113); +insert into sys_role_menu values (2, 114); +insert into sys_role_menu values (2, 120); +insert into sys_role_menu values (2, 115); +insert into sys_role_menu values (2, 116); +insert into sys_role_menu values (2, 117); +insert into sys_role_menu values (2, 500); +insert into sys_role_menu values (2, 501); +insert into sys_role_menu values (2, 1000); +insert into sys_role_menu values (2, 1001); +insert into sys_role_menu values (2, 1002); +insert into sys_role_menu values (2, 1003); +insert into sys_role_menu values (2, 1004); +insert into sys_role_menu values (2, 1005); +insert into sys_role_menu values (2, 1006); +insert into sys_role_menu values (2, 1007); +insert into sys_role_menu values (2, 1008); +insert into sys_role_menu values (2, 1009); +insert into sys_role_menu values (2, 1010); +insert into sys_role_menu values (2, 1011); +insert into sys_role_menu values (2, 1012); +insert into sys_role_menu values (2, 1013); +insert into sys_role_menu values (2, 1014); +insert into sys_role_menu values (2, 1015); +insert into sys_role_menu values (2, 1016); +insert into sys_role_menu values (2, 1017); +insert into sys_role_menu values (2, 1018); +insert into sys_role_menu values (2, 1019); +insert into sys_role_menu values (2, 1020); +insert into sys_role_menu values (2, 1021); +insert into sys_role_menu values (2, 1022); +insert into sys_role_menu values (2, 1023); +insert into sys_role_menu values (2, 1024); +insert into sys_role_menu values (2, 1025); +insert into sys_role_menu values (2, 1026); +insert into sys_role_menu values (2, 1027); +insert into sys_role_menu values (2, 1028); +insert into sys_role_menu values (2, 1029); +insert into sys_role_menu values (2, 1030); +insert into sys_role_menu values (2, 1031); +insert into sys_role_menu values (2, 1032); +insert into sys_role_menu values (2, 1033); +insert into sys_role_menu values (2, 1034); +insert into sys_role_menu values (2, 1035); +insert into sys_role_menu values (2, 1036); +insert into sys_role_menu values (2, 1037); +insert into sys_role_menu values (2, 1038); +insert into sys_role_menu values (2, 1039); +insert into sys_role_menu values (2, 1040); +insert into sys_role_menu values (2, 1041); +insert into sys_role_menu values (2, 1042); +insert into sys_role_menu values (2, 1043); +insert into sys_role_menu values (2, 1044); +insert into sys_role_menu values (2, 1045); +insert into sys_role_menu values (2, 1046); +insert into sys_role_menu values (2, 1047); +insert into sys_role_menu values (2, 1048); +insert into sys_role_menu values (2, 1049); +insert into sys_role_menu values (2, 1050); +insert into sys_role_menu values (2, 1051); +insert into sys_role_menu values (2, 1052); +insert into sys_role_menu values (2, 1053); +insert into sys_role_menu values (2, 1054); +insert into sys_role_menu values (2, 1055); +insert into sys_role_menu values (2, 1056); +insert into sys_role_menu values (2, 1057); +insert into sys_role_menu values (2, 1058); +insert into sys_role_menu values (2, 1059); +insert into sys_role_menu values (2, 1060); + +-- ---------------------------- +-- 8、角色和部门关联表 角色1-N部门 +-- ---------------------------- +drop table if exists sys_role_dept; +create table sys_role_dept ( + role_id bigint not null, + dept_id bigint not null, + primary key (role_id, dept_id) +); +comment on column sys_role_dept.role_id is '角色ID'; +comment on column sys_role_dept.dept_id is '部门ID'; +comment on table sys_role_dept is '角色和部门关联表'; + +-- ---------------------------- +-- 初始化-角色和部门关联表数据 +-- ---------------------------- +insert into sys_role_dept values (2, 100); +insert into sys_role_dept values (2, 101); +insert into sys_role_dept values (2, 105); + +-- ---------------------------- +-- 9、用户与岗位关联表 用户1-N岗位 +-- ---------------------------- +drop table if exists sys_user_post; +create table sys_user_post ( + user_id bigint not null, + post_id bigint not null, + primary key (user_id, post_id) +); +comment on column sys_user_post.user_id is '用户ID'; +comment on column sys_user_post.post_id is '岗位ID'; +comment on table sys_user_post is '用户与岗位关联表'; + +-- ---------------------------- +-- 初始化-用户与岗位关联表数据 +-- ---------------------------- +insert into sys_user_post values (1, 1); +insert into sys_user_post values (2, 2); + +-- ---------------------------- +-- 10、操作日志记录 +-- ---------------------------- +drop table if exists sys_oper_log; +create table sys_oper_log ( + oper_id bigserial not null, + title varchar(50) default '', + business_type int4 default 0, + method varchar(100) default '', + request_method varchar(10) default '', + operator_type int4 default 0, + oper_name varchar(50) default '', + dept_name varchar(50) default '', + oper_url varchar(255) default '', + oper_ip varchar(128) default '', + oper_location varchar(255) default '', + oper_param varchar(2000) default '', + json_result varchar(2000) default '', + status int4 default 0, + error_msg varchar(2000) default '', + oper_time timestamp(0), + cost_time bigint default 0, + primary key (oper_id) +); +alter sequence sys_oper_log_oper_id_seq restart 100; +create index idx_sys_oper_log_bt on sys_oper_log(business_type); +create index idx_sys_oper_log_s on sys_oper_log(status); +create index idx_sys_oper_log_ot on sys_oper_log(oper_time); +comment on column sys_oper_log.oper_id is '日志主键'; +comment on column sys_oper_log.title is '模块标题'; +comment on column sys_oper_log.business_type is '业务类型(0其它 1新增 2修改 3删除)'; +comment on column sys_oper_log.method is '方法名称'; +comment on column sys_oper_log.request_method is '请求方式'; +comment on column sys_oper_log.operator_type is '操作类别(0其它 1后台用户 2手机端用户)'; +comment on column sys_oper_log.oper_name is '操作人员'; +comment on column sys_oper_log.dept_name is '部门名称'; +comment on column sys_oper_log.oper_url is '请求URL'; +comment on column sys_oper_log.oper_ip is '主机地址'; +comment on column sys_oper_log.oper_location is '操作地点'; +comment on column sys_oper_log.oper_param is '请求参数'; +comment on column sys_oper_log.json_result is '返回参数'; +comment on column sys_oper_log.status is '操作状态(0正常 1异常)'; +comment on column sys_oper_log.error_msg is '错误消息'; +comment on column sys_oper_log.oper_time is '操作时间'; +comment on column sys_oper_log.cost_time is '消耗时间'; +comment on table sys_oper_log is '操作日志记录'; + +-- ---------------------------- +-- 11、字典类型表 +-- ---------------------------- +drop table if exists sys_dict_type; +create table sys_dict_type ( + dict_id bigserial not null, + dict_name varchar(100) default '', + dict_type varchar(100) unique default '', + status char(1) default '0', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (dict_id) +); +alter sequence sys_dict_type_dict_id_seq restart 100; +comment on column sys_dict_type.dict_id is '字典主键'; +comment on column sys_dict_type.dict_name is '字典名称'; +comment on column sys_dict_type.dict_type is '字典类型'; +comment on column sys_dict_type.status is '状态(0正常 1停用)'; +comment on column sys_dict_type.create_by is '创建者'; +comment on column sys_dict_type.create_time is '创建时间'; +comment on column sys_dict_type.update_by is '更新者'; +comment on column sys_dict_type.update_time is '更新时间'; +comment on column sys_dict_type.remark is '备注'; +comment on table sys_dict_type is '字典类型表'; + +-- ---------------------------- +-- 初始化-字典类型表数据 +-- ---------------------------- +insert into sys_dict_type values(1, '用户性别', 'sys_user_sex', '0', 'admin', current_timestamp, '', null, '用户性别列表'); +insert into sys_dict_type values(2, '菜单状态', 'sys_show_hide', '0', 'admin', current_timestamp, '', null, '菜单状态列表'); +insert into sys_dict_type values(3, '系统开关', 'sys_normal_disable', '0', 'admin', current_timestamp, '', null, '系统开关列表'); +insert into sys_dict_type values(4, '任务状态', 'sys_job_status', '0', 'admin', current_timestamp, '', null, '任务状态列表'); +insert into sys_dict_type values(5, '任务分组', 'sys_job_group', '0', 'admin', current_timestamp, '', null, '任务分组列表'); +insert into sys_dict_type values(6, '任务执行器', 'sys_job_executor', '0', 'admin', current_timestamp, '', null, '任务执行器列表'); +insert into sys_dict_type values(7, '系统是否', 'sys_yes_no', '0', 'admin', current_timestamp, '', null, '系统是否列表'); +insert into sys_dict_type values(8, '通知类型', 'sys_notice_type', '0', 'admin', current_timestamp, '', null, '通知类型列表'); +insert into sys_dict_type values(9, '通知状态', 'sys_notice_status', '0', 'admin', current_timestamp, '', null, '通知状态列表'); +insert into sys_dict_type values(10, '操作类型', 'sys_oper_type', '0', 'admin', current_timestamp, '', null, '操作类型列表'); +insert into sys_dict_type values(11, '系统状态', 'sys_common_status', '0', 'admin', current_timestamp, '', null, '登录状态列表'); +insert into sys_dict_type values(12, 'AI模型提供商', 'ai_provider_type', '0', 'admin', current_timestamp, '', null, 'AI模型提供商列表'); + +-- ---------------------------- +-- 12、字典数据表 +-- ---------------------------- +drop table if exists sys_dict_data; +create table sys_dict_data ( + dict_code bigserial not null, + dict_sort int4 default 0, + dict_label varchar(100) default '', + dict_value varchar(100) default '', + dict_type varchar(100) default '', + css_class varchar(100) default null, + list_class varchar(100) default null, + is_default char(1) default 'N', + status char(1) default '0', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (dict_code) +); +alter sequence sys_dict_data_dict_code_seq restart 100; +comment on column sys_dict_data.dict_code is '字典编码'; +comment on column sys_dict_data.dict_sort is '字典排序'; +comment on column sys_dict_data.dict_label is '字典标签'; +comment on column sys_dict_data.dict_value is '字典键值'; +comment on column sys_dict_data.dict_type is '字典类型'; +comment on column sys_dict_data.css_class is '样式属性(其他样式扩展)'; +comment on column sys_dict_data.list_class is '表格回显样式'; +comment on column sys_dict_data.is_default is '是否默认(Y是 N否)'; +comment on column sys_dict_data.status is '状态(0正常 1停用)'; +comment on column sys_dict_data.create_by is '创建者'; +comment on column sys_dict_data.create_time is '创建时间'; +comment on column sys_dict_data.update_by is '更新者'; +comment on column sys_dict_data.update_time is '更新时间'; +comment on column sys_dict_data.remark is '备注'; +comment on table sys_dict_data is '字典数据表'; + +-- ---------------------------- +-- 初始化-字典数据表数据 +-- ---------------------------- +insert into sys_dict_data values(1, 1, '男', '0', 'sys_user_sex', '', '', 'Y', '0', 'admin', current_timestamp, '', null, '性别男'); +insert into sys_dict_data values(2, 2, '女', '1', 'sys_user_sex', '', '', 'N', '0', 'admin', current_timestamp, '', null, '性别女'); +insert into sys_dict_data values(3, 3, '未知', '2', 'sys_user_sex', '', '', 'N', '0', 'admin', current_timestamp, '', null, '性别未知'); +insert into sys_dict_data values(4, 1, '显示', '0', 'sys_show_hide', '', 'primary', 'Y', '0', 'admin', current_timestamp, '', null, '显示菜单'); +insert into sys_dict_data values(5, 2, '隐藏', '1', 'sys_show_hide', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '隐藏菜单'); +insert into sys_dict_data values(6, 1, '正常', '0', 'sys_normal_disable', '', 'primary', 'Y', '0', 'admin', current_timestamp, '', null, '正常状态'); +insert into sys_dict_data values(7, 2, '停用', '1', 'sys_normal_disable', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '停用状态'); +insert into sys_dict_data values(8, 1, '正常', '0', 'sys_job_status', '', 'primary', 'Y', '0', 'admin', current_timestamp, '', null, '正常状态'); +insert into sys_dict_data values(9, 2, '暂停', '1', 'sys_job_status', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '停用状态'); +insert into sys_dict_data values(10, 1, '默认', 'default', 'sys_job_group', '', '', 'Y', '0', 'admin', current_timestamp, '', null, '默认分组'); +insert into sys_dict_data values(11, 2, '数据库', 'sqlalchemy', 'sys_job_group', '', '', 'N', '0', 'admin', current_timestamp, '', null, '数据库分组'); +insert into sys_dict_data values(12, 3, 'redis', 'redis', 'sys_job_group', '', '', 'N', '0', 'admin', current_timestamp, '', null, 'reids分组'); +insert into sys_dict_data values(13, 1, '默认', 'default', 'sys_job_executor', '', '', 'N', '0', 'admin', current_timestamp, '', null, '线程池'); +insert into sys_dict_data values(14, 2, '进程池', 'processpool', 'sys_job_executor', '', '', 'N', '0', 'admin', current_timestamp, '', null, '进程池'); +insert into sys_dict_data values(15, 1, '是', 'Y', 'sys_yes_no', '', 'primary', 'Y', '0', 'admin', current_timestamp, '', null, '系统默认是'); +insert into sys_dict_data values(16, 2, '否', 'N', 'sys_yes_no', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '系统默认否'); +insert into sys_dict_data values(17, 1, '通知', '1', 'sys_notice_type', '', 'warning', 'Y', '0', 'admin', current_timestamp, '', null, '通知'); +insert into sys_dict_data values(18, 2, '公告', '2', 'sys_notice_type', '', 'success', 'N', '0', 'admin', current_timestamp, '', null, '公告'); +insert into sys_dict_data values(19, 1, '正常', '0', 'sys_notice_status', '', 'primary', 'Y', '0', 'admin', current_timestamp, '', null, '正常状态'); +insert into sys_dict_data values(20, 2, '关闭', '1', 'sys_notice_status', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '关闭状态'); +insert into sys_dict_data values(21, 99, '其他', '0', 'sys_oper_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, '其他操作'); +insert into sys_dict_data values(22, 1, '新增', '1', 'sys_oper_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, '新增操作'); +insert into sys_dict_data values(23, 2, '修改', '2', 'sys_oper_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, '修改操作'); +insert into sys_dict_data values(24, 3, '删除', '3', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '删除操作'); +insert into sys_dict_data values(25, 4, '授权', '4', 'sys_oper_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '授权操作'); +insert into sys_dict_data values(26, 5, '导出', '5', 'sys_oper_type', '', 'warning', 'N', '0', 'admin', current_timestamp, '', null, '导出操作'); +insert into sys_dict_data values(27, 6, '导入', '6', 'sys_oper_type', '', 'warning', 'N', '0', 'admin', current_timestamp, '', null, '导入操作'); +insert into sys_dict_data values(28, 7, '强退', '7', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '强退操作'); +insert into sys_dict_data values(29, 8, '生成代码', '8', 'sys_oper_type', '', 'warning', 'N', '0', 'admin', current_timestamp, '', null, '生成操作'); +insert into sys_dict_data values(30, 9, '清空数据', '9', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '清空操作'); +insert into sys_dict_data values(31, 1, '成功', '0', 'sys_common_status', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '正常状态'); +insert into sys_dict_data values(32, 2, '失败', '1', 'sys_common_status', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '停用状态'); +insert into sys_dict_data values(33, 1, 'AIMLAPI', 'AIMLAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'AIMLAPI'); +insert into sys_dict_data values(34, 2, 'Anthropic', 'Anthropic', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Anthropic'); +insert into sys_dict_data values(35, 3, 'Cerebras', 'Cerebras', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Cerebras'); +insert into sys_dict_data values(36, 4, 'CerebrasOpenAI', 'CerebrasOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'CerebrasOpenAI'); +insert into sys_dict_data values(37, 5, 'Cohere', 'Cohere', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Cohere'); +insert into sys_dict_data values(38, 6, 'CometAPI', 'CometAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'CometAPI'); +insert into sys_dict_data values(39, 7, 'DashScope', 'DashScope', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'DashScope'); +insert into sys_dict_data values(40, 8, 'DeepInfra', 'DeepInfra', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'DeepInfra'); +insert into sys_dict_data values(41, 9, 'DeepSeek', 'DeepSeek', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'DeepSeek'); +insert into sys_dict_data values(42, 10, 'Fireworks', 'Fireworks', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Fireworks'); +insert into sys_dict_data values(43, 11, 'Google', 'Google', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Google'); +insert into sys_dict_data values(44, 12, 'Groq', 'Groq', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Groq'); +insert into sys_dict_data values(45, 13, 'HuggingFace', 'HuggingFace', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'HuggingFace'); +insert into sys_dict_data values(46, 14, 'LangDB', 'LangDB', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LangDB'); +insert into sys_dict_data values(47, 15, 'LiteLLM', 'LiteLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LiteLLM'); +insert into sys_dict_data values(48, 16, 'LiteLLMOpenAI', 'LiteLLMOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LiteLLMOpenAI'); +insert into sys_dict_data values(49, 17, 'LlamaCpp', 'LlamaCpp', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LlamaCpp'); +insert into sys_dict_data values(50, 18, 'LMStudio', 'LMStudio', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LMStudio'); +insert into sys_dict_data values(51, 19, 'Meta', 'Meta', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Meta'); +insert into sys_dict_data values(52, 20, 'Mistral', 'Mistral', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Mistral'); +insert into sys_dict_data values(53, 21, 'N1N', 'N1N', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'N1N'); +insert into sys_dict_data values(54, 22, 'Nebius', 'Nebius', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Nebius'); +insert into sys_dict_data values(55, 23, 'Nexus', 'Nexus', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Nexus'); +insert into sys_dict_data values(56, 24, 'Nvidia', 'Nvidia', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Nvidia'); +insert into sys_dict_data values(57, 25, 'Ollama', 'Ollama', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Ollama'); +insert into sys_dict_data values(58, 26, 'OpenAI', 'OpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'OpenAI'); +insert into sys_dict_data values(59, 27, 'OpenAIResponses', 'OpenAIResponses', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'OpenAIResponses'); +insert into sys_dict_data values(60, 28, 'OpenRouter', 'OpenRouter', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'OpenRouter'); +insert into sys_dict_data values(61, 29, 'Perplexity', 'Perplexity', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Perplexity'); +insert into sys_dict_data values(62, 30, 'Portkey', 'Portkey', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Portkey'); +insert into sys_dict_data values(63, 31, 'Requesty', 'Requesty', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Requesty'); +insert into sys_dict_data values(64, 32, 'Sambanova', 'Sambanova', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Sambanova'); +insert into sys_dict_data values(65, 33, 'SiliconFlow', 'SiliconFlow', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'SiliconFlow'); +insert into sys_dict_data values(66, 34, 'Together', 'Together', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Together'); +insert into sys_dict_data values(67, 35, 'Vercel', 'Vercel', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Vercel'); +insert into sys_dict_data values(68, 36, 'VLLM', 'VLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'VLLM'); +insert into sys_dict_data values(69, 37, 'xAI', 'xAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'xAI'); + +-- ---------------------------- +-- 13、参数配置表 +-- ---------------------------- +drop table if exists sys_config; +create table sys_config ( + config_id serial not null, + config_name varchar(100) default '', + config_key varchar(100) default '', + config_value varchar(500) default '', + config_type char(1) default 'N', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (config_id) +); +alter sequence sys_config_config_id_seq restart 100; +comment on column sys_config.config_id is '参数主键'; +comment on column sys_config.config_name is '参数名称'; +comment on column sys_config.config_key is '参数键名'; +comment on column sys_config.config_value is '参数键值'; +comment on column sys_config.config_type is '系统内置(Y是 N否)'; +comment on column sys_config.create_by is '创建者'; +comment on column sys_config.create_time is '创建时间'; +comment on column sys_config.update_by is '更新者'; +comment on column sys_config.update_time is '更新时间'; +comment on column sys_config.remark is '备注'; +comment on table sys_config is '参数配置表'; + +-- ---------------------------- +-- 初始化-参数配置表数据 +-- ---------------------------- +insert into sys_config values(1, '主框架页-默认皮肤样式名称', 'sys.index.skinName', 'skin-blue', 'Y', 'admin', current_timestamp, '', null, '蓝色 skin-blue、绿色 skin-green、紫色 skin-purple、红色 skin-red、黄色 skin-yellow' ); +insert into sys_config values(2, '用户管理-账号初始密码', 'sys.user.initPassword', '123456', 'Y', 'admin', current_timestamp, '', null, '初始化密码 123456' ); +insert into sys_config values(3, '主框架页-侧边栏主题', 'sys.index.sideTheme', 'theme-dark', 'Y', 'admin', current_timestamp, '', null, '深色主题theme-dark,浅色主题theme-light' ); +insert into sys_config values(4, '账号自助-验证码开关', 'sys.account.captchaEnabled', 'true', 'Y', 'admin', current_timestamp, '', null, '是否开启验证码功能(true开启,false关闭)'); +insert into sys_config values(5, '账号自助-是否开启用户注册功能', 'sys.account.registerUser', 'false', 'Y', 'admin', current_timestamp, '', null, '是否开启注册用户功能(true开启,false关闭)'); +insert into sys_config values(6, '用户登录-黑名单列表', 'sys.login.blackIPList', '', 'Y', 'admin', current_timestamp, '', null, '设置登录IP黑名单限制,多个匹配项以;分隔,支持匹配(*通配、网段)'); +insert into sys_config values(7, '用户管理-初始密码修改策略', 'sys.account.initPasswordModify', '1', 'Y', 'admin', current_timestamp, '', null, '0:初始密码修改策略关闭,没有任何提示,1:提醒用户,如果未修改初始密码,则在登录时就会提醒修改密码对话框'); +insert into sys_config values(8, '用户管理-账号密码更新周期', 'sys.account.passwordValidateDays', '0', 'Y', 'admin', current_timestamp, '', null, '密码更新周期(填写数字,数据初始化值为0不限制,若修改必须为大于0小于365的正整数),如果超过这个周期登录系统时,则在登录时就会提醒修改密码对话框'); + +-- ---------------------------- +-- 14、系统访问记录 +-- ---------------------------- +drop table if exists sys_logininfor; +create table sys_logininfor ( + info_id bigserial not null, + user_name varchar(50) default '', + ipaddr varchar(128) default '', + login_location varchar(255) default '', + browser varchar(50) default '', + os varchar(50) default '', + status char(1) default '0', + msg varchar(255) default '', + login_time timestamp(0), + primary key (info_id) +); +alter sequence sys_logininfor_info_id_seq restart 100; +create index idx_sys_logininfor_s on sys_logininfor(status); +create index idx_sys_logininfor_lt on sys_logininfor(login_time); +comment on column sys_logininfor.info_id is '访问ID'; +comment on column sys_logininfor.user_name is '用户账号'; +comment on column sys_logininfor.ipaddr is '登录IP地址'; +comment on column sys_logininfor.login_location is '登录地点'; +comment on column sys_logininfor.browser is '浏览器类型'; +comment on column sys_logininfor.os is '操作系统'; +comment on column sys_logininfor.status is '登录状态(0成功 1失败)'; +comment on column sys_logininfor.msg is '提示消息'; +comment on column sys_logininfor.login_time is '访问时间'; +comment on table sys_logininfor is '系统访问记录'; + +-- ---------------------------- +-- 15、定时任务调度表 +-- ---------------------------- +drop table if exists sys_job; +create table sys_job ( + job_id bigserial not null, + job_name varchar(64) default '', + job_group varchar(64) default 'default', + job_executor varchar(64) default 'default', + invoke_target varchar(500) not null, + job_args varchar(255) default '', + job_kwargs varchar(255) default '', + cron_expression varchar(255) default '', + misfire_policy varchar(20) default '3', + concurrent char(1) default '1', + status char(1) default '0', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default '', + primary key (job_id, job_name, job_group) +); +alter sequence sys_job_job_id_seq restart 100; +comment on column sys_job.job_id is '任务ID'; +comment on column sys_job.job_name is '任务名称'; +comment on column sys_job.job_group is '任务组名'; +comment on column sys_job.job_executor is '任务执行器'; +comment on column sys_job.invoke_target is '调用目标字符串'; +comment on column sys_job.job_args is '位置参数'; +comment on column sys_job.job_kwargs is '关键字参数'; +comment on column sys_job.cron_expression is 'cron执行表达式'; +comment on column sys_job.misfire_policy is '计划执行错误策略(1立即执行 2执行一次 3放弃执行)'; +comment on column sys_job.concurrent is '是否并发执行(0允许 1禁止)'; +comment on column sys_job.status is '状态(0正常 1暂停)'; +comment on column sys_job.create_by is '创建者'; +comment on column sys_job.create_time is '创建时间'; +comment on column sys_job.update_by is '更新者'; +comment on column sys_job.update_time is '更新时间'; +comment on column sys_job.remark is '备注信息'; +comment on table sys_job is '定时任务调度表'; + +-- ---------------------------- +-- 初始化-定时任务调度表数据 +-- ---------------------------- +insert into sys_job values(1, '系统默认(无参)', 'default', 'default', 'module_task.scheduler_test.job', null, null, '0/10 * * * * ?', '3', '1', '1', 'admin', current_timestamp, '', null, ''); +insert into sys_job values(2, '系统默认(有参)', 'default', 'default', 'module_task.scheduler_test.job', 'test', null, '0/15 * * * * ?', '3', '1', '1', 'admin', current_timestamp, '', null, ''); +insert into sys_job values(3, '系统默认(多参)', 'default', 'default', 'module_task.scheduler_test.job', 'new', '{test: 111}', '0/20 * * * * ?', '3', '1', '1', 'admin', current_timestamp, '', null, ''); + +-- ---------------------------- +-- 16、定时任务调度日志表 +-- ---------------------------- +drop table if exists sys_job_log; +create table sys_job_log ( + job_log_id bigserial not null, + job_name varchar(64) not null, + job_group varchar(64) not null, + job_executor varchar(64) not null, + invoke_target varchar(500) not null, + job_args varchar(255) default '', + job_kwargs varchar(255) default '', + job_trigger varchar(255) default '', + job_message varchar(500), + status char(1) default '0', + exception_info varchar(2000) default '', + create_time timestamp(0), + primary key (job_log_id) +); +comment on column sys_job_log.job_log_id is '任务日志ID'; +comment on column sys_job_log.job_name is '任务名称'; +comment on column sys_job_log.job_group is '任务组名'; +comment on column sys_job_log.job_executor is '任务执行器'; +comment on column sys_job_log.invoke_target is '调用目标字符串'; +comment on column sys_job_log.job_args is '位置参数'; +comment on column sys_job_log.job_kwargs is '关键字参数'; +comment on column sys_job_log.job_trigger is '任务触发器'; +comment on column sys_job_log.job_message is '日志信息'; +comment on column sys_job_log.status is '执行状态(0正常 1失败)'; +comment on column sys_job_log.exception_info is '异常信息'; +comment on column sys_job_log.create_time is '创建时间'; +comment on table sys_job_log is '定时任务调度日志表'; + +-- ---------------------------- +-- 17、通知公告表 +-- ---------------------------- +drop table if exists sys_notice; +create table sys_notice ( + notice_id serial not null, + notice_title varchar(50) not null, + notice_type char(1) not null, + notice_content bytea default null, + status char(1) default '0', + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(255) default null, + primary key (notice_id) +); +alter sequence sys_notice_notice_id_seq restart 10; +comment on column sys_notice.notice_id is '公告ID'; +comment on column sys_notice.notice_title is '公告标题'; +comment on column sys_notice.notice_type is '公告类型(1通知 2公告)'; +comment on column sys_notice.notice_content is '公告内容'; +comment on column sys_notice.status is '公告状态(0正常 1关闭)'; +comment on column sys_notice.create_by is '创建者'; +comment on column sys_notice.create_time is '创建时间'; +comment on column sys_notice.update_by is '更新者'; +comment on column sys_notice.update_time is '更新时间'; +comment on column sys_notice.remark is '备注'; +comment on table sys_notice is '通知公告表'; + +-- ---------------------------- +-- 初始化-公告信息表数据 +-- ---------------------------- +insert into sys_notice values(1, '温馨提醒:2018-07-01 vfadmin新版本发布啦', '2', '新版本内容', '0', 'admin', current_timestamp, '', null, '管理员'); +insert into sys_notice values(2, '维护通知:2018-07-01 vfadmin系统凌晨维护', '1', '维护内容', '0', 'admin', current_timestamp, '', null, '管理员'); + +-- ---------------------------- +-- 18、代码生成业务表 +-- ---------------------------- +drop table if exists gen_table; +create table gen_table ( + table_id bigserial not null, + table_name varchar(200) default '', + table_comment varchar(500) default '', + sub_table_name varchar(64) default null, + sub_table_fk_name varchar(64) default null, + class_name varchar(100) default '', + tpl_category varchar(200) default 'crud', + tpl_web_type varchar(30) default '', + package_name varchar(100), + module_name varchar(30), + business_name varchar(30), + function_name varchar(50), + function_author varchar(50), + gen_type char(1) default '0', + gen_path varchar(200) default '/', + options varchar(1000), + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (table_id) +); +comment on column gen_table.table_id is '编号'; +comment on column gen_table.table_name is '表名称'; +comment on column gen_table.table_comment is '表描述'; +comment on column gen_table.sub_table_name is '关联子表的表名'; +comment on column gen_table.sub_table_fk_name is '子表关联的外键名'; +comment on column gen_table.class_name is '实体类名称'; +comment on column gen_table.tpl_category is '使用的模板(crud单表操作 tree树表操作)'; +comment on column gen_table.tpl_web_type is '前端模板类型(element-ui模版 element-plus模版)'; +comment on column gen_table.package_name is '生成包路径'; +comment on column gen_table.module_name is '生成模块名'; +comment on column gen_table.business_name is '生成业务名'; +comment on column gen_table.function_name is '生成功能名'; +comment on column gen_table.function_author is '生成功能作者'; +comment on column gen_table.gen_type is '生成代码方式(0zip压缩包 1自定义路径)'; +comment on column gen_table.gen_path is '生成路径(不填默认项目路径)'; +comment on column gen_table.options is '其它生成选项'; +comment on column gen_table.create_by is '创建者'; +comment on column gen_table.create_time is '创建时间'; +comment on column gen_table.update_by is '更新者'; +comment on column gen_table.update_time is '更新时间'; +comment on column gen_table.remark is '备注'; +comment on table gen_table is '代码生成业务表'; + +-- ---------------------------- +-- 19、代码生成业务表字段 +-- ---------------------------- +drop table if exists gen_table_column; +create table gen_table_column ( + column_id bigserial not null, + table_id bigint, + column_name varchar(200), + column_comment varchar(500), + column_type varchar(100), + python_type varchar(500), + python_field varchar(200), + is_pk char(1), + is_increment char(1), + is_required char(1), + is_unique char(1), + is_insert char(1), + is_edit char(1), + is_list char(1), + is_query char(1), + query_type varchar(200) default 'EQ', + html_type varchar(200), + dict_type varchar(200) default '', + sort int4, + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + primary key (column_id) +); +comment on column gen_table_column.column_id is '编号'; +comment on column gen_table_column.table_id is '归属表编号'; +comment on column gen_table_column.column_name is '列名称'; +comment on column gen_table_column.column_comment is '列描述'; +comment on column gen_table_column.column_type is '列类型'; +comment on column gen_table_column.python_type is 'PYTHON类型'; +comment on column gen_table_column.python_field is 'PYTHON字段名'; +comment on column gen_table_column.is_pk is '是否主键(1是)'; +comment on column gen_table_column.is_increment is '是否自增(1是)'; +comment on column gen_table_column.is_required is '是否必填(1是)'; +comment on column gen_table_column.is_unique is '是否唯一(1是)'; +comment on column gen_table_column.is_insert is '是否为插入字段(1是)'; +comment on column gen_table_column.is_edit is '是否编辑字段(1是)'; +comment on column gen_table_column.is_list is '是否列表字段(1是)'; +comment on column gen_table_column.is_query is '是否查询字段(1是)'; +comment on column gen_table_column.query_type is '查询方式(等于、不等于、大于、小于、范围)'; +comment on column gen_table_column.html_type is '显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)'; +comment on column gen_table_column.dict_type is '字典类型'; +comment on column gen_table_column.sort is '排序'; +comment on column gen_table_column.create_by is '创建者'; +comment on column gen_table_column.create_time is '创建时间'; +comment on column gen_table_column.update_by is '更新者'; +comment on column gen_table_column.update_time is '更新时间'; +comment on table gen_table_column is '代码生成业务表字段'; + +-- ---------------------------- +-- 20、AI模型表 +-- ---------------------------- +drop table if exists ai_models; +create table ai_models ( + model_id bigserial not null, + model_code varchar(100) not null, + model_name varchar(100) default null, + provider varchar(50) not null, + model_sort int4 not null, + api_key varchar(255) default null, + base_url varchar(255) default null, + model_type varchar(50) default null, + max_tokens integer default null, + temperature float default null, + support_reasoning char(1) default 'N', + support_images char(1) default 'N', + status char(1) default '0', + user_id bigint, + dept_id bigint, + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (model_id) +); +comment on table ai_models is 'AI模型表'; +comment on column ai_models.model_id is '模型主键'; +comment on column ai_models.model_code is '模型编码'; +comment on column ai_models.model_name is '模型名称'; +comment on column ai_models.provider is '提供商'; +comment on column ai_models.model_sort is '显示顺序'; +comment on column ai_models.api_key is 'API Key'; +comment on column ai_models.base_url is 'Base URL'; +comment on column ai_models.model_type is '模型类型'; +comment on column ai_models.max_tokens is '最大输出token'; +comment on column ai_models.temperature is '默认温度'; +comment on column ai_models.support_reasoning is '是否支持推理'; +comment on column ai_models.support_images is '是否支持图片'; +comment on column ai_models.status is '模型状态'; +comment on column ai_models.user_id is '用户ID'; +comment on column ai_models.dept_id is '部门ID'; +comment on column ai_models.create_by is '创建者'; +comment on column ai_models.create_time is '创建时间'; +comment on column ai_models.update_by is '更新者'; +comment on column ai_models.update_time is '更新时间'; +comment on column ai_models.remark is '备注'; + +-- ---------------------------- +-- 21、AI对话配置表 +-- ---------------------------- +drop table if exists ai_chat_config; +create table ai_chat_config ( + chat_config_id bigserial not null, + user_id bigint not null unique, + temperature float default null, + add_history_to_context char(1) default '0', + num_history_runs int4 default null, + system_prompt text default null, + metrics_default_visible char(1) default '0', + vision_enabled char(1) default '1', + image_max_size_mb int4 default null, + create_time timestamp(0), + update_time timestamp(0), + primary key (chat_config_id) +); +comment on table ai_chat_config is 'AI对话配置表'; +comment on column ai_chat_config.chat_config_id is '配置主键'; +comment on column ai_chat_config.user_id is '用户ID'; +comment on column ai_chat_config.temperature is '默认温度'; +comment on column ai_chat_config.add_history_to_context is '是否添加历史记录(0是, 1否)'; +comment on column ai_chat_config.num_history_runs is '历史记录条数'; +comment on column ai_chat_config.system_prompt is '系统提示词'; +comment on column ai_chat_config.metrics_default_visible is '默认显示指标(0是, 1否)'; +comment on column ai_chat_config.vision_enabled is '是否开启视觉(0是, 1否)'; +comment on column ai_chat_config.image_max_size_mb is '图片最大大小(MB)'; +comment on column ai_chat_config.create_time is '创建时间'; +comment on column ai_chat_config.update_time is '更新时间'; + +CREATE OR REPLACE FUNCTION "find_in_set"(int8, varchar) + RETURNS "pg_catalog"."bool" AS $BODY$ +DECLARE + STR ALIAS FOR $1; + STRS ALIAS FOR $2; + POS INTEGER; + STATUS BOOLEAN; +BEGIN + SELECT POSITION( ','||STR||',' IN ','||STRS||',') INTO POS; + IF POS > 0 THEN + STATUS = TRUE; + ELSE + STATUS = FALSE; + END IF; + RETURN STATUS; +END; +$BODY$ + LANGUAGE plpgsql VOLATILE + COST 100; + +create or replace view list_column as +SELECT c.relname AS table_name, + a.attname AS column_name, + d.description AS column_comment, + CASE + WHEN a.attnotnull AND con.conname IS NULL THEN '1' + ELSE '0' + END AS is_required, + CASE + WHEN con.conname IS NOT NULL THEN '1' + ELSE '0' + END AS is_pk, + a.attnum AS sort, + CASE + WHEN "position"(pg_get_expr(ad.adbin, ad.adrelid), ((c.relname::text || '_'::text) || a.attname + ::text) || '_seq'::text) > 0 THEN '1' + ELSE '0' + END AS is_increment, + btrim( + CASE + WHEN t.typelem <> 0::oid AND t.typlen = '-1'::integer THEN 'ARRAY'::text + ELSE + CASE + WHEN t.typtype = 'd'::"char" THEN format_type(t.typbasetype, NULL::integer) + ELSE format_type(a.atttypid, NULL::integer) + END + END, '"'::text) AS column_type +FROM pg_attribute a + JOIN (pg_class c + JOIN pg_namespace n ON c.relnamespace = n.oid) ON a.attrelid = c.oid + LEFT JOIN pg_description d ON d.objoid = c.oid AND a.attnum = d.objsubid + LEFT JOIN pg_constraint con ON con.conrelid = c.oid AND (a.attnum = ANY (con.conkey)) + LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum + LEFT JOIN pg_type t ON a.atttypid = t.oid +WHERE (c.relkind = ANY (ARRAY['r'::"char", 'p'::"char"])) + AND a.attnum > 0 + AND n.nspname = 'public'::name + AND not a.attisdropped + ORDER BY c.relname, a.attnum; + +create or replace view list_table as +SELECT c.relname AS table_name, + obj_description(c.oid) AS table_comment, + CURRENT_TIMESTAMP AS create_time, + CURRENT_TIMESTAMP AS update_time +FROM pg_class c + LEFT JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE (c.relkind = ANY (ARRAY['r'::"char", 'p'::"char"])) + AND c.relname !~~ 'spatial_%'::text AND n.nspname = 'public'::name AND n.nspname <> ''::name; + +CREATE OR REPLACE FUNCTION substring_index(varchar, varchar, integer) +RETURNS varchar AS $$ +DECLARE +tokens varchar[]; +length integer ; +indexnum integer; +BEGIN +tokens := pg_catalog.string_to_array($1, $2); +length := pg_catalog.array_upper(tokens, 1); +indexnum := length - ($3 * -1) + 1; +IF $3 >= 0 THEN +RETURN pg_catalog.array_to_string(tokens[1:$3], $2); +ELSE +RETURN pg_catalog.array_to_string(tokens[indexnum:length], $2); +END IF; +END; +$$ IMMUTABLE STRICT LANGUAGE PLPGSQL; diff --git a/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql b/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql new file mode 100644 index 0000000..4295803 --- /dev/null +++ b/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql @@ -0,0 +1,813 @@ +-- ---------------------------- +-- 1、部门表 +-- ---------------------------- +drop table if exists sys_dept; +create table sys_dept ( + dept_id bigint(20) not null auto_increment comment '部门id', + parent_id bigint(20) default 0 comment '父部门id', + ancestors varchar(50) default '' comment '祖级列表', + dept_name varchar(30) default '' comment '部门名称', + order_num int(4) default 0 comment '显示顺序', + leader varchar(20) default null comment '负责人', + phone varchar(11) default null comment '联系电话', + email varchar(50) default null comment '邮箱', + status char(1) default '0' comment '部门状态(0正常 1停用)', + del_flag char(1) default '0' comment '删除标志(0代表存在 2代表删除)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + primary key (dept_id) +) engine=innodb auto_increment=200 comment = '部门表'; + +-- ---------------------------- +-- 初始化-部门表数据 +-- ---------------------------- +insert into sys_dept values(100, 0, '0', '集团总公司', 0, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(101, 100, '0,100', '深圳分公司', 1, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(102, 100, '0,100', '长沙分公司', 2, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(103, 101, '0,100,101', '研发部门', 1, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(104, 101, '0,100,101', '市场部门', 2, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(105, 101, '0,100,101', '测试部门', 3, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(106, 101, '0,100,101', '财务部门', 4, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(107, 101, '0,100,101', '运维部门', 5, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(108, 102, '0,100,102', '市场部门', 1, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); +insert into sys_dept values(109, 102, '0,100,102', '财务部门', 2, '年糕', '15888888888', 'niangao@qq.com', '0', '0', 'admin', sysdate(), '', null); + + +-- ---------------------------- +-- 2、用户信息表 +-- ---------------------------- +drop table if exists sys_user; +create table sys_user ( + user_id bigint(20) not null auto_increment comment '用户ID', + dept_id bigint(20) default null comment '部门ID', + user_name varchar(30) not null comment '用户账号', + nick_name varchar(30) not null comment '用户昵称', + user_type varchar(2) default '00' comment '用户类型(00系统用户)', + email varchar(50) default '' comment '用户邮箱', + phonenumber varchar(11) default '' comment '手机号码', + sex char(1) default '0' comment '用户性别(0男 1女 2未知)', + avatar varchar(100) default '' comment '头像地址', + password varchar(100) default '' comment '密码', + status char(1) default '0' comment '帐号状态(0正常 1停用)', + del_flag char(1) default '0' comment '删除标志(0代表存在 2代表删除)', + login_ip varchar(128) default '' comment '最后登录IP', + login_date datetime comment '最后登录时间', + pwd_update_date datetime comment '密码最后更新时间', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (user_id) +) engine=innodb auto_increment=100 comment = '用户信息表'; + +-- ---------------------------- +-- 初始化-用户信息表数据 +-- ---------------------------- +insert into sys_user values(1, 103, 'admin', '超级管理员', '00', 'niangao@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', sysdate(), sysdate(), 'admin', sysdate(), '', null, '管理员'); +insert into sys_user values(2, 105, 'niangao', '年糕', '00', 'niangao@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', sysdate(), sysdate(), 'admin', sysdate(), '', null, '测试员'); + + +-- ---------------------------- +-- 3、岗位信息表 +-- ---------------------------- +drop table if exists sys_post; +create table sys_post +( + post_id bigint(20) not null auto_increment comment '岗位ID', + post_code varchar(64) not null comment '岗位编码', + post_name varchar(50) not null comment '岗位名称', + post_sort int(4) not null comment '显示顺序', + status char(1) not null comment '状态(0正常 1停用)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (post_id) +) engine=innodb comment = '岗位信息表'; + +-- ---------------------------- +-- 初始化-岗位信息表数据 +-- ---------------------------- +insert into sys_post values(1, 'ceo', '董事长', 1, '0', 'admin', sysdate(), '', null, ''); +insert into sys_post values(2, 'se', '项目经理', 2, '0', 'admin', sysdate(), '', null, ''); +insert into sys_post values(3, 'hr', '人力资源', 3, '0', 'admin', sysdate(), '', null, ''); +insert into sys_post values(4, 'user', '普通员工', 4, '0', 'admin', sysdate(), '', null, ''); + + +-- ---------------------------- +-- 4、角色信息表 +-- ---------------------------- +drop table if exists sys_role; +create table sys_role ( + role_id bigint(20) not null auto_increment comment '角色ID', + role_name varchar(30) not null comment '角色名称', + role_key varchar(100) not null comment '角色权限字符串', + role_sort int(4) not null comment '显示顺序', + data_scope char(1) default '1' comment '数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)', + menu_check_strictly tinyint(1) default 1 comment '菜单树选择项是否关联显示', + dept_check_strictly tinyint(1) default 1 comment '部门树选择项是否关联显示', + status char(1) not null comment '角色状态(0正常 1停用)', + del_flag char(1) default '0' comment '删除标志(0代表存在 2代表删除)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (role_id) +) engine=innodb auto_increment=100 comment = '角色信息表'; + +-- ---------------------------- +-- 初始化-角色信息表数据 +-- ---------------------------- +insert into sys_role values('1', '超级管理员', 'admin', 1, 1, 1, 1, '0', '0', 'admin', sysdate(), '', null, '超级管理员'); +insert into sys_role values('2', '普通角色', 'common', 2, 2, 1, 1, '0', '0', 'admin', sysdate(), '', null, '普通角色'); + + +-- ---------------------------- +-- 5、菜单权限表 +-- ---------------------------- +drop table if exists sys_menu; +create table sys_menu ( + menu_id bigint(20) not null auto_increment comment '菜单ID', + menu_name varchar(50) not null comment '菜单名称', + parent_id bigint(20) default 0 comment '父菜单ID', + order_num int(4) default 0 comment '显示顺序', + path varchar(200) default '' comment '路由地址', + component varchar(255) default null comment '组件路径', + query varchar(255) default null comment '路由参数', + route_name varchar(50) default '' comment '路由名称', + is_frame int(1) default 1 comment '是否为外链(0是 1否)', + is_cache int(1) default 0 comment '是否缓存(0缓存 1不缓存)', + menu_type char(1) default '' comment '菜单类型(M目录 C菜单 F按钮)', + visible char(1) default 0 comment '菜单状态(0显示 1隐藏)', + status char(1) default 0 comment '菜单状态(0正常 1停用)', + perms varchar(100) default null comment '权限标识', + icon varchar(100) default '#' comment '菜单图标', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default '' comment '备注', + primary key (menu_id) +) engine=innodb auto_increment=2000 comment = '菜单权限表'; + +-- ---------------------------- +-- 初始化-菜单信息表数据 +-- ---------------------------- +-- 一级菜单 +insert into sys_menu values('1', '系统管理', '0', '1', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', sysdate(), '', null, '系统管理目录'); +insert into sys_menu values('2', '系统监控', '0', '2', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', null, '系统监控目录'); +insert into sys_menu values('3', '系统工具', '0', '3', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', null, '系统工具目录'); +insert into sys_menu values('4', 'AI 管理', '0', '4', 'ai', null, '', '', 1, 0, 'M', '0', '0', '', 'ai-manage', 'admin', sysdate(), '', null, 'AI 管理目录'); +insert into sys_menu values('99', '施尔丰仓库', '0', '99', 'https://git.yujiangrubber.cn/niuniu/ShiErFeng', null, '', '', 0, 0, 'M', '0', '0', '', 'guide', 'admin', sysdate(), '', null, '施尔丰仓库地址'); +-- 二级菜单 +insert into sys_menu values('100', '用户管理', '1', '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', sysdate(), '', null, '用户管理菜单'); +insert into sys_menu values('101', '角色管理', '1', '2', 'role', 'system/role/index', '', '', 1, 0, 'C', '0', '0', 'system:role:list', 'peoples', 'admin', sysdate(), '', null, '角色管理菜单'); +insert into sys_menu values('102', '菜单管理', '1', '3', 'menu', 'system/menu/index', '', '', 1, 0, 'C', '0', '0', 'system:menu:list', 'tree-table', 'admin', sysdate(), '', null, '菜单管理菜单'); +insert into sys_menu values('103', '部门管理', '1', '4', 'dept', 'system/dept/index', '', '', 1, 0, 'C', '0', '0', 'system:dept:list', 'tree', 'admin', sysdate(), '', null, '部门管理菜单'); +insert into sys_menu values('104', '岗位管理', '1', '5', 'post', 'system/post/index', '', '', 1, 0, 'C', '0', '0', 'system:post:list', 'post', 'admin', sysdate(), '', null, '岗位管理菜单'); +insert into sys_menu values('105', '字典管理', '1', '6', 'dict', 'system/dict/index', '', '', 1, 0, 'C', '0', '0', 'system:dict:list', 'dict', 'admin', sysdate(), '', null, '字典管理菜单'); +insert into sys_menu values('106', '参数设置', '1', '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', sysdate(), '', null, '参数设置菜单'); +insert into sys_menu values('107', '通知公告', '1', '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', sysdate(), '', null, '通知公告菜单'); +insert into sys_menu values('108', '日志管理', '1', '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', sysdate(), '', null, '日志管理菜单'); +insert into sys_menu values('109', '在线用户', '2', '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', sysdate(), '', null, '在线用户菜单'); +insert into sys_menu values('110', '定时任务', '2', '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', sysdate(), '', null, '定时任务菜单'); +insert into sys_menu values('111', '数据监控', '2', '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', sysdate(), '', null, '数据监控菜单'); +insert into sys_menu values('112', '服务监控', '2', '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', sysdate(), '', null, '服务监控菜单'); +insert into sys_menu values('113', '缓存监控', '2', '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', sysdate(), '', null, '缓存监控菜单'); +insert into sys_menu values('114', '缓存列表', '2', '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', sysdate(), '', null, '缓存列表菜单'); +insert into sys_menu values('120', '传输加密', '2', '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', sysdate(), '', null, '传输加密监控菜单'); +insert into sys_menu values('115', '表单构建', '3', '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', sysdate(), '', null, '表单构建菜单'); +insert into sys_menu values('116', '代码生成', '3', '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', sysdate(), '', null, '代码生成菜单'); +insert into sys_menu values('117', '系统接口', '3', '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', sysdate(), '', null, '系统接口菜单'); +insert into sys_menu values('118', '模型管理', '4', '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'ai-model', 'admin', sysdate(), '', null, '模型管理菜单'); +insert into sys_menu values('119', 'AI 对话', '4', '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'ai-chat', 'admin', sysdate(), '', null, 'AI 对话菜单'); +-- 三级菜单 +insert into sys_menu values('500', '操作日志', '108', '1', 'operlog', 'monitor/operlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:operlog:list', 'form', 'admin', sysdate(), '', null, '操作日志菜单'); +insert into sys_menu values('501', '登录日志', '108', '2', 'logininfor', 'monitor/logininfor/index', '', '', 1, 0, 'C', '0', '0', 'monitor:logininfor:list', 'logininfor', 'admin', sysdate(), '', null, '登录日志菜单'); +-- 用户管理按钮 +insert into sys_menu values('1000', '用户查询', '100', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1001', '用户新增', '100', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1002', '用户修改', '100', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1003', '用户删除', '100', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1004', '用户导出', '100', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1005', '用户导入', '100', '6', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1006', '重置密码', '100', '7', '', '', '', '', 1, 0, 'F', '0', '0', 'system:user:resetPwd', '#', 'admin', sysdate(), '', null, ''); +-- 角色管理按钮 +insert into sys_menu values('1007', '角色查询', '101', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1008', '角色新增', '101', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1009', '角色修改', '101', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1010', '角色删除', '101', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1011', '角色导出', '101', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'system:role:export', '#', 'admin', sysdate(), '', null, ''); +-- 菜单管理按钮 +insert into sys_menu values('1012', '菜单查询', '102', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1013', '菜单新增', '102', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1014', '菜单修改', '102', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1015', '菜单删除', '102', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:menu:remove', '#', 'admin', sysdate(), '', null, ''); +-- 部门管理按钮 +insert into sys_menu values('1016', '部门查询', '103', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1017', '部门新增', '103', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1018', '部门修改', '103', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1019', '部门删除', '103', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:dept:remove', '#', 'admin', sysdate(), '', null, ''); +-- 岗位管理按钮 +insert into sys_menu values('1020', '岗位查询', '104', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1021', '岗位新增', '104', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1022', '岗位修改', '104', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1023', '岗位删除', '104', '4', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1024', '岗位导出', '104', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'system:post:export', '#', 'admin', sysdate(), '', null, ''); +-- 字典管理按钮 +insert into sys_menu values('1025', '字典查询', '105', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1026', '字典新增', '105', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1027', '字典修改', '105', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1028', '字典删除', '105', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1029', '字典导出', '105', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:dict:export', '#', 'admin', sysdate(), '', null, ''); +-- 参数设置按钮 +insert into sys_menu values('1030', '参数查询', '106', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1031', '参数新增', '106', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1032', '参数修改', '106', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1033', '参数删除', '106', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1034', '参数导出', '106', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:config:export', '#', 'admin', sysdate(), '', null, ''); +-- 通知公告按钮 +insert into sys_menu values('1035', '公告查询', '107', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1036', '公告新增', '107', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1037', '公告修改', '107', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1038', '公告删除', '107', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:remove', '#', 'admin', sysdate(), '', null, ''); +-- 操作日志按钮 +insert into sys_menu values('1039', '操作查询', '500', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1040', '操作删除', '500', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1041', '日志导出', '500', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:export', '#', 'admin', sysdate(), '', null, ''); +-- 登录日志按钮 +insert into sys_menu values('1042', '登录查询', '501', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1043', '登录删除', '501', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1044', '日志导出', '501', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:export', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1045', '账户解锁', '501', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:logininfor:unlock', '#', 'admin', sysdate(), '', null, ''); +-- 在线用户按钮 +insert into sys_menu values('1046', '在线查询', '109', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:online:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1047', '批量强退', '109', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:online:batchLogout', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1048', '单条强退', '109', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:online:forceLogout', '#', 'admin', sysdate(), '', null, ''); +-- 定时任务按钮 +insert into sys_menu values('1049', '任务查询', '110', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1050', '任务新增', '110', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1051', '任务修改', '110', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1052', '任务删除', '110', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1053', '状态修改', '110', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:changeStatus', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1054', '任务导出', '110', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:job:export', '#', 'admin', sysdate(), '', null, ''); +-- 代码生成按钮 +insert into sys_menu values('1055', '生成查询', '116', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1056', '生成修改', '116', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1057', '生成删除', '116', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1058', '导入代码', '116', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:import', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1059', '预览代码', '116', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:preview', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1060', '生成代码', '116', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:code', '#', 'admin', sysdate(), '', null, ''); +-- 模型管理按钮 +insert into sys_menu values('1061', '模型查询', '118', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1062', '模型新增', '118', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:add', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1063', '模型修改', '118', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1064', '模型删除', '118', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:remove', '#', 'admin', sysdate(), '', null, ''); + + +-- ---------------------------- +-- 6、用户和角色关联表 用户N-1角色 +-- ---------------------------- +drop table if exists sys_user_role; +create table sys_user_role ( + user_id bigint(20) not null comment '用户ID', + role_id bigint(20) not null comment '角色ID', + primary key(user_id, role_id) +) engine=innodb comment = '用户和角色关联表'; + +-- ---------------------------- +-- 初始化-用户和角色关联表数据 +-- ---------------------------- +insert into sys_user_role values ('1', '1'); +insert into sys_user_role values ('2', '2'); + + +-- ---------------------------- +-- 7、角色和菜单关联表 角色1-N菜单 +-- ---------------------------- +drop table if exists sys_role_menu; +create table sys_role_menu ( + role_id bigint(20) not null comment '角色ID', + menu_id bigint(20) not null comment '菜单ID', + primary key(role_id, menu_id) +) engine=innodb comment = '角色和菜单关联表'; + +-- ---------------------------- +-- 初始化-角色和菜单关联表数据 +-- ---------------------------- +insert into sys_role_menu values ('2', '1'); +insert into sys_role_menu values ('2', '2'); +insert into sys_role_menu values ('2', '3'); +insert into sys_role_menu values ('2', '4'); +insert into sys_role_menu values ('2', '100'); +insert into sys_role_menu values ('2', '101'); +insert into sys_role_menu values ('2', '102'); +insert into sys_role_menu values ('2', '103'); +insert into sys_role_menu values ('2', '104'); +insert into sys_role_menu values ('2', '105'); +insert into sys_role_menu values ('2', '106'); +insert into sys_role_menu values ('2', '107'); +insert into sys_role_menu values ('2', '108'); +insert into sys_role_menu values ('2', '109'); +insert into sys_role_menu values ('2', '110'); +insert into sys_role_menu values ('2', '111'); +insert into sys_role_menu values ('2', '112'); +insert into sys_role_menu values ('2', '113'); +insert into sys_role_menu values ('2', '114'); +insert into sys_role_menu values ('2', '120'); +insert into sys_role_menu values ('2', '115'); +insert into sys_role_menu values ('2', '116'); +insert into sys_role_menu values ('2', '117'); +insert into sys_role_menu values ('2', '500'); +insert into sys_role_menu values ('2', '501'); +insert into sys_role_menu values ('2', '1000'); +insert into sys_role_menu values ('2', '1001'); +insert into sys_role_menu values ('2', '1002'); +insert into sys_role_menu values ('2', '1003'); +insert into sys_role_menu values ('2', '1004'); +insert into sys_role_menu values ('2', '1005'); +insert into sys_role_menu values ('2', '1006'); +insert into sys_role_menu values ('2', '1007'); +insert into sys_role_menu values ('2', '1008'); +insert into sys_role_menu values ('2', '1009'); +insert into sys_role_menu values ('2', '1010'); +insert into sys_role_menu values ('2', '1011'); +insert into sys_role_menu values ('2', '1012'); +insert into sys_role_menu values ('2', '1013'); +insert into sys_role_menu values ('2', '1014'); +insert into sys_role_menu values ('2', '1015'); +insert into sys_role_menu values ('2', '1016'); +insert into sys_role_menu values ('2', '1017'); +insert into sys_role_menu values ('2', '1018'); +insert into sys_role_menu values ('2', '1019'); +insert into sys_role_menu values ('2', '1020'); +insert into sys_role_menu values ('2', '1021'); +insert into sys_role_menu values ('2', '1022'); +insert into sys_role_menu values ('2', '1023'); +insert into sys_role_menu values ('2', '1024'); +insert into sys_role_menu values ('2', '1025'); +insert into sys_role_menu values ('2', '1026'); +insert into sys_role_menu values ('2', '1027'); +insert into sys_role_menu values ('2', '1028'); +insert into sys_role_menu values ('2', '1029'); +insert into sys_role_menu values ('2', '1030'); +insert into sys_role_menu values ('2', '1031'); +insert into sys_role_menu values ('2', '1032'); +insert into sys_role_menu values ('2', '1033'); +insert into sys_role_menu values ('2', '1034'); +insert into sys_role_menu values ('2', '1035'); +insert into sys_role_menu values ('2', '1036'); +insert into sys_role_menu values ('2', '1037'); +insert into sys_role_menu values ('2', '1038'); +insert into sys_role_menu values ('2', '1039'); +insert into sys_role_menu values ('2', '1040'); +insert into sys_role_menu values ('2', '1041'); +insert into sys_role_menu values ('2', '1042'); +insert into sys_role_menu values ('2', '1043'); +insert into sys_role_menu values ('2', '1044'); +insert into sys_role_menu values ('2', '1045'); +insert into sys_role_menu values ('2', '1046'); +insert into sys_role_menu values ('2', '1047'); +insert into sys_role_menu values ('2', '1048'); +insert into sys_role_menu values ('2', '1049'); +insert into sys_role_menu values ('2', '1050'); +insert into sys_role_menu values ('2', '1051'); +insert into sys_role_menu values ('2', '1052'); +insert into sys_role_menu values ('2', '1053'); +insert into sys_role_menu values ('2', '1054'); +insert into sys_role_menu values ('2', '1055'); +insert into sys_role_menu values ('2', '1056'); +insert into sys_role_menu values ('2', '1057'); +insert into sys_role_menu values ('2', '1058'); +insert into sys_role_menu values ('2', '1059'); +insert into sys_role_menu values ('2', '1060'); + +-- ---------------------------- +-- 8、角色和部门关联表 角色1-N部门 +-- ---------------------------- +drop table if exists sys_role_dept; +create table sys_role_dept ( + role_id bigint(20) not null comment '角色ID', + dept_id bigint(20) not null comment '部门ID', + primary key(role_id, dept_id) +) engine=innodb comment = '角色和部门关联表'; + +-- ---------------------------- +-- 初始化-角色和部门关联表数据 +-- ---------------------------- +insert into sys_role_dept values ('2', '100'); +insert into sys_role_dept values ('2', '101'); +insert into sys_role_dept values ('2', '105'); + + +-- ---------------------------- +-- 9、用户与岗位关联表 用户1-N岗位 +-- ---------------------------- +drop table if exists sys_user_post; +create table sys_user_post +( + user_id bigint(20) not null comment '用户ID', + post_id bigint(20) not null comment '岗位ID', + primary key (user_id, post_id) +) engine=innodb comment = '用户与岗位关联表'; + +-- ---------------------------- +-- 初始化-用户与岗位关联表数据 +-- ---------------------------- +insert into sys_user_post values ('1', '1'); +insert into sys_user_post values ('2', '2'); + + +-- ---------------------------- +-- 10、操作日志记录 +-- ---------------------------- +drop table if exists sys_oper_log; +create table sys_oper_log ( + oper_id bigint(20) not null auto_increment comment '日志主键', + title varchar(50) default '' comment '模块标题', + business_type int(2) default 0 comment '业务类型(0其它 1新增 2修改 3删除)', + method varchar(100) default '' comment '方法名称', + request_method varchar(10) default '' comment '请求方式', + operator_type int(1) default 0 comment '操作类别(0其它 1后台用户 2手机端用户)', + oper_name varchar(50) default '' comment '操作人员', + dept_name varchar(50) default '' comment '部门名称', + oper_url varchar(255) default '' comment '请求URL', + oper_ip varchar(128) default '' comment '主机地址', + oper_location varchar(255) default '' comment '操作地点', + oper_param varchar(2000) default '' comment '请求参数', + json_result varchar(2000) default '' comment '返回参数', + status int(1) default 0 comment '操作状态(0正常 1异常)', + error_msg varchar(2000) default '' comment '错误消息', + oper_time datetime comment '操作时间', + cost_time bigint(20) default 0 comment '消耗时间', + primary key (oper_id), + key idx_sys_oper_log_bt (business_type), + key idx_sys_oper_log_s (status), + key idx_sys_oper_log_ot (oper_time) +) engine=innodb auto_increment=100 comment = '操作日志记录'; + + +-- ---------------------------- +-- 11、字典类型表 +-- ---------------------------- +drop table if exists sys_dict_type; +create table sys_dict_type +( + dict_id bigint(20) not null auto_increment comment '字典主键', + dict_name varchar(100) default '' comment '字典名称', + dict_type varchar(100) default '' comment '字典类型', + status char(1) default '0' comment '状态(0正常 1停用)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (dict_id), + unique (dict_type) +) engine=innodb auto_increment=100 comment = '字典类型表'; + +insert into sys_dict_type values(1, '用户性别', 'sys_user_sex', '0', 'admin', sysdate(), '', null, '用户性别列表'); +insert into sys_dict_type values(2, '菜单状态', 'sys_show_hide', '0', 'admin', sysdate(), '', null, '菜单状态列表'); +insert into sys_dict_type values(3, '系统开关', 'sys_normal_disable', '0', 'admin', sysdate(), '', null, '系统开关列表'); +insert into sys_dict_type values(4, '任务状态', 'sys_job_status', '0', 'admin', sysdate(), '', null, '任务状态列表'); +insert into sys_dict_type values(5, '任务分组', 'sys_job_group', '0', 'admin', sysdate(), '', null, '任务分组列表'); +insert into sys_dict_type values(6, '任务执行器', 'sys_job_executor', '0', 'admin', sysdate(), '', null, '任务执行器列表'); +insert into sys_dict_type values(7, '系统是否', 'sys_yes_no', '0', 'admin', sysdate(), '', null, '系统是否列表'); +insert into sys_dict_type values(8, '通知类型', 'sys_notice_type', '0', 'admin', sysdate(), '', null, '通知类型列表'); +insert into sys_dict_type values(9, '通知状态', 'sys_notice_status', '0', 'admin', sysdate(), '', null, '通知状态列表'); +insert into sys_dict_type values(10, '操作类型', 'sys_oper_type', '0', 'admin', sysdate(), '', null, '操作类型列表'); +insert into sys_dict_type values(11, '系统状态', 'sys_common_status', '0', 'admin', sysdate(), '', null, '登录状态列表'); +insert into sys_dict_type values(12, 'AI模型提供商', 'ai_provider_type', '0', 'admin', sysdate(), '', null, 'AI模型提供商列表'); + + +-- ---------------------------- +-- 12、字典数据表 +-- ---------------------------- +drop table if exists sys_dict_data; +create table sys_dict_data +( + dict_code bigint(20) not null auto_increment comment '字典编码', + dict_sort int(4) default 0 comment '字典排序', + dict_label varchar(100) default '' comment '字典标签', + dict_value varchar(100) default '' comment '字典键值', + dict_type varchar(100) default '' comment '字典类型', + css_class varchar(100) default null comment '样式属性(其他样式扩展)', + list_class varchar(100) default null comment '表格回显样式', + is_default char(1) default 'N' comment '是否默认(Y是 N否)', + status char(1) default '0' comment '状态(0正常 1停用)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (dict_code) +) engine=innodb auto_increment=100 comment = '字典数据表'; + +insert into sys_dict_data values(1, 1, '男', '0', 'sys_user_sex', '', '', 'Y', '0', 'admin', sysdate(), '', null, '性别男'); +insert into sys_dict_data values(2, 2, '女', '1', 'sys_user_sex', '', '', 'N', '0', 'admin', sysdate(), '', null, '性别女'); +insert into sys_dict_data values(3, 3, '未知', '2', 'sys_user_sex', '', '', 'N', '0', 'admin', sysdate(), '', null, '性别未知'); +insert into sys_dict_data values(4, 1, '显示', '0', 'sys_show_hide', '', 'primary', 'Y', '0', 'admin', sysdate(), '', null, '显示菜单'); +insert into sys_dict_data values(5, 2, '隐藏', '1', 'sys_show_hide', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '隐藏菜单'); +insert into sys_dict_data values(6, 1, '正常', '0', 'sys_normal_disable', '', 'primary', 'Y', '0', 'admin', sysdate(), '', null, '正常状态'); +insert into sys_dict_data values(7, 2, '停用', '1', 'sys_normal_disable', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '停用状态'); +insert into sys_dict_data values(8, 1, '正常', '0', 'sys_job_status', '', 'primary', 'Y', '0', 'admin', sysdate(), '', null, '正常状态'); +insert into sys_dict_data values(9, 2, '暂停', '1', 'sys_job_status', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '停用状态'); +insert into sys_dict_data values(10, 1, '默认', 'default', 'sys_job_group', '', '', 'Y', '0', 'admin', sysdate(), '', null, '默认分组'); +insert into sys_dict_data values(11, 2, '数据库', 'sqlalchemy', 'sys_job_group', '', '', 'N', '0', 'admin', sysdate(), '', null, '数据库分组'); +insert into sys_dict_data values(12, 3, 'redis', 'redis', 'sys_job_group', '', '', 'N', '0', 'admin', sysdate(), '', null, 'reids分组'); +insert into sys_dict_data values(13, 1, '默认', 'default', 'sys_job_executor', '', '', 'N', '0', 'admin', sysdate(), '', null, '线程池'); +insert into sys_dict_data values(14, 2, '进程池', 'processpool', 'sys_job_executor', '', '', 'N', '0', 'admin', sysdate(), '', null, '进程池'); +insert into sys_dict_data values(15, 1, '是', 'Y', 'sys_yes_no', '', 'primary', 'Y', '0', 'admin', sysdate(), '', null, '系统默认是'); +insert into sys_dict_data values(16, 2, '否', 'N', 'sys_yes_no', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '系统默认否'); +insert into sys_dict_data values(17, 1, '通知', '1', 'sys_notice_type', '', 'warning', 'Y', '0', 'admin', sysdate(), '', null, '通知'); +insert into sys_dict_data values(18, 2, '公告', '2', 'sys_notice_type', '', 'success', 'N', '0', 'admin', sysdate(), '', null, '公告'); +insert into sys_dict_data values(19, 1, '正常', '0', 'sys_notice_status', '', 'primary', 'Y', '0', 'admin', sysdate(), '', null, '正常状态'); +insert into sys_dict_data values(20, 2, '关闭', '1', 'sys_notice_status', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '关闭状态'); +insert into sys_dict_data values(21, 99, '其他', '0', 'sys_oper_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, '其他操作'); +insert into sys_dict_data values(22, 1, '新增', '1', 'sys_oper_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, '新增操作'); +insert into sys_dict_data values(23, 2, '修改', '2', 'sys_oper_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, '修改操作'); +insert into sys_dict_data values(24, 3, '删除', '3', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '删除操作'); +insert into sys_dict_data values(25, 4, '授权', '4', 'sys_oper_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '授权操作'); +insert into sys_dict_data values(26, 5, '导出', '5', 'sys_oper_type', '', 'warning', 'N', '0', 'admin', sysdate(), '', null, '导出操作'); +insert into sys_dict_data values(27, 6, '导入', '6', 'sys_oper_type', '', 'warning', 'N', '0', 'admin', sysdate(), '', null, '导入操作'); +insert into sys_dict_data values(28, 7, '强退', '7', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '强退操作'); +insert into sys_dict_data values(29, 8, '生成代码', '8', 'sys_oper_type', '', 'warning', 'N', '0', 'admin', sysdate(), '', null, '生成操作'); +insert into sys_dict_data values(30, 9, '清空数据', '9', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '清空操作'); +insert into sys_dict_data values(31, 1, '成功', '0', 'sys_common_status', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '正常状态'); +insert into sys_dict_data values(32, 2, '失败', '1', 'sys_common_status', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '停用状态'); +insert into sys_dict_data values(33, 1, 'AIMLAPI', 'AIMLAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'AIMLAPI'); +insert into sys_dict_data values(34, 2, 'Anthropic', 'Anthropic', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Anthropic'); +insert into sys_dict_data values(35, 3, 'Cerebras', 'Cerebras', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Cerebras'); +insert into sys_dict_data values(36, 4, 'CerebrasOpenAI', 'CerebrasOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'CerebrasOpenAI'); +insert into sys_dict_data values(37, 5, 'Cohere', 'Cohere', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Cohere'); +insert into sys_dict_data values(38, 6, 'CometAPI', 'CometAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'CometAPI'); +insert into sys_dict_data values(39, 7, 'DashScope', 'DashScope', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'DashScope'); +insert into sys_dict_data values(40, 8, 'DeepInfra', 'DeepInfra', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'DeepInfra'); +insert into sys_dict_data values(41, 9, 'DeepSeek', 'DeepSeek', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'DeepSeek'); +insert into sys_dict_data values(42, 10, 'Fireworks', 'Fireworks', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Fireworks'); +insert into sys_dict_data values(43, 11, 'Google', 'Google', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Google'); +insert into sys_dict_data values(44, 12, 'Groq', 'Groq', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Groq'); +insert into sys_dict_data values(45, 13, 'HuggingFace', 'HuggingFace', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'HuggingFace'); +insert into sys_dict_data values(46, 14, 'LangDB', 'LangDB', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LangDB'); +insert into sys_dict_data values(47, 15, 'LiteLLM', 'LiteLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LiteLLM'); +insert into sys_dict_data values(48, 16, 'LiteLLMOpenAI', 'LiteLLMOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LiteLLMOpenAI'); +insert into sys_dict_data values(49, 17, 'LlamaCpp', 'LlamaCpp', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LlamaCpp'); +insert into sys_dict_data values(50, 18, 'LMStudio', 'LMStudio', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LMStudio'); +insert into sys_dict_data values(51, 19, 'Meta', 'Meta', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Meta'); +insert into sys_dict_data values(52, 20, 'Mistral', 'Mistral', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Mistral'); +insert into sys_dict_data values(53, 21, 'N1N', 'N1N', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'N1N'); +insert into sys_dict_data values(54, 22, 'Nebius', 'Nebius', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Nebius'); +insert into sys_dict_data values(55, 23, 'Nexus', 'Nexus', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Nexus'); +insert into sys_dict_data values(56, 24, 'Nvidia', 'Nvidia', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Nvidia'); +insert into sys_dict_data values(57, 25, 'Ollama', 'Ollama', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Ollama'); +insert into sys_dict_data values(58, 26, 'OpenAI', 'OpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'OpenAI'); +insert into sys_dict_data values(59, 27, 'OpenAIResponses', 'OpenAIResponses', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'OpenAIResponses'); +insert into sys_dict_data values(60, 28, 'OpenRouter', 'OpenRouter', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'OpenRouter'); +insert into sys_dict_data values(61, 29, 'Perplexity', 'Perplexity', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Perplexity'); +insert into sys_dict_data values(62, 30, 'Portkey', 'Portkey', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Portkey'); +insert into sys_dict_data values(63, 31, 'Requesty', 'Requesty', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Requesty'); +insert into sys_dict_data values(64, 32, 'Sambanova', 'Sambanova', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Sambanova'); +insert into sys_dict_data values(65, 33, 'SiliconFlow', 'SiliconFlow', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'SiliconFlow'); +insert into sys_dict_data values(66, 34, 'Together', 'Together', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Together'); +insert into sys_dict_data values(67, 35, 'Vercel', 'Vercel', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Vercel'); +insert into sys_dict_data values(68, 36, 'VLLM', 'VLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'VLLM'); +insert into sys_dict_data values(69, 37, 'xAI', 'xAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'xAI'); + + +-- ---------------------------- +-- 13、参数配置表 +-- ---------------------------- +drop table if exists sys_config; +create table sys_config ( + config_id int(5) not null auto_increment comment '参数主键', + config_name varchar(100) default '' comment '参数名称', + config_key varchar(100) default '' comment '参数键名', + config_value varchar(500) default '' comment '参数键值', + config_type char(1) default 'N' comment '系统内置(Y是 N否)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (config_id) +) engine=innodb auto_increment=100 comment = '参数配置表'; + +insert into sys_config values(1, '主框架页-默认皮肤样式名称', 'sys.index.skinName', 'skin-blue', 'Y', 'admin', sysdate(), '', null, '蓝色 skin-blue、绿色 skin-green、紫色 skin-purple、红色 skin-red、黄色 skin-yellow' ); +insert into sys_config values(2, '用户管理-账号初始密码', 'sys.user.initPassword', '123456', 'Y', 'admin', sysdate(), '', null, '初始化密码 123456' ); +insert into sys_config values(3, '主框架页-侧边栏主题', 'sys.index.sideTheme', 'theme-dark', 'Y', 'admin', sysdate(), '', null, '深色主题theme-dark,浅色主题theme-light' ); +insert into sys_config values(4, '账号自助-验证码开关', 'sys.account.captchaEnabled', 'true', 'Y', 'admin', sysdate(), '', null, '是否开启验证码功能(true开启,false关闭)'); +insert into sys_config values(5, '账号自助-是否开启用户注册功能', 'sys.account.registerUser', 'false', 'Y', 'admin', sysdate(), '', null, '是否开启注册用户功能(true开启,false关闭)'); +insert into sys_config values(6, '用户登录-黑名单列表', 'sys.login.blackIPList', '', 'Y', 'admin', sysdate(), '', null, '设置登录IP黑名单限制,多个匹配项以;分隔,支持匹配(*通配、网段)'); +insert into sys_config values(7, '用户管理-初始密码修改策略', 'sys.account.initPasswordModify', '1', 'Y', 'admin', sysdate(), '', null, '0:初始密码修改策略关闭,没有任何提示,1:提醒用户,如果未修改初始密码,则在登录时就会提醒修改密码对话框'); +insert into sys_config values(8, '用户管理-账号密码更新周期', 'sys.account.passwordValidateDays', '0', 'Y', 'admin', sysdate(), '', null, '密码更新周期(填写数字,数据初始化值为0不限制,若修改必须为大于0小于365的正整数),如果超过这个周期登录系统时,则在登录时就会提醒修改密码对话框'); + + +-- ---------------------------- +-- 14、系统访问记录 +-- ---------------------------- +drop table if exists sys_logininfor; +create table sys_logininfor ( + info_id bigint(20) not null auto_increment comment '访问ID', + user_name varchar(50) default '' comment '用户账号', + ipaddr varchar(128) default '' comment '登录IP地址', + login_location varchar(255) default '' comment '登录地点', + browser varchar(50) default '' comment '浏览器类型', + os varchar(50) default '' comment '操作系统', + status char(1) default '0' comment '登录状态(0成功 1失败)', + msg varchar(255) default '' comment '提示消息', + login_time datetime comment '访问时间', + primary key (info_id), + key idx_sys_logininfor_s (status), + key idx_sys_logininfor_lt (login_time) +) engine=innodb auto_increment=100 comment = '系统访问记录'; + + +-- ---------------------------- +-- 15、定时任务调度表 +-- ---------------------------- +drop table if exists sys_job; +create table sys_job ( + job_id bigint(20) not null auto_increment comment '任务ID', + job_name varchar(64) default '' comment '任务名称', + job_group varchar(64) default 'default' comment '任务组名', + job_executor varchar(64) default 'default' comment '任务执行器', + invoke_target varchar(500) not null comment '调用目标字符串', + job_args varchar(255) default '' comment '位置参数', + job_kwargs varchar(255) default '' comment '关键字参数', + cron_expression varchar(255) default '' comment 'cron执行表达式', + misfire_policy varchar(20) default '3' comment '计划执行错误策略(1立即执行 2执行一次 3放弃执行)', + concurrent char(1) default '1' comment '是否并发执行(0允许 1禁止)', + status char(1) default '0' comment '状态(0正常 1暂停)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default '' comment '备注信息', + primary key (job_id, job_name, job_group) +) engine=innodb auto_increment=100 comment = '定时任务调度表'; + +insert into sys_job values(1, '系统默认(无参)', 'default', 'default', 'module_task.scheduler_test.job', NULL, NULL, '0/10 * * * * ?', '3', '1', '1', 'admin', sysdate(), '', null, ''); +insert into sys_job values(2, '系统默认(有参)', 'default', 'default', 'module_task.scheduler_test.job', 'test', NULL, '0/15 * * * * ?', '3', '1', '1', 'admin', sysdate(), '', null, ''); +insert into sys_job values(3, '系统默认(多参)', 'default', 'default', 'module_task.scheduler_test.job', 'new', '{\"test\": 111}', '0/20 * * * * ?', '3', '1', '1', 'admin', sysdate(), '', null, ''); + + +-- ---------------------------- +-- 16、定时任务调度日志表 +-- ---------------------------- +drop table if exists sys_job_log; +create table sys_job_log ( + job_log_id bigint(20) not null auto_increment comment '任务日志ID', + job_name varchar(64) not null comment '任务名称', + job_group varchar(64) not null comment '任务组名', + job_executor varchar(64) not null comment '任务执行器', + invoke_target varchar(500) not null comment '调用目标字符串', + job_args varchar(255) default '' comment '位置参数', + job_kwargs varchar(255) default '' comment '关键字参数', + job_trigger varchar(255) default '' comment '任务触发器', + job_message varchar(500) comment '日志信息', + status char(1) default '0' comment '执行状态(0正常 1失败)', + exception_info varchar(2000) default '' comment '异常信息', + create_time datetime comment '创建时间', + primary key (job_log_id) +) engine=innodb comment = '定时任务调度日志表'; + + +-- ---------------------------- +-- 17、通知公告表 +-- ---------------------------- +drop table if exists sys_notice; +create table sys_notice ( + notice_id int(4) not null auto_increment comment '公告ID', + notice_title varchar(50) not null comment '公告标题', + notice_type char(1) not null comment '公告类型(1通知 2公告)', + notice_content longblob default null comment '公告内容', + status char(1) default '0' comment '公告状态(0正常 1关闭)', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(255) default null comment '备注', + primary key (notice_id) +) engine=innodb auto_increment=10 comment = '通知公告表'; + +-- ---------------------------- +-- 初始化-公告信息表数据 +-- ---------------------------- +insert into sys_notice values('1', '温馨提醒:2018-07-01 vfadmin新版本发布啦', '2', '新版本内容', '0', 'admin', sysdate(), '', null, '管理员'); +insert into sys_notice values('2', '维护通知:2018-07-01 vfadmin系统凌晨维护', '1', '维护内容', '0', 'admin', sysdate(), '', null, '管理员'); + + +-- ---------------------------- +-- 18、代码生成业务表 +-- ---------------------------- +drop table if exists gen_table; +create table gen_table ( + table_id bigint(20) not null auto_increment comment '编号', + table_name varchar(200) default '' comment '表名称', + table_comment varchar(500) default '' comment '表描述', + sub_table_name varchar(64) default null comment '关联子表的表名', + sub_table_fk_name varchar(64) default null comment '子表关联的外键名', + class_name varchar(100) default '' comment '实体类名称', + tpl_category varchar(200) default 'crud' comment '使用的模板(crud单表操作 tree树表操作)', + tpl_web_type varchar(30) default '' comment '前端模板类型(element-ui模版 element-plus模版)', + package_name varchar(100) comment '生成包路径', + module_name varchar(30) comment '生成模块名', + business_name varchar(30) comment '生成业务名', + function_name varchar(50) comment '生成功能名', + function_author varchar(50) comment '生成功能作者', + gen_type char(1) default '0' comment '生成代码方式(0zip压缩包 1自定义路径)', + gen_path varchar(200) default '/' comment '生成路径(不填默认项目路径)', + options varchar(1000) comment '其它生成选项', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (table_id) +) engine=innodb auto_increment=1 comment = '代码生成业务表'; + + +-- ---------------------------- +-- 19、代码生成业务表字段 +-- ---------------------------- +drop table if exists gen_table_column; +create table gen_table_column ( + column_id bigint(20) not null auto_increment comment '编号', + table_id bigint(20) comment '归属表编号', + column_name varchar(200) comment '列名称', + column_comment varchar(500) comment '列描述', + column_type varchar(100) comment '列类型', + python_type varchar(500) comment 'PYTHON类型', + python_field varchar(200) comment 'PYTHON字段名', + is_pk char(1) comment '是否主键(1是)', + is_increment char(1) comment '是否自增(1是)', + is_required char(1) comment '是否必填(1是)', + is_unique char(1) comment '是否唯一(1是)', + is_insert char(1) comment '是否为插入字段(1是)', + is_edit char(1) comment '是否编辑字段(1是)', + is_list char(1) comment '是否列表字段(1是)', + is_query char(1) comment '是否查询字段(1是)', + query_type varchar(200) default 'EQ' comment '查询方式(等于、不等于、大于、小于、范围)', + html_type varchar(200) comment '显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)', + dict_type varchar(200) default '' comment '字典类型', + sort int comment '排序', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + primary key (column_id) +) engine=innodb auto_increment=1 comment = '代码生成业务表字段'; + + +-- ---------------------------- +-- 20、AI模型表 +-- ---------------------------- +drop table if exists ai_models; +create table ai_models ( + model_id bigint(20) not null auto_increment comment '模型主键', + model_code varchar(100) not null comment '模型编码', + model_name varchar(100) default null comment '模型名称', + provider varchar(50) not null comment '提供商', + model_sort int(4) not null comment '显示顺序', + api_key varchar(255) default null comment 'API Key', + base_url varchar(255) default null comment 'Base URL', + model_type varchar(50) default null comment '模型类型', + max_tokens int(11) default null comment '最大输出token', + temperature float default null comment '默认温度', + support_reasoning char(1) default 'N' comment '是否支持推理', + support_images char(1) default 'N' comment '是否支持图片', + status char(1) default '0' comment '模型状态', + user_id bigint(20) comment '用户ID', + dept_id bigint(20) comment '部门ID', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (model_id) +) engine=innodb auto_increment=1 comment = 'AI模型表'; + + +-- ---------------------------- +-- 21、AI对话配置表 +-- ---------------------------- +drop table if exists ai_chat_config; +create table ai_chat_config ( + chat_config_id bigint(20) not null auto_increment comment '配置主键', + user_id bigint(20) not null unique comment '用户ID', + temperature float default null comment '默认温度', + add_history_to_context char(1) default '0' comment '是否添加历史记录(0是, 1否)', + num_history_runs int(4) default null comment '历史记录条数', + system_prompt text default null comment '系统提示词', + metrics_default_visible char(1) default '0' comment '默认显示指标(0是, 1否)', + vision_enabled char(1) default '1' comment '是否开启视觉(0是, 1否)', + image_max_size_mb int(4) default null comment '图片最大大小(MB)', + create_time datetime comment '创建时间', + update_time datetime comment '更新时间', + primary key (chat_config_id) +) engine=innodb auto_increment=1 comment = 'AI对话配置表'; diff --git a/shierfeng-fastapi-backend/sub_applications/handle.py b/shierfeng-fastapi-backend/sub_applications/handle.py new file mode 100644 index 0000000..325193e --- /dev/null +++ b/shierfeng-fastapi-backend/sub_applications/handle.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI + +from sub_applications.staticfiles import mount_staticfiles + + +def handle_sub_applications(app: FastAPI) -> None: + """ + 全局处理子应用挂载 + """ + # 挂载静态文件 + mount_staticfiles(app) diff --git a/shierfeng-fastapi-backend/sub_applications/staticfiles.py b/shierfeng-fastapi-backend/sub_applications/staticfiles.py new file mode 100644 index 0000000..5e5d4e5 --- /dev/null +++ b/shierfeng-fastapi-backend/sub_applications/staticfiles.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from config.env import UploadConfig + + +def mount_staticfiles(app: FastAPI) -> None: + """ + 挂载静态文件 + """ + app.mount(f'{UploadConfig.UPLOAD_PREFIX}', StaticFiles(directory=f'{UploadConfig.UPLOAD_PATH}'), name='profile') diff --git a/shierfeng-fastapi-backend/tests/cli/completion/test_installers.py b/shierfeng-fastapi-backend/tests/cli/completion/test_installers.py new file mode 100644 index 0000000..f0dd607 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/completion/test_installers.py @@ -0,0 +1,112 @@ +from pathlib import Path + +import typer +from pytest import MonkeyPatch + +from cli.completion.installers import ( + CompletionInstallerService, + CompletionInstallerShellSupport, + CompletionShellRuntimePolicy, + CompletionShellRuntimePolicyRegistry, +) + + +def test_build_source_command_uses_shell_runtime_policy() -> None: + """ + 校验安装服务会按 shell 运行时策略构建 source 命令。 + + :return: None + """ + installer = CompletionInstallerService() + target_file = Path('/tmp/shierfeng-completion') + + assert installer.build_source_command(target_file, 'bash') == 'source /tmp/shierfeng-completion' + assert installer.build_source_command(target_file, 'zsh') == 'source /tmp/shierfeng-completion' + assert ( + installer.build_source_command(target_file, 'fish') + == 'status --is-interactive; and source /tmp/shierfeng-completion' + ) + assert installer.build_source_command(target_file, 'powershell') == '. "/tmp/shierfeng-completion"' + + +def test_make_bash_completion_script_compatible_wraps_legacy_instructions() -> None: + """ + 校验 Bash 兼容转换会包装旧版 shell 不支持的指令。 + + :return: None + """ + script_text = 'compopt -o dirnames\ncompopt -o default\ncomplete -o nosort -F _shierfeng_completion shierfeng' + + compatible_script = CompletionInstallerShellSupport.make_bash_completion_script_compatible(script_text) + + assert 'if command -v compopt >/dev/null 2>&1; then' in compatible_script + assert 'complete -F _shierfeng_completion shierfeng' in compatible_script + assert 'complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null' in compatible_script + + +def test_render_completion_script_uses_runtime_policy_transformer(monkeypatch: MonkeyPatch) -> None: + """ + 校验脚本渲染会通过 shell 运行时策略执行脚本后处理。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class DummyComplete: + """ + 模拟 Click completion 生成器。 + """ + + def __init__(self, *args, **kwargs) -> None: + """ + 初始化模拟生成器。 + + :param args: 位置参数 + :param kwargs: 关键字参数 + :return: None + """ + + @staticmethod + def source() -> str: + """ + 返回原始脚本文本。 + + :return: 原始脚本文本 + """ + return 'raw-script' + + runtime_policy_registry = CompletionShellRuntimePolicyRegistry( + policies={ + 'bash': CompletionShellRuntimePolicy( + name='bash', + click_completion_class=DummyComplete, + script_transformer=lambda script_text: f'transformed::{script_text}', + source_command_builder=lambda target_file: f'source {target_file}', + ) + } + ) + installer = CompletionInstallerService(shell_runtime_policy_registry=runtime_policy_registry) + monkeypatch.setattr(installer, 'build_completion_click_command', lambda root_cli: object()) + + rendered_script = installer.render_completion_script(typer.Typer(), 'bash') + + assert rendered_script == 'transformed::raw-script' + + +def test_render_powershell_completion_script_contains_native_completer() -> None: + """ + 校验 PowerShell completion 脚本会注册原生命令补全器。 + + :return: None + """ + installer = CompletionInstallerService() + root_cli = typer.Typer() + + @root_cli.command() + def demo() -> None: + return None + + rendered_script = installer.render_completion_script(root_cli, 'powershell') + + assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in rendered_script + assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in rendered_script diff --git a/shierfeng-fastapi-backend/tests/cli/completion/test_providers.py b/shierfeng-fastapi-backend/tests/cli/completion/test_providers.py new file mode 100644 index 0000000..32d8c71 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/completion/test_providers.py @@ -0,0 +1,442 @@ +import importlib +import os +import sys +from pathlib import Path + +from pytest import MonkeyPatch + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +sys.path.insert(0, str(BACKEND_DIR)) +sys.modules.pop('cli.completion.providers', None) +sys.modules.pop('cli', None) + +completion_support = importlib.import_module('cli.completion.providers') +completion_gateway = completion_support.COMPLETION_PROVIDER_GATEWAY +completion_registry = completion_gateway.provider_registry + + +def test_complete_alembic_revisions_returns_default_choices_and_local_revisions( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + """ + 校验 Alembic 版本补全会返回默认候选和本地迁移版本。 + + :param tmp_path: pytest 临时目录 + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + versions_dir = tmp_path / 'alembic' / 'versions' + versions_dir.mkdir(parents=True) + (versions_dir / '2026_04_29_1000-abc123_add_demo_table.py').write_text('# test', encoding='utf-8') + + monkeypatch.chdir(tmp_path) + + candidates = completion_gateway.complete_alembic_revisions(None, None, 'a') + default_candidates = completion_gateway.complete_alembic_revisions(None, None, '') + + assert 'abc123' in candidates + assert 'head' in default_candidates + assert 'base' in default_candidates + assert 'current' in default_candidates + assert '-1' in default_candidates + + +def test_complete_sql_files_returns_project_relative_sql_paths( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + """ + 校验 SQL 文件补全会返回项目相对路径。 + + :param tmp_path: pytest 临时目录 + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + sql_dir = tmp_path / 'sql' + sql_dir.mkdir() + (sql_dir / 'demo.sql').write_text('select 1;', encoding='utf-8') + + monkeypatch.chdir(tmp_path) + + candidates = completion_gateway.complete_sql_files(None, None, 'sql/d') + + assert candidates == ['sql/demo.sql'] + + +def test_complete_cache_names_returns_static_cache_name_choices(monkeypatch: MonkeyPatch) -> None: + """ + 校验缓存名称补全会返回系统内置缓存名称。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class _FakeCacheKeyConfig: + """ + 模拟缓存键配置对象。 + """ + + def __init__(self, key: str) -> None: + """ + 初始化模拟缓存键配置对象。 + + :param key: 缓存名称 + :return: None + """ + self.key = key + + class _FakeEnumsModule: + """ + 模拟枚举模块。 + """ + + RedisInitKeyConfig = [_FakeCacheKeyConfig('sys_config'), _FakeCacheKeyConfig('login_tokens')] + + monkeypatch.setattr( + completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeEnumsModule + ) + + candidates = completion_gateway.complete_cache_names(None, None, 'sys') + + assert candidates == ['sys_config'] + + +def test_complete_output_paths_returns_directories_and_zip_files( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + """ + 校验导出路径补全会返回目录和 zip 文件。 + + :param tmp_path: pytest 临时目录 + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + build_dir = tmp_path / 'build' + build_dir.mkdir() + (build_dir / 'gen.zip').write_text('zip', encoding='utf-8') + (build_dir / 'notes.txt').write_text('ignore', encoding='utf-8') + + monkeypatch.chdir(tmp_path) + + candidates = completion_gateway.complete_output_paths(None, None, 'build/') + + assert 'build/gen.zip' in candidates + assert 'build/notes.txt' not in candidates + + +def test_complete_config_keys_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None: + """ + 校验参数键名补全会返回动态只读查询结果。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class _FakeConfigRuntime: + """ + 模拟配置运行时模块。 + """ + + class ConfigRuntime: + """ + 模拟配置运行时服务对象。 + """ + + @staticmethod + def list_configs(**kwargs: str) -> dict: + """ + 模拟配置列表查询入口。 + + :param kwargs: 查询参数 + :return: 伪造的结果字典 + """ + return kwargs + + CONFIG_RUNTIME = ConfigRuntime() + + monkeypatch.setattr( + completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeConfigRuntime + ) + monkeypatch.setattr( + completion_registry.dynamic_service, + 'run_completion_coroutine', + lambda coroutine, *, env: { + 'ok': True, + 'items': [ + {'configKey': 'sys.user.initPassword'}, + {'configKey': 'sys.user.maxRetryCount'}, + ], + }, + ) + + candidates = completion_gateway.complete_config_keys(None, None, 'sys.user.i') + + assert candidates == ['sys.user.initPassword'] + + +def test_complete_gen_table_names_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None: + """ + 校验代码生成业务表补全会返回动态只读查询结果。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class _FakeGenRuntime: + """ + 模拟代码生成运行时模块。 + """ + + class GenRuntime: + """ + 模拟代码生成运行时服务对象。 + """ + + @staticmethod + def list_gen_tables(**kwargs: str) -> dict: + """ + 模拟业务表列表查询入口。 + + :param kwargs: 查询参数 + :return: 伪造的结果字典 + """ + return kwargs + + @staticmethod + def list_gen_db_tables(**kwargs: str) -> dict: + """ + 模拟数据库物理表列表查询入口。 + + :param kwargs: 查询参数 + :return: 伪造的结果字典 + """ + return kwargs + + GEN_RUNTIME = GenRuntime() + + monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeGenRuntime) + monkeypatch.setattr( + completion_registry.dynamic_service, + 'run_completion_coroutine', + lambda coroutine, *, env: { + 'ok': True, + 'items': [ + {'tableName': 'sys_user'}, + {'tableName': 'sys_role'}, + ], + }, + ) + + candidates = completion_gateway.complete_gen_table_names(None, None, 'sys_u') + + assert candidates == ['sys_user'] + + +def test_dynamic_completion_returns_empty_list_when_runtime_fails(monkeypatch: MonkeyPatch) -> None: + """ + 校验动态补全在运行时失败时会优雅降级为空列表。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + monkeypatch.setattr( + completion_registry.dynamic_service, + 'load_runtime_module', + lambda module_name: (_ for _ in ()).throw(RuntimeError('boom')), + ) + + candidates = completion_gateway.complete_gen_db_table_names(None, None, 'sys') + + assert candidates == [] + + +def test_complete_cache_keys_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None: + """ + 校验缓存键名补全会基于缓存名称返回动态只读查询结果。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class _FakeCacheRuntime: + """ + 模拟缓存运行时模块。 + """ + + class CacheRuntime: + """ + 模拟缓存运行时服务对象。 + """ + + @staticmethod + def list_cache_keys(cache_name: str) -> dict[str, str]: + """ + 模拟缓存键列表查询入口。 + + :param cache_name: 缓存名称 + :return: 伪造的参数字典 + """ + return {'cache_name': cache_name} + + CACHE_RUNTIME = CacheRuntime() + + class _FakeContext: + """ + 模拟 Click 上下文。 + """ + + params = {'cache_name': 'sys_config'} + + monkeypatch.setattr( + completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeCacheRuntime + ) + monkeypatch.setattr( + completion_registry.dynamic_service, + 'run_completion_coroutine', + lambda coroutine, *, env: { + 'ok': True, + 'keys': ['site.name', 'site.logo'], + }, + ) + + candidates = completion_gateway.complete_cache_keys(_FakeContext(), None, 'site.n') + + assert candidates == ['site.name'] + + +def test_complete_job_names_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None: + """ + 校验任务名称补全会返回动态只读查询结果。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class _FakeJobRuntime: + """ + 模拟任务运行时模块。 + """ + + class JobRuntime: + """ + 模拟任务运行时服务对象。 + """ + + @staticmethod + def list_jobs(**kwargs: str) -> dict: + """ + 模拟任务列表查询入口。 + + :param kwargs: 查询参数 + :return: 伪造的参数字典 + """ + return kwargs + + JOB_RUNTIME = JobRuntime() + + monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeJobRuntime) + monkeypatch.setattr( + completion_registry.dynamic_service, + 'run_completion_coroutine', + lambda coroutine, *, env: { + 'ok': True, + 'items': [ + {'jobName': '同步任务'}, + {'jobName': '缓存预热'}, + ], + }, + ) + + candidates = completion_gateway.complete_job_names(None, None, '同') + + assert candidates == ['同步任务'] + + +def test_complete_job_ids_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None: + """ + 校验任务 ID 补全会返回动态只读查询结果。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class _FakeJobRuntime: + """ + 模拟任务运行时模块。 + """ + + class JobRuntime: + """ + 模拟任务运行时服务对象。 + """ + + @staticmethod + def list_jobs(**kwargs: str) -> dict: + """ + 模拟任务列表查询入口。 + + :param kwargs: 查询参数 + :return: 伪造的参数字典 + """ + return kwargs + + JOB_RUNTIME = JobRuntime() + + monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeJobRuntime) + monkeypatch.setattr( + completion_registry.dynamic_service, + 'run_completion_coroutine', + lambda coroutine, *, env: { + 'ok': True, + 'items': [ + {'jobId': 101}, + {'jobId': 202}, + ], + }, + ) + + candidates = completion_gateway.complete_job_ids(None, None, '1') + + assert candidates == ['101'] + + +def test_run_completion_coroutine_scopes_app_env(monkeypatch: MonkeyPatch) -> None: + """ + 校验动态补全执行时会临时注入 APP_ENV,并在结束后恢复原值。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + monkeypatch.setenv('APP_ENV', 'prod') + captured: dict[str, str | None] = {} + + async def _read_env() -> str | None: + captured['during'] = os.environ.get('APP_ENV') + return captured['during'] + + result = completion_registry.dynamic_service.run_completion_coroutine(_read_env(), env='dev') + + assert result == 'dev' + assert captured['during'] == 'dev' + assert os.environ.get('APP_ENV') == 'prod' + + +def test_run_completion_coroutine_clears_scoped_app_env_when_original_missing(monkeypatch: MonkeyPatch) -> None: + """ + 校验动态补全执行结束后会移除临时注入的 APP_ENV。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + monkeypatch.delenv('APP_ENV', raising=False) + + async def _read_env() -> str | None: + return os.environ.get('APP_ENV') + + result = completion_registry.dynamic_service.run_completion_coroutine(_read_env(), env='dockerpg') + + assert result == 'dockerpg' + assert 'APP_ENV' not in os.environ diff --git a/shierfeng-fastapi-backend/tests/cli/core/test_execution_service.py b/shierfeng-fastapi-backend/tests/cli/core/test_execution_service.py new file mode 100644 index 0000000..802c3c6 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/core/test_execution_service.py @@ -0,0 +1,74 @@ +import importlib +import sys +from pathlib import Path + +from cli.exit_codes import RUNTIME_ERROR + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +sys.path.insert(0, str(BACKEND_DIR)) +sys.modules.pop('cli.core.execution', None) +sys.modules.pop('cli', None) + +cli_execution = importlib.import_module('cli.core.execution') +EXPLICIT_EXIT_CODE = 23 +DEFAULT_EXIT_CODE = 7 + + +def test_build_result_does_not_mutate_payload_and_removes_exit_code_from_result_data() -> None: + """ + 校验结果翻译不会修改调用方 payload,且会从结果数据中移除 exit_code 字段。 + + :return: None + """ + payload = {'ok': True, 'message': 'done', 'exit_code': EXPLICIT_EXIT_CODE} + + result = cli_execution.CliExecutionService.build_result(payload) + + assert result.exit_code == EXPLICIT_EXIT_CODE + assert result.data == {'ok': True, 'message': 'done'} + assert payload == {'ok': True, 'message': 'done', 'exit_code': EXPLICIT_EXIT_CODE} + + +def test_build_result_uses_runtime_error_when_failed_payload_has_no_explicit_exit_code() -> None: + """ + 校验失败 payload 未显式提供退出码时,会回退到统一运行时错误码。 + + :return: None + """ + payload = {'ok': False, 'message': 'failed'} + + result = cli_execution.CliExecutionService.build_result(payload) + + assert result.exit_code == RUNTIME_ERROR + assert result.data == {'ok': False, 'message': 'failed'} + assert payload == {'ok': False, 'message': 'failed'} + + +def test_build_result_uses_default_exit_code_without_mutating_payload() -> None: + """ + 校验显式默认退出码会参与结果翻译,且不会修改原始 payload。 + + :return: None + """ + payload = {'ok': True, 'message': 'warn'} + + result = cli_execution.CliExecutionService.build_result(payload, default_exit_code=DEFAULT_EXIT_CODE) + + assert result.exit_code == DEFAULT_EXIT_CODE + assert result.data == {'ok': True, 'message': 'warn'} + assert payload == {'ok': True, 'message': 'warn'} + + +def test_build_result_uses_runtime_error_when_failed_payload_keeps_default_zero() -> None: + """ + 校验失败 payload 在未显式提供退出码且默认退出码为 0 时,会回退到统一运行时错误码。 + + :return: None + """ + payload = {'ok': False, 'message': 'failed'} + + result = cli_execution.CliExecutionService.build_result(payload, default_exit_code=0) + + assert result.exit_code == RUNTIME_ERROR + assert result.data == {'ok': False, 'message': 'failed'} diff --git a/shierfeng-fastapi-backend/tests/cli/root/conftest.py b/shierfeng-fastapi-backend/tests/cli/root/conftest.py new file mode 100644 index 0000000..068b9f0 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/conftest.py @@ -0,0 +1,93 @@ +import json +import os +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + + +@pytest.fixture +def backend_dir() -> Path: + return Path(__file__).resolve().parents[3] + + +@pytest.fixture +def run_cli_command(backend_dir: Path) -> Callable[..., subprocess.CompletedProcess[str]]: + def _run_cli_command(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, '-m', 'cli.main', *args], + cwd=backend_dir, + capture_output=True, + text=True, + check=False, + ) + + return _run_cli_command + + +@pytest.fixture +def run_text_cli_command( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> Callable[..., subprocess.CompletedProcess[str]]: + def _run_text_cli_command(*args: str) -> subprocess.CompletedProcess[str]: + return run_cli_command('--color=never', '--icon=none', *args) + + return _run_text_cli_command + + +@pytest.fixture +def run_cli_completion_command( + backend_dir: Path, +) -> Callable[..., subprocess.CompletedProcess[str]]: + def _run_cli_completion_command( + *, + comp_words: str, + comp_cword: int | str, + instruction: str = 'bash_complete', + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, '-m', 'cli.main'], + cwd=backend_dir, + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + 'COMP_WORDS': comp_words, + 'COMP_CWORD': str(comp_cword), + '_SHIERFENG_COMPLETE': instruction, + }, + ) + + return _run_cli_completion_command + + +@pytest.fixture +def parse_json_stdout() -> Callable[[subprocess.CompletedProcess[str]], dict[str, Any]]: + def _parse_json_stdout(completed: subprocess.CompletedProcess[str]) -> dict[str, Any]: + return json.loads(completed.stdout) + + return _parse_json_stdout + + +@pytest.fixture +def assert_check_payload_contract() -> Callable[[dict[str, Any], bool], None]: + def _assert_check_payload_contract(payload: dict[str, Any], allow_exit_code: bool) -> None: + assert isinstance(payload, dict) + assert isinstance(payload.get('ok'), bool) + assert isinstance(payload.get('message'), str) + + if payload['ok']: + if 'error' in payload: + assert isinstance(payload['error'], str) + if 'exit_code' in payload: + assert isinstance(payload['exit_code'], int) + else: + assert isinstance(payload.get('error'), str) + if allow_exit_code: + assert isinstance(payload.get('exit_code'), int) + + return _assert_check_payload_contract diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_bootstrap.py b/shierfeng-fastapi-backend/tests/cli/root/test_bootstrap.py new file mode 100644 index 0000000..96559d5 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_bootstrap.py @@ -0,0 +1,184 @@ +import json +import subprocess +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parents[3] + + +def test_bootstrap_import_does_not_eagerly_load_heavy_runtime_modules() -> None: + """ + 校验导入 `cli.bootstrap` 时不会提前加载重依赖模块。 + + :return: None + """ + script = """ +import json +import sys + +sys.path.insert(0, '.') +for module_name in [ + 'cli.bootstrap', + 'config.database', + 'config.get_redis', + 'config.get_scheduler', + 'module_admin.service.server_service', + 'utils.transport_crypto_util', +]: + sys.modules.pop(module_name, None) + +import cli.bootstrap + +print(json.dumps({ + 'config.database': 'config.database' in sys.modules, + 'config.get_redis': 'config.get_redis' in sys.modules, + 'config.get_scheduler': 'config.get_scheduler' in sys.modules, + 'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules, + 'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules, +}, ensure_ascii=False)) +""" + completed = subprocess.run( + [sys.executable, '-c', script], + cwd=BACKEND_DIR, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + payload = json.loads(completed.stdout) + + assert payload == { + 'config.database': False, + 'config.get_redis': False, + 'config.get_scheduler': False, + 'module_admin.service.server_service': False, + 'utils.transport_crypto_util': False, + } + + +def test_build_cli_does_not_eagerly_load_heavy_runtime_dependencies() -> None: + """ + 校验构建 CLI 根应用时不会提前加载 DB、Redis、Scheduler 和业务服务依赖。 + + :return: None + """ + script = """ +import json +import sys + +sys.path.insert(0, '.') +for module_name in [ + 'cli.main', + 'config.env', + 'config.database', + 'config.get_redis', + 'config.get_scheduler', + 'cli.tui.app', + 'module_admin.service.server_service', + 'module_admin.service.job_service', + 'module_admin.service.config_service', + 'module_generator.service.gen_service', + 'textual', + 'utils.transport_crypto_util', +]: + sys.modules.pop(module_name, None) + +import cli.main + +cli.main.CLI_APPLICATION_BUILDER.build() + +print(json.dumps({ + 'config.env': 'config.env' in sys.modules, + 'config.database': 'config.database' in sys.modules, + 'config.get_redis': 'config.get_redis' in sys.modules, + 'config.get_scheduler': 'config.get_scheduler' in sys.modules, + 'cli.tui.app': 'cli.tui.app' in sys.modules, + 'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules, + 'module_admin.service.job_service': 'module_admin.service.job_service' in sys.modules, + 'module_admin.service.config_service': 'module_admin.service.config_service' in sys.modules, + 'module_generator.service.gen_service': 'module_generator.service.gen_service' in sys.modules, + 'textual': 'textual' in sys.modules, + 'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules, +}, ensure_ascii=False)) +""" + completed = subprocess.run( + [sys.executable, '-c', script], + cwd=BACKEND_DIR, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + payload = json.loads(completed.stdout) + + assert payload == { + 'config.env': False, + 'config.database': False, + 'config.get_redis': False, + 'config.get_scheduler': False, + 'cli.tui.app': False, + 'module_admin.service.server_service': False, + 'module_admin.service.job_service': False, + 'module_admin.service.config_service': False, + 'module_generator.service.gen_service': False, + 'textual': False, + 'utils.transport_crypto_util': False, + } + + +def test_build_wizard_app_does_not_eagerly_load_flow_modules() -> None: + """ + 校验构建 `wizard` 子应用时不会提前加载各个 flow 模块。 + + :return: None + """ + script = """ +import json +import sys + +sys.path.insert(0, '.') +for module_name in [ + 'cli.wizard.commands', + 'cli.wizard.flows.app_run', + 'cli.wizard.flows.db_upgrade', + 'cli.wizard.flows.cache_clear', + 'cli.wizard.flows.gen_export', + 'cli.wizard.flows.gen_import', + 'cli.wizard.flows.prod_check', +]: + sys.modules.pop(module_name, None) + +from cli.wizard.commands import WIZARD_COMMAND_BUILDER + +WIZARD_COMMAND_BUILDER.build() + +print(json.dumps({ + 'cli.wizard.flows.app_run': 'cli.wizard.flows.app_run' in sys.modules, + 'cli.wizard.flows.db_upgrade': 'cli.wizard.flows.db_upgrade' in sys.modules, + 'cli.wizard.flows.cache_clear': 'cli.wizard.flows.cache_clear' in sys.modules, + 'cli.wizard.flows.gen_export': 'cli.wizard.flows.gen_export' in sys.modules, + 'cli.wizard.flows.gen_import': 'cli.wizard.flows.gen_import' in sys.modules, + 'cli.wizard.flows.prod_check': 'cli.wizard.flows.prod_check' in sys.modules, +}, ensure_ascii=False)) +""" + completed = subprocess.run( + [sys.executable, '-c', script], + cwd=BACKEND_DIR, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + payload = json.loads(completed.stdout) + + assert payload == { + 'cli.wizard.flows.app_run': False, + 'cli.wizard.flows.db_upgrade': False, + 'cli.wizard.flows.cache_clear': False, + 'cli.wizard.flows.gen_export': False, + 'cli.wizard.flows.gen_import': False, + 'cli.wizard.flows.prod_check': False, + } diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_contract_app_ops.py b/shierfeng-fastapi-backend/tests/cli/root/test_contract_app_ops.py new file mode 100644 index 0000000..4c9040d --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_contract_app_ops.py @@ -0,0 +1,311 @@ +import subprocess +from collections.abc import Callable + +from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS + + +def test_app_config_json_output_is_pure_json( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('app', 'config', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == 0 + assert completed.stderr == '' + assert payload['ok'] is True + assert payload['env'] == 'dev' + assert isinstance(payload['config'], dict) + assert '\u001b[' not in completed.stdout + assert '✅' not in completed.stdout + assert '❌' not in completed.stdout + + +def test_app_config_text_output_has_stable_section_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('app', 'config', '--env=dev', '--output=text') + + assert completed.returncode == SUCCESS + assert '\x1b[' not in completed.stdout + assert '✅' not in completed.stdout + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'env: dev\n' in completed.stdout + assert 'application:\n' in completed.stdout + assert 'database:\n' in completed.stdout + assert 'redis:\n' in completed.stdout + assert 'logging:\n' in completed.stdout + assert 'transport_crypto:\n' in completed.stdout + + +def test_app_env_text_output_has_stable_section_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('app', 'env', '--env=dev', '--output=text') + + assert completed.returncode == SUCCESS + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'env: dev\n' in completed.stdout + assert 'runtime:\n' in completed.stdout + assert 'cli_env: dev\n' in completed.stdout + assert 'config_env:' in completed.stdout + assert 'app_env: dev\n' in completed.stdout + assert 'env_file: .env.dev\n' in completed.stdout + assert 'env_file_exists:' in completed.stdout + assert 'backend_dir:' in completed.stdout + assert 'python_executable:' in completed.stdout + + +def test_app_doctor_text_output_has_stable_check_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('app', 'doctor', '--env=dev', '--output=text') + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert '\x1b[' not in completed.stdout + assert '✅' not in completed.stdout + assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n')) + assert 'env: dev\n' in completed.stdout + assert 'checks:\n' in completed.stdout + assert 'database:' in completed.stdout + assert 'redis:' in completed.stdout + assert 'crypto:' in completed.stdout + + +def test_app_doctor_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], + assert_check_payload_contract: Callable[[dict, bool], None], +) -> None: + completed = run_cli_command('app', 'doctor', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert completed.stderr == '' + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + assert set(payload) == {'env', 'database', 'redis', 'crypto', 'ok'} + assert_check_payload_contract(payload['database'], True) + assert_check_payload_contract(payload['redis'], True) + assert_check_payload_contract(payload['crypto'], False) + + +def test_app_env_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('app', 'env', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['env'] == 'dev' + assert set(payload) == {'ok', 'env', 'runtime'} + assert isinstance(payload['runtime'], dict) + assert set(payload['runtime']) == { + 'cliEnv', + 'configEnv', + 'appEnv', + 'envFile', + 'envFilePath', + 'envFileExists', + 'backendDir', + 'pythonExecutable', + } + assert payload['runtime']['cliEnv'] == 'dev' + assert payload['runtime']['appEnv'] == 'dev' + assert payload['runtime']['envFile'] == '.env.dev' + assert isinstance(payload['runtime']['envFileExists'], bool) + + +def test_app_routes_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('app', 'routes', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == 0 + assert payload['ok'] is True + assert payload['env'] == 'dev' + assert isinstance(payload['count'], int) + assert payload['count'] > 0 + assert payload['groupedRoutes'] is None + assert payload['filters'] == { + 'pathPrefix': '', + 'method': '', + 'groupBy': 'none', + 'includeHidden': False, + } + assert isinstance(payload['routes'], list) + assert payload['routes'] + + first_route = payload['routes'][0] + assert set(first_route) == { + 'path', + 'methods', + 'name', + 'summary', + 'operationId', + 'tags', + 'includeInSchema', + } + assert isinstance(first_route['path'], str) + assert isinstance(first_route['methods'], list) + assert first_route['methods'] + assert all(isinstance(method, str) for method in first_route['methods']) + assert isinstance(first_route['name'], str | None) + assert isinstance(first_route['summary'], str | None) + assert isinstance(first_route['operationId'], str | None) + assert isinstance(first_route['tags'], list) + assert all(isinstance(tag, str) for tag in first_route['tags']) + assert isinstance(first_route['includeInSchema'], bool) + + +def test_server_info_text_output_has_stable_section_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('ops', 'server-info', '--env=dev') + + assert completed.returncode == 0 + assert '\x1b[' not in completed.stdout + assert '✅' not in completed.stdout + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'host:\n' in completed.stdout + assert 'cpu:\n' in completed.stdout + assert 'memory:\n' in completed.stdout + assert 'python:\n' in completed.stdout + assert 'disks:' in completed.stdout + + +def test_ops_health_text_output_has_stable_check_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('ops', 'health', '--env=dev', '--output=text') + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert '\x1b[' not in completed.stdout + assert '✅' not in completed.stdout + assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n')) + assert 'env: dev\n' in completed.stdout + assert 'checks:\n' in completed.stdout + assert 'database:' in completed.stdout + assert 'redis:' in completed.stdout + + +def test_ops_deps_text_output_has_stable_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('ops', 'deps', '--env=dev', '--output=text') + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n')) + assert 'message:' in completed.stdout + assert 'include_dev: false\n' in completed.stdout + assert 'missing_required:' in completed.stdout + assert 'packages:\n' in completed.stdout + assert 'python:' in completed.stdout + assert 'fastapi:' in completed.stdout + assert 'typer:' in completed.stdout + + +def test_ops_health_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], + assert_check_payload_contract: Callable[[dict, bool], None], +) -> None: + completed = run_cli_command('ops', 'health', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert completed.stderr == '' + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + assert set(payload) == {'env', 'database', 'redis', 'ok'} + assert_check_payload_contract(payload['database'], True) + assert_check_payload_contract(payload['redis'], True) + + +def test_ops_deps_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('ops', 'deps', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert set(payload) == {'ok', 'message', 'missingRequired', 'includeDev', 'packages'} + assert isinstance(payload['ok'], bool) + assert isinstance(payload['message'], str) + assert isinstance(payload['missingRequired'], list) + assert payload['includeDev'] is False + assert isinstance(payload['packages'], dict) + assert 'python' in payload['packages'] + assert 'fastapi' in payload['packages'] + assert 'typer' in payload['packages'] + for package_payload in payload['packages'].values(): + assert isinstance(package_payload, dict) + assert isinstance(package_payload.get('installed'), bool) + assert isinstance(package_payload.get('version'), str) + assert isinstance(package_payload.get('required'), bool) + + +def test_ops_server_info_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('ops', 'server-info', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == 0 + assert payload['ok'] is True + assert isinstance(payload['server'], dict) + + server = payload['server'] + assert set(server) == {'cpu', 'mem', 'sys', 'py', 'sysFiles'} + + cpu = server['cpu'] + assert set(cpu) == {'cpuNum', 'used', 'sys', 'free'} + assert isinstance(cpu['cpuNum'], int) + assert isinstance(cpu['used'], float | int | str) + assert isinstance(cpu['sys'], float | int | str) + assert isinstance(cpu['free'], float | int | str) + + memory = server['mem'] + assert set(memory) == {'total', 'used', 'free', 'usage'} + assert isinstance(memory['total'], str) + assert isinstance(memory['used'], str) + assert isinstance(memory['free'], str) + assert isinstance(memory['usage'], float | int) + + sys_info = server['sys'] + assert set(sys_info) == {'computerIp', 'computerName', 'osArch', 'osName', 'userDir'} + assert all(isinstance(value, str) for value in sys_info.values()) + + py_info = server['py'] + assert set(py_info) == { + 'name', + 'version', + 'startTime', + 'runTime', + 'home', + 'total', + 'used', + 'free', + 'usage', + } + assert isinstance(py_info['name'], str) + assert isinstance(py_info['version'], str) + assert isinstance(py_info['startTime'], str) + assert isinstance(py_info['runTime'], str) + assert isinstance(py_info['home'], str) + assert isinstance(py_info['total'], str) + assert isinstance(py_info['used'], str) + assert isinstance(py_info['free'], str) + assert isinstance(py_info['usage'], float | int) + + assert isinstance(server['sysFiles'], list) + assert server['sysFiles'] + first_disk = server['sysFiles'][0] + assert set(first_disk) == {'dirName', 'sysTypeName', 'typeName', 'total', 'used', 'free', 'usage'} + assert all(isinstance(value, str) for value in first_disk.values()) diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_contract_cli.py b/shierfeng-fastapi-backend/tests/cli/root/test_contract_cli.py new file mode 100644 index 0000000..42f1bab --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_contract_cli.py @@ -0,0 +1,207 @@ +import subprocess +from collections.abc import Callable +from pathlib import Path + +from cli.exit_codes import SUCCESS + + +def test_root_help_shows_commands_without_completion_options( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_command('--help') + + assert completed.returncode == 0 + assert 'Usage: shierfeng [OPTIONS] COMMAND [ARGS]...' in completed.stdout + assert 'app' in completed.stdout + assert 'db' in completed.stdout + assert 'completion' in completed.stdout + assert 'wizard' in completed.stdout + assert 'tui' in completed.stdout + assert completed.stdout.index('wizard') < completed.stdout.index('tui') + assert '--install-completion' not in completed.stdout + assert '--show-completion' not in completed.stdout + + +def test_completion_show_bash_outputs_completion_script( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_command('completion', 'show', 'bash') + + assert completed.returncode == SUCCESS + assert completed.stderr == '' + assert '_SHIERFENG_COMPLETE=bash_complete' in completed.stdout + assert '_shierfeng_completion()' in completed.stdout + assert 'if command -v compopt >/dev/null 2>&1; then' in completed.stdout + assert 'if complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null; then' in completed.stdout + assert 'complete -F _shierfeng_completion shierfeng' in completed.stdout + + +def test_completion_show_powershell_outputs_completion_script( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_command('completion', 'show', 'powershell') + + assert completed.returncode == SUCCESS + assert completed.stderr == '' + assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in completed.stdout + assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in completed.stdout + + +def test_bash_completion_protocol_returns_candidates_without_not_supported_error( + run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_completion_command(comp_words='shierfeng comp', comp_cword=1) + + assert completed.returncode == SUCCESS + assert 'plain,completion' in completed.stdout + assert 'not supported' not in completed.stdout.lower() + assert completed.stderr == '' + + +def test_powershell_completion_protocol_returns_candidates_without_not_supported_error( + run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_completion_command( + comp_words='shierfeng comp', + comp_cword='comp', + instruction='powershell_complete', + ) + + assert completed.returncode == SUCCESS + assert 'completion' in completed.stdout + assert 'not supported' not in completed.stdout.lower() + assert completed.stderr == '' + + +def test_completion_doctor_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('completion', 'doctor', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['message'] == 'completion 诊断信息已生成' + assert set(payload) == { + 'ok', + 'message', + 'activeShell', + 'projectDir', + 'envChoices', + 'completeEnvVar', + 'recommendedInstallCommand', + 'shells', + } + assert isinstance(payload['envChoices'], list) + assert payload['completeEnvVar'] == '_SHIERFENG_COMPLETE' + assert isinstance(payload['shells'], dict) + assert set(payload['shells']) == {'bash', 'zsh', 'fish', 'powershell'} + assert payload['shells']['bash']['supported'] is True + assert payload['shells']['fish']['autoDiscovery'] is True + assert payload['shells']['powershell']['supported'] is True + assert isinstance(payload['shells']['bash']['sourceCommand'], str) + assert isinstance(payload['shells']['bash']['recommendedInstallCommand'], str) + assert isinstance(payload['shells']['powershell']['sourceCommand'], str) + assert isinstance(payload['shells']['powershell']['recommendedInstallCommand'], str) + + +def test_completion_install_json_output_has_stable_contract( + tmp_path: Path, + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + target_file = tmp_path / 'shierfeng.bash' + rc_file = tmp_path / '.bashrc' + completed = run_cli_command( + 'completion', + 'install', + '--shell=bash', + '--target-file', + str(target_file), + '--activate', + '--rc-file', + str(rc_file), + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert set(payload) == { + 'ok', + 'message', + 'shell', + 'detectedShell', + 'targetFile', + 'activated', + 'activateRequested', + 'rcFile', + 'rcFileUpdated', + 'sourceCommand', + 'autoDiscovery', + 'activationRequired', + 'nextStep', + 'completeEnvVar', + } + assert payload['shell'] == 'bash' + assert payload['targetFile'] == str(target_file.resolve()) + assert payload['activated'] is True + assert payload['activateRequested'] is True + assert payload['rcFile'] == str(rc_file.resolve()) + assert payload['rcFileUpdated'] is True + assert payload['autoDiscovery'] is False + assert payload['activationRequired'] is True + assert isinstance(payload['nextStep'], str) + assert target_file.exists() + assert rc_file.exists() + assert '_SHIERFENG_COMPLETE=bash_complete' in target_file.read_text(encoding='utf-8') + assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8') + + +def test_completion_install_powershell_json_output_has_stable_contract( + tmp_path: Path, + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + target_file = tmp_path / 'shierfeng.ps1' + rc_file = tmp_path / 'Microsoft.PowerShell_profile.ps1' + completed = run_cli_command( + 'completion', + 'install', + '--shell=powershell', + '--target-file', + str(target_file), + '--activate', + '--rc-file', + str(rc_file), + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['shell'] == 'powershell' + assert payload['targetFile'] == str(target_file.resolve()) + assert payload['activated'] is True + assert payload['activateRequested'] is True + assert payload['rcFile'] == str(rc_file.resolve()) + assert payload['rcFileUpdated'] is True + assert payload['autoDiscovery'] is False + assert payload['activationRequired'] is True + assert payload['sourceCommand'] == f'. "{target_file.resolve()}"' + assert target_file.exists() + assert rc_file.exists() + assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in target_file.read_text(encoding='utf-8') + assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8') + + +def test_app_run_help_only_exposes_env_option( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_command('app', 'run', '--help') + + assert completed.returncode == 0 + assert 'Usage: shierfeng app run [OPTIONS]' in completed.stdout + assert '--env' in completed.stdout + assert '--output' not in completed.stdout diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_contract_config.py b/shierfeng-fastapi-backend/tests/cli/root/test_contract_config.py new file mode 100644 index 0000000..d7f00c3 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_contract_config.py @@ -0,0 +1,40 @@ +import subprocess +from collections.abc import Callable + +from cli.exit_codes import DATABASE_ERROR, REDIS_ERROR, RUNTIME_ERROR, SUCCESS + + +def test_config_doctor_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, REDIS_ERROR, DATABASE_ERROR, RUNTIME_ERROR} + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + assert set(payload) == { + 'ok', + 'message', + 'databaseCount', + 'cacheCount', + 'missingInCacheCount', + 'orphanInCacheCount', + 'mismatchCount', + 'sampleLimit', + 'missingInCache', + 'orphanInCache', + 'mismatchKeys', + 'env', + } + assert isinstance(payload['databaseCount'], int) + assert isinstance(payload['cacheCount'], int) + assert isinstance(payload['missingInCache'], list) + assert isinstance(payload['orphanInCache'], list) + assert isinstance(payload['mismatchKeys'], list) diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py b/shierfeng-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py new file mode 100644 index 0000000..1e5ed95 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py @@ -0,0 +1,235 @@ +import subprocess +from collections.abc import Callable +from pathlib import Path + +from cli.exit_codes import DATABASE_ERROR, GUARD_REJECTED, REDIS_ERROR, RUNTIME_ERROR, SUCCESS + +DB_HISTORY_TEST_LIMIT = 5 + + +def test_dangerous_command_is_rejected_without_yes_in_non_interactive_mode( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == GUARD_REJECTED + assert payload['ok'] is False + assert payload['message'] == '已取消危险命令执行:db revision' + assert '--yes' in payload['hint'] + + +def test_prod_dangerous_command_is_rejected_without_allow_prod( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('db', 'revision', '--env=prod', '--message=test-contract', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == GUARD_REJECTED + assert payload['ok'] is False + assert payload['message'] == '生产环境默认禁止直接执行危险命令:db revision' + assert '--allow-prod' in payload['hint'] + assert '--yes' in payload['hint'] + + +def test_dry_run_command_returns_structured_preview_payload( + backend_dir: Path, + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'db', + 'upgrade', + '--env=dev', + '--revision=head', + '--dry-run', + '--yes', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == 0 + assert payload['ok'] is True + assert payload['dryRun'] is True + assert isinstance(payload['command'], list) + assert payload['command'][-2:] == ['upgrade', 'head'] + assert payload['cwd'] == str(backend_dir) + + +def test_db_heads_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('db', 'heads', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR} + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + assert set(payload) == {'ok', 'message', 'count', 'items', 'env'} + assert isinstance(payload['count'], int) + assert isinstance(payload['items'], list) + if payload['items']: + first_item = payload['items'][0] + assert set(first_item) == {'revision', 'downRevisions', 'branchLabels', 'dependsOn', 'doc', 'path'} + assert isinstance(first_item['revision'], str) + assert isinstance(first_item['downRevisions'], list) + assert isinstance(first_item['branchLabels'], list) + assert isinstance(first_item['dependsOn'], list) + assert isinstance(first_item['doc'], str) + assert isinstance(first_item['path'], str) + + +def test_db_history_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'db', + 'history', + '--env=dev', + f'--limit={DB_HISTORY_TEST_LIMIT}', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR} + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + assert set(payload) == {'ok', 'message', 'count', 'totalCount', 'limit', 'items', 'env'} + assert payload['limit'] == DB_HISTORY_TEST_LIMIT + assert isinstance(payload['count'], int) + assert isinstance(payload['totalCount'], int) + assert isinstance(payload['items'], list) + + +def test_cache_ttl_json_output_has_stable_contract_for_missing_key_or_redis_error( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'cache', + 'ttl', + 'sys_config', + 'definitely_missing_cli_key', + '--env=dev', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, REDIS_ERROR, RUNTIME_ERROR} + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert payload['message'] == '读取缓存剩余过期时间失败' + assert isinstance(payload['error'], str) + return + + assert set(payload) == {'ok', 'message', 'cacheName', 'cacheKey', 'fullCacheKey'} + assert payload['ok'] is False + assert payload['cacheName'] == 'sys_config' + assert payload['cacheKey'] == 'definitely_missing_cli_key' + assert payload['fullCacheKey'] == 'sys_config:definitely_missing_cli_key' + + +def test_dangerous_command_rejection_text_output_is_human_readable( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=text') + + assert completed.returncode == GUARD_REJECTED + assert completed.stdout == ( + 'FAIL FAILED\n' + 'message: 已取消危险命令执行:db revision\n' + 'hint: 当前命令需要交互确认;如需非交互执行,请传入 --yes\n' + ) + + +def test_dry_run_text_output_contains_preview_command_and_workdir( + backend_dir: Path, + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command( + 'db', + 'upgrade', + '--env=dev', + '--revision=head', + '--dry-run', + '--yes', + '--output=text', + ) + + assert completed.returncode == 0 + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'message: 数据库已升级到 head(dry-run)\n' in completed.stdout + assert 'dry_run: true\n' in completed.stdout + assert 'command:\n - alembic\n - -c\n' in completed.stdout + assert f' - {backend_dir / "alembic.ini"}\n' in completed.stdout + assert f'cwd: {backend_dir}\n' in completed.stdout + + +def test_dev_lint_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'dev', + 'lint', + 'cli/groups/dev/command.py', + '--env=dev', + '--check-only', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['message'] == '开发检查已完成' + assert payload['env'] == 'dev' + assert payload['checkOnly'] is True + assert payload['fix'] is False + assert payload['unsafeFixes'] is False + assert payload['targets'] == ['cli/groups/dev/command.py'] + assert isinstance(payload['format'], dict) + assert isinstance(payload['check'], dict) + assert payload['format']['ok'] is True + assert payload['check']['ok'] is True + assert isinstance(payload['format']['command'], list) + assert isinstance(payload['check']['command'], list) + + +def test_dev_test_text_output_has_stable_summary( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command( + 'dev', + 'test', + 'tests/cli/root/test_contract_cli.py', + '--env=dev', + '--keyword=test_root_help_shows_commands_without_completion_options', + '--maxfail=1', + '--quiet', + '--output=text', + ) + + assert completed.returncode == SUCCESS + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'ok: true\n' in completed.stdout + assert 'env: dev\n' in completed.stdout + assert 'keyword: test_root_help_shows_commands_without_completion_options\n' in completed.stdout + assert 'maxfail: 1\n' in completed.stdout + assert 'quiet: true\n' in completed.stdout + assert 'targets:\n - tests/cli/root/test_contract_cli.py\n' in completed.stdout + assert 'test:\n ok: true\n' in completed.stdout + assert 'command: ' in completed.stdout diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_contract_job_gen.py b/shierfeng-fastapi-backend/tests/cli/root/test_contract_job_gen.py new file mode 100644 index 0000000..f713da5 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_contract_job_gen.py @@ -0,0 +1,368 @@ +import subprocess +from collections.abc import Callable + +from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, GUARD_REJECTED, RUNTIME_ERROR, SUCCESS + +GEN_TEST_TABLE_NAME = 'demo_table' +GEN_CREATE_SQL = 'CREATE TABLE demo_cli_test (id bigint);' +MISSING_JOB_ID = 999999999 +MISSING_GEN_TABLE_ID = 999999999 + + +def test_job_detail_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + list_completed = run_cli_command('job', 'list', '--env=dev', '--output=json') + list_payload = parse_json_stdout(list_completed) + job_id = MISSING_JOB_ID + if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']: + first_job = list_payload['items'][0] + if isinstance(first_job, dict) and isinstance(first_job.get('jobId'), int): + job_id = first_job['jobId'] + + completed = run_cli_command('job', 'detail', str(job_id), '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR} + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + if payload['ok'] is False: + assert set(payload) == {'ok', 'message', 'jobId'} + assert payload['jobId'] == job_id + return + + assert set(payload) == {'ok', 'job'} + assert isinstance(payload['job'], dict) + assert payload['job']['jobId'] == job_id + assert isinstance(payload['job'].get('jobName'), str) + assert isinstance(payload['job'].get('invokeTarget'), str) + + +def test_job_logs_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('job', 'logs', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR} + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + assert set(payload) == {'ok', 'filters', 'count', 'items'} + assert isinstance(payload['filters'], dict) + assert isinstance(payload['count'], int) + assert isinstance(payload['items'], list) + if payload['items']: + first_item = payload['items'][0] + assert isinstance(first_item, dict) + assert isinstance(first_item.get('jobLogId'), int) + assert isinstance(first_item.get('jobName'), str | None) + assert isinstance(first_item.get('jobMessage'), str | None) + + +def test_gen_list_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('gen', 'list', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR} + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'} + assert isinstance(payload['filters'], dict) + assert isinstance(payload['count'], int) + assert isinstance(payload['items'], list) + if payload['items']: + first_item = payload['items'][0] + assert isinstance(first_item, dict) + assert isinstance(first_item.get('tableId'), int) + assert isinstance(first_item.get('tableName'), str) + + +def test_gen_db_list_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('gen', 'db-list', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR} + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'} + assert isinstance(payload['filters'], dict) + assert isinstance(payload['count'], int) + assert isinstance(payload['items'], list) + if payload['items']: + first_item = payload['items'][0] + assert isinstance(first_item, dict) + assert isinstance(first_item.get('tableName'), str) + + +def test_gen_detail_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + list_completed = run_cli_command('gen', 'list', '--env=dev', '--output=json') + list_payload = parse_json_stdout(list_completed) + table_id = MISSING_GEN_TABLE_ID + if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']: + first_item = list_payload['items'][0] + if isinstance(first_item, dict) and isinstance(first_item.get('tableId'), int): + table_id = first_item['tableId'] + + completed = run_cli_command('gen', 'detail', str(table_id), '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR} + assert payload['env'] == 'dev' + assert isinstance(payload['ok'], bool) + if 'error' in payload: + assert isinstance(payload['message'], str) + assert isinstance(payload['error'], str) + return + + if payload['ok'] is False: + assert set(payload) == {'ok', 'message', 'tableId', 'env'} + assert payload['tableId'] == table_id + return + + assert set(payload) == {'ok', 'tableId', 'tableName', 'columnCount', 'tableCount', 'detail', 'env'} + assert payload['tableId'] == table_id + assert isinstance(payload['tableName'], str) + assert isinstance(payload['columnCount'], int) + assert isinstance(payload['tableCount'], int) + assert isinstance(payload['detail'], dict) + assert isinstance(payload['detail'].get('info'), dict) + assert isinstance(payload['detail'].get('rows'), list) + assert isinstance(payload['detail'].get('tables'), list) + + +def test_gen_sync_db_is_rejected_without_yes_in_non_interactive_mode( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('gen', 'sync-db', 'demo_table', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == GUARD_REJECTED + assert payload['ok'] is False + assert payload['message'] == '已取消危险命令执行:gen sync-db' + assert '--yes' in payload['hint'] + + +def test_gen_export_dry_run_text_output_has_stable_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command( + 'gen', + 'export', + GEN_TEST_TABLE_NAME, + '--env=dev', + '--dry-run', + '--yes', + '--output=text', + ) + + assert completed.returncode == SUCCESS + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'env: dev\n' in completed.stdout + assert 'mode: zip\n' in completed.stdout + assert 'dry_run: true\n' in completed.stdout + assert 'message: 代码导出演练完成,未执行实际导出\n' in completed.stdout + assert 'table_names:\n' in completed.stdout + assert f' - {GEN_TEST_TABLE_NAME}\n' in completed.stdout + assert 'output_file:' in completed.stdout + assert f'gen_code_{GEN_TEST_TABLE_NAME}.zip' in completed.stdout + + +def test_gen_export_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'gen', + 'export', + GEN_TEST_TABLE_NAME, + '--env=dev', + '--dry-run', + '--yes', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['env'] == 'dev' + assert payload['dryRun'] is True + assert payload['mode'] == 'zip' + assert payload['message'] == '代码导出演练完成,未执行实际导出' + assert payload['tableNames'] == [GEN_TEST_TABLE_NAME] + assert isinstance(payload['outputFile'], str) + assert payload['outputFile'].endswith(f'gen_code_{GEN_TEST_TABLE_NAME}.zip') + + +def test_gen_create_table_dry_run_text_output_has_stable_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command( + 'gen', + 'create-table', + '--env=dev', + '--dry-run', + '--yes', + '--sql', + GEN_CREATE_SQL, + '--output=text', + ) + + assert completed.returncode == SUCCESS + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'env: dev\n' in completed.stdout + assert 'message: 建表语句演练完成,未执行实际建表\n' in completed.stdout + assert 'dry_run: true\n' in completed.stdout + assert 'statement_count: 1\n' in completed.stdout + assert 'table_names:\n' in completed.stdout + assert ' - demo_cli_test\n' in completed.stdout + assert f'sql: {GEN_CREATE_SQL}\n' in completed.stdout + + +def test_gen_create_table_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'gen', + 'create-table', + '--env=dev', + '--dry-run', + '--yes', + '--sql', + GEN_CREATE_SQL, + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['env'] == 'dev' + assert payload['dryRun'] is True + assert payload['message'] == '建表语句演练完成,未执行实际建表' + assert payload['statementCount'] == 1 + assert payload['tableNames'] == ['demo_cli_test'] + assert payload['sql'] == GEN_CREATE_SQL + + +def test_gen_create_table_rejects_non_create_sql_in_text_output( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command( + 'gen', + 'create-table', + '--env=dev', + '--dry-run', + '--yes', + '--sql', + 'DROP TABLE demo_cli_test;', + '--output=text', + ) + + assert completed.returncode == ARGUMENT_ERROR + assert completed.stdout == ( + 'FAIL FAILED\nmessage: 创建表结构失败\nerror: 建表语句不合法,仅允许 CREATE TABLE 语句\nenv: dev\n' + ) + + +def test_gen_create_table_rejects_non_create_sql_in_json_output( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'gen', + 'create-table', + '--env=dev', + '--dry-run', + '--yes', + '--sql', + 'DROP TABLE demo_cli_test;', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == ARGUMENT_ERROR + assert payload == { + 'ok': False, + 'message': '创建表结构失败', + 'error': '建表语句不合法,仅允许 CREATE TABLE 语句', + 'env': 'dev', + } + + +def test_gen_create_table_rejects_conflicting_sql_inputs_in_text_output( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command( + 'gen', + 'create-table', + '--env=dev', + '--dry-run', + '--yes', + '--sql', + GEN_CREATE_SQL, + '--sql-file', + 'fake.sql', + '--output=text', + ) + + assert completed.returncode == ARGUMENT_ERROR + assert completed.stdout == ( + 'FAIL FAILED\nmessage: 创建表结构失败\nerror: 必须且只能传入 --sql 或 --sql-file 其中一种方式\nenv: dev\n' + ) + + +def test_gen_create_table_rejects_conflicting_sql_inputs_in_json_output( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'gen', + 'create-table', + '--env=dev', + '--dry-run', + '--yes', + '--sql', + GEN_CREATE_SQL, + '--sql-file', + 'fake.sql', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == ARGUMENT_ERROR + assert payload == { + 'ok': False, + 'message': '创建表结构失败', + 'error': '必须且只能传入 --sql 或 --sql-file 其中一种方式', + 'env': 'dev', + } diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_guards.py b/shierfeng-fastapi-backend/tests/cli/root/test_guards.py new file mode 100644 index 0000000..7759955 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_guards.py @@ -0,0 +1,69 @@ +import importlib +import sys +from pathlib import Path + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +sys.path.insert(0, str(BACKEND_DIR)) +cli_guards = importlib.import_module('cli.guards') +cli_guards = importlib.reload(cli_guards) + +DEFAULT_DANGEROUS_COMMAND_RULES = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULES +DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY + + +def test_dangerous_command_rules_cover_expected_commands() -> None: + """ + 校验危险命令规则表已覆盖当前设计文档中约定的命令范围。 + + :return: None + """ + assert set(DEFAULT_DANGEROUS_COMMAND_RULES) == { + 'cache clear', + 'cache warmup', + 'db upgrade', + 'db init', + 'db downgrade', + 'db revision', + 'config set', + 'config sync-cache', + 'crypto rotate', + 'job run-once', + 'job pause', + 'job resume', + 'job sync', + 'gen import-table', + 'gen create-table', + 'gen export', + 'gen sync-db', + } + + +def test_dangerous_command_rules_expose_risk_level_and_dry_run_metadata() -> None: + """ + 校验危险命令规则能够提供风险级别和 dry-run 能力元数据。 + + :return: None + """ + db_upgrade_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('db upgrade') + cache_warmup_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('cache warmup') + + assert db_upgrade_rule is not None + assert db_upgrade_rule.risk_level == 'high' + assert db_upgrade_rule.supports_dry_run is True + + assert cache_warmup_rule is not None + assert cache_warmup_rule.risk_level == 'normal' + assert cache_warmup_rule.supports_dry_run is False + + +def test_require_dangerous_command_rule_rejects_unknown_command() -> None: + """ + 校验未注册的危险命令名称会被显式拒绝。 + + :return: None + """ + with pytest.raises(ValueError, match='危险命令未注册保护规则'): + DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('unknown dangerous command') diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_json_output_suppression.py b/shierfeng-fastapi-backend/tests/cli/root/test_json_output_suppression.py new file mode 100644 index 0000000..2e9fcbe --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_json_output_suppression.py @@ -0,0 +1,18 @@ +import subprocess +from collections.abc import Callable + + +def test_config_doctor_json_stdout_is_not_polluted_by_sqlalchemy_logs( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + """ + 校验 JSON 输出模式下不会混入 SQLAlchemy engine 日志。 + + :param run_cli_command: CLI 子进程执行器 + :return: None + """ + completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json') + + assert 'sqlalchemy.engine.Engine' not in completed.stdout + assert 'SELECT DATABASE()' not in completed.stdout + assert completed.stdout.lstrip().startswith('{') diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_main.py b/shierfeng-fastapi-backend/tests/cli/root/test_main.py new file mode 100644 index 0000000..f68f8e7 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_main.py @@ -0,0 +1,13 @@ +import importlib + + +def test_cli_main_runner_reuses_single_project_runtime_locator() -> None: + """ + 校验 CLI 根运行器与导入期参数作用域共享同一个项目定位器实例。 + + :return: None + """ + cli_main = importlib.import_module('cli.main') + + assert cli_main.CLI_MAIN_RUNNER.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR + assert cli_main.CLI_MAIN_RUNNER.import_argv_scope.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_output.py b/shierfeng-fastapi-backend/tests/cli/root/test_output.py new file mode 100644 index 0000000..c4e0ce1 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_output.py @@ -0,0 +1,181 @@ +import importlib +import re +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +sys.path.insert(0, str(BACKEND_DIR)) +sys.modules.pop('cli.output', None) +sys.modules.pop('cli', None) + +cli_output = importlib.import_module('cli.output') +OUTPUT_RENDERER = cli_output.OutputRenderer() + +_ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*m') + + +class _FakeStream: + """ + 模拟终端输出流。 + + :param is_tty: 是否模拟为 TTY + """ + + def __init__(self, *, is_tty: bool) -> None: + self._is_tty = is_tty + + def isatty(self) -> bool: + """ + 返回是否为 TTY。 + + :return: 是否为 TTY + """ + return self._is_tty + + +def _strip_ansi(text: str) -> str: + """ + 移除 ANSI 转义序列,便于断言文本可视结果。 + + :param text: 原始带样式文本 + :return: 去除 ANSI 后的文本 + """ + return _ANSI_ESCAPE_PATTERN.sub('', text) + + +def test_decorate_text_output_promotes_success_header_with_emoji_icon() -> None: + """ + 校验成功结果头在 emoji 模式下会被提升为统一状态头。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + + rendered = OUTPUT_RENDERER.decorate_text_output('ok: true\nenv: dev') + + assert rendered == '✅ OK SUCCESS\nenv: dev' + + +def test_decorate_text_output_promotes_failure_header_with_emoji_icon() -> None: + """ + 校验失败结果头在 emoji 模式下会被提升为统一状态头。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + + rendered = OUTPUT_RENDERER.decorate_text_output('ok: false\nerror: boom') + + assert rendered == '❌ FAIL FAILED\nerror: boom' + + +def test_colorize_text_output_renders_inline_status_segments_with_emoji_icon() -> None: + """ + 校验文本着色后会为 message、hint、error 等字段附加状态标签。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + OUTPUT_RENDERER.set_color_mode('always') + decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nmessage: retry later\nhint: use --yes\nerror: boom') + + rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True)) + + assert '\x1b[' in rendered + assert _strip_ansi(rendered) == ( + '❌ FAIL FAILED\nmessage: 💬 INFO retry later\nhint: 💡 HINT use --yes\nerror: 🚨 ERROR boom' + ) + + +def test_colorize_text_output_supports_warning_and_info_field_aliases() -> None: + """ + 校验 `warning` 与 `info` 字段也会使用统一状态图标渲染。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + OUTPUT_RENDERER.set_color_mode('always') + decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nwarning: rotate key soon\ninfo: cache refreshed') + + rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True)) + + assert _strip_ansi(rendered) == ('✅ OK SUCCESS\nwarning: 💡 HINT rotate key soon\ninfo: 💬 INFO cache refreshed') + + +def test_colorize_text_output_styles_status_segments_inside_list_items() -> None: + """ + 校验列表项中的带标签状态片段也会应用统一视觉规则。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + OUTPUT_RENDERER.set_color_mode('always') + rendered = OUTPUT_RENDERER.colorize_text_output( + 'results:\n - users: true | exported\n - roles: false | error: lock timeout', + _FakeStream(is_tty=True), + ) + + assert _strip_ansi(rendered) == ( + 'results:\n - users: ✅ OK | exported\n - roles: ❌ FAIL | 🚨 ERROR lock timeout' + ) + + +def test_colorize_text_output_supports_ascii_icon_mode() -> None: + """ + 校验 ascii 图标模式会渲染为纯 ASCII 状态标签。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('ascii') + OUTPUT_RENDERER.set_color_mode('always') + decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nhint: use --yes') + + rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True)) + + assert _strip_ansi(rendered) == '[OK] SUCCESS\nhint: [HINT] use --yes' + + +def test_colorize_text_output_skips_ansi_when_color_mode_is_never() -> None: + """ + 校验关闭颜色后不会输出 ANSI 转义序列。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + OUTPUT_RENDERER.set_color_mode('never') + decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nhint: use --yes') + + rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True)) + + assert rendered == '❌ FAIL FAILED\nhint: use --yes' + assert '\x1b[' not in rendered + + +def test_render_error_text_uses_status_token_in_emoji_mode() -> None: + """ + 校验标准错误输出在 emoji 模式下使用统一错误图标。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('emoji') + OUTPUT_RENDERER.set_color_mode('never') + + rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True)) + + assert rendered == '🚨 ERROR[23]: boom' + + +def test_render_error_text_supports_ascii_mode_with_color() -> None: + """ + 校验标准错误输出在 ascii 模式下仍保持统一错误前缀与颜色。 + + :return: None + """ + OUTPUT_RENDERER.set_icon_mode('ascii') + OUTPUT_RENDERER.set_color_mode('always') + + rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True)) + + assert '\x1b[' in rendered + assert _strip_ansi(rendered) == '[ERROR][23]: boom' diff --git a/shierfeng-fastapi-backend/tests/cli/root/test_utils.py b/shierfeng-fastapi-backend/tests/cli/root/test_utils.py new file mode 100644 index 0000000..0f39f0c --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/root/test_utils.py @@ -0,0 +1,142 @@ +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +from pytest import MonkeyPatch + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +sys.path.insert(0, str(BACKEND_DIR)) +sys.modules.pop('cli.utils', None) +cli_utils = importlib.import_module('cli.utils') + + +def test_run_nested_cli_command_extracts_json_from_noisy_stdout(monkeypatch: MonkeyPatch) -> None: + """ + 校验内部 CLI 调用可从带噪声的标准输出中提取 JSON 负载。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace: + del args, kwargs + return SimpleNamespace( + stdout='debug line before json\n{"ok": true, "message": "success"}\ndebug line after json\n', + stderr='', + returncode=0, + ) + + monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run) + + result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', '--output=json', parse_json=True) + + assert result.payload == {'ok': True, 'message': 'success'} + + +def test_run_nested_cli_command_extracts_first_json_object_from_mixed_stdout( + monkeypatch: MonkeyPatch, +) -> None: + """ + 校验内部 CLI 调用可从混杂文本中提取首个合法 JSON 对象。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace: + del args, kwargs + return SimpleNamespace( + stdout='preface\ntrace {not-json}\n{"ok": true, "message": "job done"}\ntrailer\n', + stderr='', + returncode=0, + ) + + monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run) + + result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True) + + assert result.payload == {'ok': True, 'message': 'job done'} + + +def test_run_nested_cli_command_falls_back_to_text_payload_when_json_missing( + monkeypatch: MonkeyPatch, +) -> None: + """ + 校验内部 CLI 未返回 JSON 时会回退为文本结果负载。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace: + del args, kwargs + return SimpleNamespace( + stdout='任务已提交到调度器', + stderr='', + returncode=0, + ) + + monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run) + + result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True) + + assert result.payload is not None + assert result.payload.get('ok') is True + assert result.payload.get('message') == '任务已提交到调度器' + assert result.payload.get('fallback') == 'non_json_output' + + +def test_run_nested_cli_command_uses_backend_dir_and_pythonpath(monkeypatch: MonkeyPatch) -> None: + """ + 校验内部 CLI 调用会固定后端工作目录并注入项目路径。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + recorded_kwargs: dict[str, object] = {} + + def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace: + del args + recorded_kwargs.update(kwargs) + return SimpleNamespace(stdout='{}', stderr='', returncode=0) + + monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run) + + result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', parse_json=False) + + assert result.returncode == 0 + assert recorded_kwargs['cwd'] == str(BACKEND_DIR) + process_env = recorded_kwargs['env'] + assert isinstance(process_env, dict) + assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '') + + +def test_run_nested_cli_command_live_uses_backend_dir_without_capturing_output(monkeypatch: MonkeyPatch) -> None: + """ + 校验交互式内部 CLI 调用会复用后端工作目录并直接占用当前终端。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + recorded_kwargs: dict[str, object] = {} + recorded_args: tuple[object, ...] = () + + def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace: + nonlocal recorded_args + recorded_args = args + recorded_kwargs.update(kwargs) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run) + + completed = cli_utils.NESTED_CLI_SUPPORT.run_live('wizard', 'cache-clear', '--output=text') + + assert completed.returncode == 0 + assert recorded_args + assert recorded_kwargs['cwd'] == str(BACKEND_DIR) + process_env = recorded_kwargs['env'] + assert isinstance(process_env, dict) + assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '') + assert 'capture_output' not in recorded_kwargs diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_app_runtime.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_app_runtime.py new file mode 100644 index 0000000..21bbada --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_app_runtime.py @@ -0,0 +1,151 @@ +from types import SimpleNamespace + +from pytest import MonkeyPatch + +from cli.runtime.app import AppRuntimeService +from cli.runtime.app.gateway import AppInfrastructureGateway +from cli.runtime.app.support import AppSnapshotSupport +from cli.runtime.base import RuntimeEnvironmentService + +REDIS_PORT = 6379 + + +class FakeRuntimeEnvironment(RuntimeEnvironmentService): + """ + 模拟运行时环境服务。 + """ + + @staticmethod + def get_backend_dir() -> str: + """ + 返回固定后端目录。 + + :return: 固定目录 + """ + return '/tmp/shierfeng-backend' + + @staticmethod + def get_python_executable() -> str: + """ + 返回固定 Python 可执行文件。 + + :return: Python 可执行文件路径 + """ + return '/usr/bin/python3' + + +def test_app_snapshot_support_builds_config_snapshot() -> None: + """ + 校验应用快照支持对象会构建应用配置快照。 + + :return: None + """ + gateway = AppInfrastructureGateway() + support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment()) + + fake_env_module = SimpleNamespace( + AppConfig=SimpleNamespace( + app_env='dev', + app_name='shierfeng', + app_host='127.0.0.1', + app_port=8080, + app_root_path='/api', + app_reload=True, + app_workers=1, + app_disable_swagger=False, + app_disable_redoc=False, + ), + DataBaseConfig=SimpleNamespace( + db_type='mysql', + db_host='127.0.0.1', + db_port=3306, + db_database='shierfeng', + ), + RedisConfig=SimpleNamespace(redis_host='127.0.0.1', redis_port=REDIS_PORT), + LogConfig=SimpleNamespace(loguru_level='INFO'), + TransportCryptoConfig=SimpleNamespace( + transport_crypto_enabled=True, + transport_crypto_mode='strict', + ), + ) + + def _fake_get_env_module() -> SimpleNamespace: + return fake_env_module + + object.__setattr__(gateway, 'get_env_module', _fake_get_env_module) + + payload = support.build_app_config_snapshot() + + assert payload['env'] == 'dev' + assert payload['dbType'] == 'mysql' + assert payload['redisPort'] == REDIS_PORT + assert payload['transportCryptoMode'] == 'strict' + + +def test_app_snapshot_support_builds_env_snapshot(monkeypatch: MonkeyPatch) -> None: + """ + 校验应用快照支持对象会构建环境解析快照。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + gateway = AppInfrastructureGateway() + support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment()) + fake_env_module = SimpleNamespace( + AppConfig=SimpleNamespace(app_env='prod'), + ) + + def _fake_get_env_module() -> SimpleNamespace: + return fake_env_module + + object.__setattr__(gateway, 'get_env_module', _fake_get_env_module) + monkeypatch.setenv('APP_ENV', 'test') + + payload = support.build_app_env_snapshot() + + assert payload == { + 'cliEnv': 'test', + 'configEnv': 'prod', + 'appEnv': 'test', + 'envFile': '.env.test', + 'envFilePath': '/tmp/shierfeng-backend/.env.test', + 'envFileExists': False, + 'backendDir': '/tmp/shierfeng-backend', + 'pythonExecutable': '/usr/bin/python3', + } + + +def test_app_runtime_service_builds_app_instance() -> None: + """ + 校验应用运行时 facade 会通过基础设施网关构建应用实例。 + + :return: None + """ + gateway = AppInfrastructureGateway() + service = AppRuntimeService( + runtime_environment=FakeRuntimeEnvironment(), + infrastructure_gateway=gateway, + ) + + class FakeServerModule: + """ + 模拟 server 模块。 + """ + + @staticmethod + def create_app() -> dict[str, str]: + """ + 返回模拟应用实例。 + + :return: 模拟应用实例 + """ + return {'app': 'ok'} + + def _fake_get_server_module() -> FakeServerModule: + return FakeServerModule() + + object.__setattr__(gateway, 'get_server_module', _fake_get_server_module) + + payload = service.build_app_instance() + + assert payload == {'app': 'ok'} diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_base.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_base.py new file mode 100644 index 0000000..7050476 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_base.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from cli.core.app_builder import ProjectRuntimeLocator +from cli.runtime.base import RUNTIME_ENVIRONMENT +from cli.utils import NestedCliProjectLocator + + +def test_backend_project_dir_detection_uses_consistent_rule(tmp_path: Path) -> None: + """ + 校验不同入口的后端目录判定规则保持一致。 + + :param tmp_path: pytest 临时目录 + :return: None + """ + (tmp_path / 'app.py').write_text('', encoding='utf-8') + (tmp_path / 'config').mkdir() + (tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8') + (tmp_path / 'cli').mkdir() + + project_runtime_locator = ProjectRuntimeLocator() + nested_cli_project_locator = NestedCliProjectLocator() + + assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is True + assert project_runtime_locator.is_backend_project_dir(tmp_path) is True + assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is True + + +def test_backend_project_dir_detection_rejects_directory_without_cli_package(tmp_path: Path) -> None: + """ + 校验缺少 cli 目录时不会被误判为后端项目根目录。 + + :param tmp_path: pytest 临时目录 + :return: None + """ + (tmp_path / 'app.py').write_text('', encoding='utf-8') + (tmp_path / 'config').mkdir() + (tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8') + + project_runtime_locator = ProjectRuntimeLocator() + nested_cli_project_locator = NestedCliProjectLocator() + + assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is False + assert project_runtime_locator.is_backend_project_dir(tmp_path) is False + assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is False diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_cache.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_cache.py new file mode 100644 index 0000000..0c5e396 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_cache.py @@ -0,0 +1,164 @@ +import pytest + +from cli.runtime.cache import CacheRuntimeService +from cli.runtime.cache.gateway import REDIS_TTL_KEY_NOT_FOUND, CacheInfrastructureGateway +from cli.runtime.cache.support import CacheDomainSupport + +EXPECTED_MATCHED_KEY_COUNT = 2 + + +def test_cache_domain_support_extracts_scoped_keys() -> None: + """ + 校验缓存领域支持对象会提取指定缓存名前缀下的相对键名。 + + :return: None + """ + support = CacheDomainSupport(CacheInfrastructureGateway()) + + key_items = support.extract_cache_key_items( + 'sys_config', + ['sys_config:site_name', 'sys_config:logo', 'login_tokens:user:1'], + ) + + assert key_items == ['logo', 'site_name'] + + +def test_cache_domain_support_builds_missing_cache_result() -> None: + """ + 校验缓存领域支持对象会生成统一的缓存不存在结果。 + + :return: None + """ + support = CacheDomainSupport(CacheInfrastructureGateway()) + + payload = support.build_missing_cache_result('sys_config', 'site_name') + + assert payload['ok'] is False + assert payload['fullCacheKey'] == 'sys_config:site_name' + assert payload['message'] == '缓存不存在:sys_config:site_name' + + +@pytest.mark.asyncio +async def test_cache_runtime_service_ttl_returns_missing_result() -> None: + """ + 校验缓存运行时在 TTL 返回不存在标记时会输出统一缺失结果。 + + :return: None + """ + + class FakeRedis: + """ + 模拟 Redis 客户端。 + """ + + @staticmethod + async def ttl(full_cache_key: str) -> int: + """ + 返回 Redis 键不存在标记。 + + :param full_cache_key: 完整缓存键名 + :return: Redis TTL 特殊值 + """ + assert full_cache_key == 'sys_config:site_name' + return REDIS_TTL_KEY_NOT_FOUND + + @staticmethod + async def close() -> None: + """ + 关闭客户端。 + + :return: None + """ + + class FakeRedisUtil: + """ + 模拟 Redis 工具类。 + """ + + @staticmethod + async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis: + """ + 返回模拟 Redis 客户端。 + + :param log_enabled: 是否启用日志 + :return: 模拟 Redis 客户端 + """ + del log_enabled + return FakeRedis() + + gateway = CacheInfrastructureGateway() + service = CacheRuntimeService(infrastructure_gateway=gateway) + + def _fake_get_redis_util() -> FakeRedisUtil: + return FakeRedisUtil() + + object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util) + + payload = await service.get_cache_ttl('sys_config', 'site_name') + + assert payload['ok'] is False + assert payload['fullCacheKey'] == 'sys_config:site_name' + + +@pytest.mark.asyncio +async def test_cache_runtime_service_clear_cache_dry_run_reports_scope() -> None: + """ + 校验缓存运行时 dry-run 清理会返回匹配键与清理范围。 + + :return: None + """ + + class FakeRedis: + """ + 模拟 Redis 客户端。 + """ + + @staticmethod + async def keys(pattern: str) -> list[str]: + """ + 返回匹配键列表。 + + :param pattern: 匹配模式 + :return: 匹配结果 + """ + assert pattern == 'sys_config:*' + return ['sys_config:site_name', 'sys_config:logo'] + + @staticmethod + async def close() -> None: + """ + 关闭客户端。 + + :return: None + """ + + class FakeRedisUtil: + """ + 模拟 Redis 工具类。 + """ + + @staticmethod + async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis: + """ + 返回模拟 Redis 客户端。 + + :param log_enabled: 是否启用日志 + :return: 模拟 Redis 客户端 + """ + del log_enabled + return FakeRedis() + + gateway = CacheInfrastructureGateway() + service = CacheRuntimeService(infrastructure_gateway=gateway) + + def _fake_get_redis_util() -> FakeRedisUtil: + return FakeRedisUtil() + + object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util) + + payload = await service.clear_cache(cache_name='sys_config', dry_run=True) + + assert payload['ok'] is True + assert payload['matchedCount'] == EXPECTED_MATCHED_KEY_COUNT + assert payload['scope'] == {'mode': 'cacheName', 'cacheName': 'sys_config'} + assert payload['message'] == '缓存清理演练完成,未执行实际删除' diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_config.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_config.py new file mode 100644 index 0000000..47a7b2f --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_config.py @@ -0,0 +1,210 @@ +import pytest + +from cli.exit_codes import RUNTIME_ERROR +from cli.runtime.config import ConfigRuntimeService +from cli.runtime.config.gateway import ConfigInfrastructureGateway +from cli.runtime.config.support import ConfigDomainSupport + +MISSING_CONFIG_KEY = 'sys.demo.key' + + +def test_config_domain_support_builds_missing_config_result() -> None: + """ + 校验参数配置领域支持对象会生成统一的缺失结果。 + + :return: None + """ + support = ConfigDomainSupport(ConfigInfrastructureGateway()) + + payload = support.build_missing_config_result(MISSING_CONFIG_KEY, 'cache') + + assert payload == { + 'ok': False, + 'message': f'参数缓存不存在:{MISSING_CONFIG_KEY}', + 'source': 'cache', + 'exit_code': RUNTIME_ERROR, + } + + +def test_config_domain_support_serializes_cache_payload_with_sanitizer() -> None: + """ + 校验参数配置领域支持对象会对缓存配置做脱敏序列化。 + + :return: None + """ + + class FakeSanitizer: + """ + 模拟脱敏工具。 + """ + + @staticmethod + def sanitize_data(payload: dict[str, str]) -> dict[str, str]: + """ + 返回带标记的脱敏结果。 + + :param payload: 原始载荷 + :return: 脱敏结果 + """ + return {**payload, 'sanitized': 'yes'} + + gateway = ConfigInfrastructureGateway() + support = ConfigDomainSupport(gateway) + + def _fake_get_log_sanitizer() -> FakeSanitizer: + return FakeSanitizer() + + object.__setattr__(gateway, 'get_log_sanitizer', _fake_get_log_sanitizer) + + payload = support.serialize_cache_payload(MISSING_CONFIG_KEY, 'demo-value') + + assert payload == { + 'configKey': MISSING_CONFIG_KEY, + 'configValue': 'demo-value', + 'sanitized': 'yes', + } + + +def test_config_runtime_service_builds_cli_config_model_from_orm_record() -> None: + """ + 校验 CLI 会将 ORM 配置记录显式映射为可序列化的配置模型。 + + :return: None + """ + + class FakeOrmConfigRecord: + """ + 模拟 ORM 配置记录。 + """ + + config_id = 2 + config_name = '用户管理-账号初始密码' + config_key = 'sys.user.initPassword' + config_value = '123456' + config_type = 'Y' + create_by = 'admin' + create_time = None + update_by = '' + update_time = None + remark = '初始化密码 123456' + + class FakeConfigModel: + """ + 模拟 VO 配置模型。 + """ + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + class FakeConfigVoModule: + """ + 模拟配置 VO 模块。 + """ + + ConfigModel = FakeConfigModel + + model = ConfigRuntimeService.build_cli_config_model(FakeConfigVoModule, FakeOrmConfigRecord()) + + assert isinstance(model, FakeConfigModel) + assert model.kwargs == { + 'configId': 2, + 'configName': '用户管理-账号初始密码', + 'configKey': 'sys.user.initPassword', + 'configValue': '123456', + 'configType': 'Y', + 'createBy': 'admin', + 'createTime': None, + 'updateBy': '', + 'updateTime': None, + 'remark': '初始化密码 123456', + } + + +@pytest.mark.asyncio +async def test_config_runtime_service_returns_missing_db_config_result() -> None: + """ + 校验参数配置运行时在数据库中未找到配置时会返回统一结果。 + + :return: None + """ + + class FakeSession: + """ + 模拟异步会话。 + """ + + async def __aenter__(self) -> 'FakeSession': + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> None: + del exc_type, exc, tb + + class FakeSessionFactory: + """ + 模拟异步会话工厂。 + """ + + def __call__(self) -> FakeSession: + return FakeSession() + + class FakeConfigModel: + """ + 模拟配置模型。 + """ + + def __init__(self, **kwargs: str) -> None: + self.kwargs = kwargs + + class FakeConfigVoModule: + """ + 模拟配置 VO 模块。 + """ + + ConfigModel = FakeConfigModel + + class FakeConfigDao: + """ + 模拟配置 DAO。 + """ + + @staticmethod + async def get_config_detail_by_info(session: FakeSession, config_model: FakeConfigModel) -> None: + """ + 返回空结果。 + + :param session: 数据库会话 + :param config_model: 配置模型 + :return: None + """ + assert isinstance(session, FakeSession) + assert config_model.kwargs == {'configKey': MISSING_CONFIG_KEY} + + gateway = ConfigInfrastructureGateway() + service = ConfigRuntimeService(infrastructure_gateway=gateway) + + def _fake_get_async_session_local() -> FakeSessionFactory: + return FakeSessionFactory() + + def _fake_get_config_dao() -> FakeConfigDao: + return FakeConfigDao() + + def _fake_get_config_vo_module() -> FakeConfigVoModule: + return FakeConfigVoModule() + + object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local) + object.__setattr__(gateway, 'get_config_dao', _fake_get_config_dao) + object.__setattr__(gateway, 'get_config_vo_module', _fake_get_config_vo_module) + + payload = await service.get_config(MISSING_CONFIG_KEY, source='db') + + assert payload == { + 'ok': False, + 'message': f'参数配置不存在:{MISSING_CONFIG_KEY}', + 'source': 'db', + 'exit_code': RUNTIME_ERROR, + } diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_crypto.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_crypto.py new file mode 100644 index 0000000..b68b82f --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_crypto.py @@ -0,0 +1,149 @@ +import json +from types import SimpleNamespace + +from cli.exit_codes import ARGUMENT_ERROR +from cli.runtime.crypto import CryptoRuntimeService +from cli.runtime.crypto.gateway import CryptoInfrastructureGateway +from cli.runtime.crypto.support import CryptoDomainSupport + +INVALID_RSA_KEY_SIZE = 1024 +VALID_RSA_KEY_SIZE = 2048 + + +def test_crypto_domain_support_rejects_invalid_rsa_key_size() -> None: + """ + 校验传输加密领域支持对象会拒绝非法 RSA 密钥长度。 + + :return: None + """ + support = CryptoDomainSupport(CryptoInfrastructureGateway()) + + try: + support.validate_rsa_key_size(INVALID_RSA_KEY_SIZE) + except ValueError as exc: + assert 'RSA 密钥长度必须大于等于 2048' in str(exc) + else: + raise AssertionError('expected ValueError for invalid RSA key size') + + +def test_crypto_domain_support_rejects_non_list_legacy_key_pairs() -> None: + """ + 校验传输加密领域支持对象会拒绝非数组格式的历史密钥配置。 + + :return: None + """ + gateway = CryptoInfrastructureGateway() + support = CryptoDomainSupport(gateway) + + def _fake_transport_crypto_config() -> SimpleNamespace: + return SimpleNamespace(transport_crypto_legacy_key_pairs='{"kid": "legacy"}') + + object.__setattr__(gateway, 'get_transport_crypto_config', _fake_transport_crypto_config) + + try: + support.load_existing_legacy_key_pairs() + except ValueError as exc: + assert 'TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS 必须为 JSON 数组' in str(exc) + else: + raise AssertionError('expected ValueError for invalid legacy key pairs payload') + + +def test_crypto_runtime_service_keygen_returns_argument_error_for_invalid_size() -> None: + """ + 校验传输加密运行时在非法密钥长度下返回参数错误结果。 + + :return: None + """ + service = CryptoRuntimeService() + + payload = service.generate_crypto_key_pair('kid-20260511', INVALID_RSA_KEY_SIZE) + + assert payload['ok'] is False + assert payload['exit_code'] == ARGUMENT_ERROR + assert payload['message'] == '生成传输加密密钥失败' + + +def test_crypto_runtime_service_rotation_rejects_same_kid() -> None: + """ + 校验传输加密运行时会拒绝与当前版本相同的新密钥版本。 + + :return: None + """ + gateway = CryptoInfrastructureGateway() + service = CryptoRuntimeService(infrastructure_gateway=gateway) + + provider = SimpleNamespace( + validate_runtime_configuration=lambda: None, + get_current_key_pair=lambda: SimpleNamespace( + kid='kid-current', + private_key_pem='PRIVATE-CURRENT', + public_key_pem='PUBLIC-CURRENT', + ), + ) + + object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider) + + payload = service.build_rotation_payload('kid-current', VALID_RSA_KEY_SIZE) + + assert payload['ok'] is False + assert payload['exit_code'] == ARGUMENT_ERROR + assert payload['message'] == '生成密钥轮换方案失败' + assert payload['error'] == '新密钥版本不能与当前版本相同' + + +def test_crypto_runtime_service_rotation_builds_merged_payload() -> None: + """ + 校验传输加密运行时会生成包含历史密钥合并结果的轮换辅助数据。 + + :return: None + """ + gateway = CryptoInfrastructureGateway() + domain_support = CryptoDomainSupport(gateway) + service = CryptoRuntimeService( + infrastructure_gateway=gateway, + domain_support=domain_support, + ) + provider = SimpleNamespace( + validate_runtime_configuration=lambda: None, + get_current_key_pair=lambda: SimpleNamespace( + kid='kid-current', + private_key_pem='PRIVATE-CURRENT', + public_key_pem='PUBLIC-CURRENT', + ), + ) + + object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider) + object.__setattr__( + domain_support, + 'generate_rsa_key_pair', + lambda key_size: ('PRIVATE-NEXT', 'PUBLIC-NEXT'), + ) + object.__setattr__( + domain_support, + 'load_existing_legacy_key_pairs', + lambda: [ + {'kid': 'kid-legacy', 'privateKey': 'PRIVATE-LEGACY', 'publicKey': 'PUBLIC-LEGACY'}, + {'kid': 'kid-next', 'privateKey': 'STALE-PRIVATE', 'publicKey': 'STALE-PUBLIC'}, + ], + ) + + payload = service.build_rotation_payload('kid-next', VALID_RSA_KEY_SIZE) + + assert payload['ok'] is True + assert payload['currentKid'] == 'kid-current' + assert payload['nextKid'] == 'kid-next' + assert payload['nextKeyPair']['privateKey'] == 'PRIVATE-NEXT' + assert payload['nextKeyPair']['publicKey'] == 'PUBLIC-NEXT' + assert payload['envPatch']['TRANSPORT_CRYPTO_KID'] == 'kid-next' + assert payload['envPatch']['TRANSPORT_CRYPTO_PRIVATE_KEY'] == 'PRIVATE-NEXT' + assert payload['envPatch']['TRANSPORT_CRYPTO_PUBLIC_KEY'] == 'PUBLIC-NEXT' + + legacy_key_pairs = payload['legacyKeyPairs'] + legacy_by_kid = {item['kid']: item for item in legacy_key_pairs} + assert 'kid-next' not in legacy_by_kid + assert legacy_by_kid['kid-current']['privateKey'] == 'PRIVATE-CURRENT' + assert legacy_by_kid['kid-legacy']['publicKey'] == 'PUBLIC-LEGACY' + + serialized_legacy = json.loads(payload['envPatch']['TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS']) + serialized_by_kid = {item['kid']: item for item in serialized_legacy} + assert serialized_by_kid['kid-current']['publicKey'] == 'PUBLIC-CURRENT' diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_db.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_db.py new file mode 100644 index 0000000..4536198 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_db.py @@ -0,0 +1,144 @@ +from pathlib import Path + +import pytest + +from cli.runtime.base import RuntimeEnvironmentService +from cli.runtime.db import DatabaseRuntimeService +from cli.runtime.db.gateway import DatabaseInfrastructureGateway +from cli.runtime.db.support import DatabaseRevisionSupport + + +class FakeRuntimeEnvironment(RuntimeEnvironmentService): + """ + 模拟运行时环境服务。 + """ + + @staticmethod + def get_backend_dir() -> str: + """ + 返回固定后端目录。 + + :return: 固定目录 + """ + return '/tmp/shierfeng-backend' + + +def test_database_revision_support_serializes_revision() -> None: + """ + 校验数据库迁移版本支持对象会序列化 Alembic revision。 + + :return: None + """ + + class FakeRevision: + """ + 模拟 Alembic revision 对象。 + """ + + revision = '202605110001' + down_revision = ('202605100001',) + branch_labels = {'main'} + dependencies = None + doc = ' demo revision ' + path = Path('/tmp/revision.py') + + support = DatabaseRevisionSupport(DatabaseInfrastructureGateway(), FakeRuntimeEnvironment()) + + payload = support.serialize_revision(FakeRevision()) + + assert payload == { + 'revision': '202605110001', + 'downRevisions': ['202605100001'], + 'branchLabels': ['main'], + 'dependsOn': [], + 'doc': 'demo revision', + 'path': '/tmp/revision.py', + } + + +def test_database_runtime_service_upgrade_dry_run_returns_command_payload() -> None: + """ + 校验数据库运行时 dry-run 升级会返回命令预览结果。 + + :return: None + """ + service = DatabaseRuntimeService(runtime_environment=FakeRuntimeEnvironment()) + + payload = service.upgrade_database('head', dry_run=True) + + assert payload['ok'] is True + assert payload['dryRun'] is True + assert payload['cwd'] == '/tmp/shierfeng-backend' + assert payload['command'] == ['alembic', '-c', '/tmp/shierfeng-backend/alembic.ini', 'upgrade', 'head'] + + +@pytest.mark.asyncio +async def test_database_runtime_service_ping_database_returns_failure() -> None: + """ + 校验数据库运行时在连接异常时会返回失败结果。 + + :return: None + """ + + class FakeConnection: + """ + 模拟数据库连接对象。 + """ + + async def __aenter__(self) -> 'FakeConnection': + raise RuntimeError('db boom') + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> None: + del exc_type, exc, tb + + class FakeEngine: + """ + 模拟数据库引擎。 + """ + + @staticmethod + def connect() -> FakeConnection: + """ + 返回模拟连接对象。 + + :return: 模拟连接对象 + """ + return FakeConnection() + + @staticmethod + async def dispose() -> None: + """ + 释放引擎资源。 + + :return: None + """ + + gateway = DatabaseInfrastructureGateway() + service = DatabaseRuntimeService( + runtime_environment=FakeRuntimeEnvironment(), + infrastructure_gateway=gateway, + ) + + def _fake_get_async_db_engine_factory() -> object: + def _factory(*, echo: bool = False) -> FakeEngine: + del echo + return FakeEngine() + + return _factory + + def _fake_get_sqlalchemy_text() -> object: + return lambda sql: sql + + object.__setattr__(gateway, 'get_async_db_engine_factory', _fake_get_async_db_engine_factory) + object.__setattr__(gateway, 'get_sqlalchemy_text', _fake_get_sqlalchemy_text) + + payload = await service.ping_database() + + assert payload['ok'] is False + assert payload['message'] == '数据库连接失败' + assert 'db boom' in payload['error'] diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_dev.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_dev.py new file mode 100644 index 0000000..ab33362 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_dev.py @@ -0,0 +1,126 @@ +from cli.exit_codes import DEPENDENCY_ERROR +from cli.runtime.base import RuntimeEnvironmentService +from cli.runtime.dev import DevelopmentRuntimeService +from cli.runtime.dev.gateway import DevelopmentProcessGateway +from cli.runtime.dev.support import DevelopmentCommandSupport, DevelopmentToolingSupport + + +class FakeRuntimeEnvironment(RuntimeEnvironmentService): + """ + 模拟运行时环境服务。 + """ + + @staticmethod + def get_backend_dir() -> str: + """ + 返回固定后端目录。 + + :return: 固定目录 + """ + return '/tmp/shierfeng-backend' + + @staticmethod + def get_python_executable() -> str: + """ + 返回固定 Python 可执行文件。 + + :return: Python 可执行文件路径 + """ + return '/usr/bin/python3' + + +def test_development_runtime_service_returns_dependency_error_when_pytest_missing() -> None: + """ + 校验开发运行时在 pytest 缺失时会返回依赖错误结果。 + + :return: None + """ + + class FakeToolingSupport(DevelopmentToolingSupport): + """ + 模拟开发工具支持对象。 + """ + + @staticmethod + def is_pytest_available() -> bool: + """ + 返回 pytest 不可用状态。 + + :return: False + """ + return False + + service = DevelopmentRuntimeService( + runtime_environment=FakeRuntimeEnvironment(), + tooling_support=FakeToolingSupport(), + ) + + payload = service.run_tests(['tests/cli']) + + assert payload == { + 'ok': False, + 'message': 'pytest 未安装,无法执行 dev test', + 'hint': '请在当前环境安装 pytest 后重试', + 'exit_code': DEPENDENCY_ERROR, + } + + +def test_development_runtime_service_run_lint_uses_command_support() -> None: + """ + 校验开发运行时会通过命令支持对象串联 format/check 执行流程。 + + :return: None + """ + + class FakeCommandSupport(DevelopmentCommandSupport): + """ + 模拟开发命令支持对象。 + """ + + def __init__(self) -> None: + self.recorded_commands: list[list[str]] = [] + + def build_format_command(self, normalized_targets: list[str], *, check_only: bool) -> list[str]: + assert normalized_targets == ['tests/cli'] + assert check_only is True + return ['format-command'] + + def build_check_command( + self, + normalized_targets: list[str], + *, + check_only: bool, + fix: bool, + unsafe_fixes: bool, + ) -> list[str]: + assert normalized_targets == ['tests/cli'] + assert check_only is True + assert fix is False + assert unsafe_fixes is False + return ['check-command'] + + class FakeProcessGateway(DevelopmentProcessGateway): + """ + 模拟开发子进程执行网关。 + """ + + def __init__(self) -> None: + self.recorded_commands: list[list[str]] = [] + + def run_command(self, command: list[str]) -> dict[str, object]: + self.recorded_commands.append(command) + return {'ok': True, 'command': command, 'returnCode': 0} + + command_support = FakeCommandSupport() + process_gateway = FakeProcessGateway() + service = DevelopmentRuntimeService( + runtime_environment=FakeRuntimeEnvironment(), + command_support=command_support, + process_gateway=process_gateway, + ) + + payload = service.run_lint(['tests/cli'], check_only=True) + + assert payload['ok'] is True + assert payload['targets'] == ['tests/cli'] + assert process_gateway.recorded_commands == [['format-command'], ['check-command']] diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_gen.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_gen.py new file mode 100644 index 0000000..b012568 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_gen.py @@ -0,0 +1,98 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from cli.exit_codes import ARGUMENT_ERROR +from cli.runtime.gen import GenRuntimeService +from cli.runtime.gen.gateway import GenInfrastructureGateway +from cli.runtime.gen.support import GenDomainSupport + +REDIS_UNUSED = 'unused' + + +def test_gen_domain_support_resolves_sql_text_from_argument() -> None: + """ + 校验代码生成领域支持对象会优先使用直接传入的 SQL 文本。 + + :return: None + """ + support = GenDomainSupport(GenInfrastructureGateway()) + + payload = support.resolve_sql_text(' CREATE TABLE demo(id bigint); ', '') + + assert payload == 'CREATE TABLE demo(id bigint);' + + +def test_gen_domain_support_resolve_sql_text_rejects_invalid_inputs(tmp_path: Path) -> None: + """ + 校验代码生成领域支持对象会拒绝无效 SQL 输入组合。 + + :param tmp_path: pytest 临时目录 + :return: None + """ + support = GenDomainSupport(GenInfrastructureGateway()) + sql_file = tmp_path / 'demo.sql' + sql_file.write_text('select 1', encoding='utf-8') + + try: + support.resolve_sql_text('select 1', str(sql_file)) + except ValueError as exc: + assert '必须且只能传入 --sql 或 --sql-file 其中一种方式' in str(exc) + else: + raise AssertionError('expected ValueError for conflicting SQL inputs') + + +def test_gen_domain_support_writes_export_zip(tmp_path: Path) -> None: + """ + 校验代码生成领域支持对象会写出导出压缩包。 + + :param tmp_path: pytest 临时目录 + :return: None + """ + support = GenDomainSupport(GenInfrastructureGateway()) + target_file = tmp_path / 'exports' / 'gen.zip' + + output_path = support.write_export_zip(str(target_file), b'zip-content') + + assert output_path == str(target_file.resolve()) + assert target_file.read_bytes() == b'zip-content' + + +@pytest.mark.asyncio +async def test_gen_runtime_service_export_code_dry_run_returns_preview_payload() -> None: + """ + 校验代码生成运行时在 dry-run 导出时会返回结构化预览结果。 + + :return: None + """ + gateway = GenInfrastructureGateway() + service = GenRuntimeService(infrastructure_gateway=gateway) + + def _fake_get_gen_config() -> SimpleNamespace: + return SimpleNamespace(allow_overwrite=True, GEN_PATH='/tmp/gen') + + object.__setattr__(gateway, 'get_gen_config', _fake_get_gen_config) + + payload = await service.export_code(['sys_user', 'sys_role'], mode='zip', dry_run=True) + + assert payload['ok'] is True + assert payload['dryRun'] is True + assert payload['tableNames'] == ['sys_user', 'sys_role'] + assert payload['outputFile'].endswith('gen_code_sys_user_sys_role.zip') + + +@pytest.mark.asyncio +async def test_gen_runtime_service_import_tables_requires_at_least_one_table() -> None: + """ + 校验代码生成运行时在未传入表名时会返回参数错误。 + + :return: None + """ + service = GenRuntimeService() + + payload = await service.import_tables([]) + + assert payload['ok'] is False + assert payload['exit_code'] == ARGUMENT_ERROR + assert payload['message'] == '至少需要传入一个表名' diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_job.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_job.py new file mode 100644 index 0000000..cee0f4e --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_job.py @@ -0,0 +1,174 @@ +import pytest + +from cli.runtime.job import JobRuntimeService +from cli.runtime.job.gateway import JobInfrastructureGateway +from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport + +MISSING_JOB_ID = 99 + + +def test_job_domain_support_builds_operation_metadata() -> None: + """ + 校验任务领域支持对象会返回统一的任务操作元数据。 + + :return: None + """ + support = JobDomainSupport(JobInfrastructureGateway()) + + payload = support.build_job_operation_metadata('pause') + + assert payload == { + 'operationLabel': '暂停任务', + 'successMessage': '定时任务已暂停', + } + + +def test_job_domain_support_serializes_model_dump_items() -> None: + """ + 校验任务领域支持对象会序列化带 `model_dump` 的任务项。 + + :return: None + """ + + class FakeJobModel: + """ + 模拟任务模型。 + """ + + @staticmethod + def model_dump(*, by_alias: bool = False, exclude_none: bool = False) -> dict[str, object]: + """ + 返回模拟序列化结果。 + + :param by_alias: 是否按别名输出 + :param exclude_none: 是否排除空值 + :return: 模拟序列化结果 + """ + assert by_alias is True + assert exclude_none is True + return {'jobId': 1, 'jobName': 'sync-job'} + + support = JobDomainSupport(JobInfrastructureGateway()) + + payload = support.serialize_job_item(FakeJobModel()) + + assert payload == {'jobId': 1, 'jobName': 'sync-job'} + + +@pytest.mark.asyncio +async def test_job_scheduler_support_closes_scheduler_and_redis() -> None: + """ + 校验任务调度支持对象会关闭调度器和 Redis 资源。 + + :return: None + """ + close_events: list[str] = [] + + class FakeRedis: + """ + 模拟 Redis 客户端。 + """ + + @staticmethod + async def close() -> None: + """ + 记录 Redis 关闭事件。 + + :return: None + """ + close_events.append('redis') + + class FakeSchedulerUtil: + """ + 模拟调度器工具。 + """ + + @staticmethod + async def close_system_scheduler() -> None: + """ + 记录调度器关闭事件。 + + :return: None + """ + close_events.append('scheduler') + + gateway = JobInfrastructureGateway() + support = JobSchedulerSupport(gateway) + + def _fake_get_scheduler_util() -> FakeSchedulerUtil: + return FakeSchedulerUtil() + + object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util) + + await support.close_scheduler_context(FakeRedis()) + + assert close_events == ['scheduler', 'redis'] + + +@pytest.mark.asyncio +async def test_job_runtime_service_reports_missing_job_detail() -> None: + """ + 校验任务运行时在任务详情不存在时会返回统一结果。 + + :return: None + """ + + class FakeSession: + """ + 模拟异步会话。 + """ + + async def __aenter__(self) -> 'FakeSession': + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> None: + del exc_type, exc, tb + + class FakeSessionFactory: + """ + 模拟异步会话工厂。 + """ + + def __call__(self) -> FakeSession: + return FakeSession() + + class FakeJobService: + """ + 模拟任务服务。 + """ + + @staticmethod + async def job_detail_services(session: FakeSession, job_id: int) -> dict[str, object]: + """ + 返回不存在任务的空结果。 + + :param session: 数据库会话 + :param job_id: 任务 ID + :return: 空结果 + """ + assert isinstance(session, FakeSession) + assert job_id == MISSING_JOB_ID + return {} + + gateway = JobInfrastructureGateway() + service = JobRuntimeService(infrastructure_gateway=gateway) + + def _fake_get_async_session_local() -> FakeSessionFactory: + return FakeSessionFactory() + + def _fake_get_job_service() -> FakeJobService: + return FakeJobService() + + object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local) + object.__setattr__(gateway, 'get_job_service', _fake_get_job_service) + + payload = await service.get_job_detail(MISSING_JOB_ID) + + assert payload['ok'] is False + assert payload['jobId'] == MISSING_JOB_ID + assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}' diff --git a/shierfeng-fastapi-backend/tests/cli/runtime/test_ops.py b/shierfeng-fastapi-backend/tests/cli/runtime/test_ops.py new file mode 100644 index 0000000..6cc56c7 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/runtime/test_ops.py @@ -0,0 +1,167 @@ +import socket +from types import SimpleNamespace + +import pytest + +from cli.runtime.ops import OperationsRuntimeService +from cli.runtime.ops.gateway import OperationsInfrastructureGateway +from cli.runtime.ops.support import OperationsDependencyInspector, OperationsServerInfoSupport + + +def test_operations_dependency_inspector_reports_missing_required(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 校验运维依赖检查器会报告缺失的必需依赖。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + inspector = OperationsDependencyInspector() + monkeypatch.setattr( + inspector, + 'read_package_version', + lambda distribution_name: None if distribution_name == 'fastapi' else '1.0.0', + ) + + payload = inspector.inspect() + + assert payload['ok'] is False + assert payload['missingRequired'] == ['fastapi'] + assert payload['packages']['python']['installed'] is True + + +def test_operations_server_info_support_resolves_non_loopback_ip(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 校验服务器信息支持对象会在主机名解析失败时回退到网卡地址。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + + class FakePsutil: + """ + 模拟 psutil 模块。 + """ + + @staticmethod + def net_if_addrs() -> dict[str, list[SimpleNamespace]]: + """ + 返回模拟网卡地址。 + + :return: 模拟网卡地址映射 + """ + return { + 'en0': [ + SimpleNamespace(family=socket.AF_INET, address='127.0.0.1'), + SimpleNamespace(family=socket.AF_INET, address='192.168.1.8'), + ] + } + + gateway = OperationsInfrastructureGateway() + support = OperationsServerInfoSupport(gateway) + + def _fake_get_psutil_module() -> FakePsutil: + return FakePsutil() + + def _fake_gethostbyname(hostname: str) -> str: + del hostname + raise OSError('boom') + + monkeypatch.setattr(gateway, 'get_psutil_module', _fake_get_psutil_module) + monkeypatch.setattr(socket, 'gethostbyname', _fake_gethostbyname) + + resolved_ip = support.resolve_server_ip('demo-host') + + assert resolved_ip == '192.168.1.8' + + +@pytest.mark.asyncio +async def test_operations_runtime_service_sync_jobs_closes_resources_on_success() -> None: + """ + 校验运维运行时在同步调度成功后会关闭调度器和 Redis 资源。 + + :return: None + """ + close_events: list[str] = [] + + class FakeRedis: + """ + 模拟 Redis 客户端。 + """ + + @staticmethod + async def close() -> None: + """ + 记录关闭事件。 + + :return: None + """ + close_events.append('redis') + + class FakeRedisUtil: + """ + 模拟 Redis 工具。 + """ + + @staticmethod + async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis: + """ + 返回模拟 Redis 客户端。 + + :param log_enabled: 是否启用日志 + :return: 模拟 Redis 客户端 + """ + del log_enabled + return FakeRedis() + + class FakeSchedulerUtil: + """ + 模拟调度器工具。 + """ + + _is_leader = True + + @staticmethod + async def init_system_scheduler(redis: FakeRedis) -> None: + """ + 初始化调度器。 + + :param redis: Redis 客户端 + :return: None + """ + assert isinstance(redis, FakeRedis) + + @staticmethod + async def request_scheduler_sync() -> None: + """ + 发送调度同步请求。 + + :return: None + """ + + @staticmethod + async def close_system_scheduler() -> None: + """ + 记录调度器关闭事件。 + + :return: None + """ + close_events.append('scheduler') + + gateway = OperationsInfrastructureGateway() + service = OperationsRuntimeService(infrastructure_gateway=gateway) + + def _fake_get_redis_util() -> FakeRedisUtil: + return FakeRedisUtil() + + def _fake_get_scheduler_util() -> FakeSchedulerUtil: + return FakeSchedulerUtil() + + object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util) + object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util) + + payload = await service.sync_jobs() + + assert payload['ok'] is True + assert payload['schedulerSyncRequested'] is True + assert payload['isLeader'] is True + assert close_events == ['scheduler', 'redis'] diff --git a/shierfeng-fastapi-backend/tests/cli/tui/actions/test_action_execution.py b/shierfeng-fastapi-backend/tests/cli/tui/actions/test_action_execution.py new file mode 100644 index 0000000..a32b61a --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/actions/test_action_execution.py @@ -0,0 +1,579 @@ +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +from pytest import MonkeyPatch + +BACKEND_DIR = Path(__file__).resolve().parents[4] + +sys.path.insert(0, str(BACKEND_DIR)) +sys.modules.pop('cli.tui.capabilities', None) +sys.modules.pop('cli.tui.actions', None) +sys.modules.pop('cli.wizard.flows.gen_import', None) +sys.modules.pop('cli', None) + +capabilities_module = importlib.import_module('cli.tui.capabilities') +actions_module = importlib.import_module('cli.tui.actions') +action_assembly_module = importlib.import_module('cli.tui.actions.assembly') +action_bootstrap_module = importlib.import_module('cli.tui.actions.bootstrap') +action_execution_module = importlib.import_module('cli.tui.actions.execution') +adapters_module = importlib.import_module('cli.tui.adapters') + + +def test_capabilities_registry_describes_supported_views() -> None: + """ + 校验 TUI 能力注册表会集中描述页面支持的动作能力。 + + :return: None + """ + cache_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_browser_capabilities('cache') + gen_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_browser_capabilities('gen') + app_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('app') + database_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('database') + ops_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('ops') + crypto_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('crypto') + + assert [capability.slot for capability in cache_capabilities] == ['global', 'utility'] + assert [capability.kind for capability in cache_capabilities] == ['wizard_entry', 'low_risk_action'] + assert [capability.hint_label for capability in cache_capabilities] == ['清理向导', '执行缓存预热'] + assert [capability.slot for capability in gen_capabilities] == ['primary', 'secondary', 'global', 'utility'] + assert [capability.kind for capability in gen_capabilities] == [ + 'wizard_entry', + 'wizard_entry', + 'preview', + 'low_risk_action', + ] + assert [capability.hint_label for capability in gen_capabilities] == [ + '导出向导', + '导入向导', + '导出预演', + '同步表结构', + ] + assert [capability.slot for capability in app_capabilities] == ['primary', 'global', 'utility'] + assert [capability.kind for capability in app_capabilities] == ['wizard_entry', 'wizard_entry', 'command_hint'] + assert [capability.hint_label for capability in app_capabilities] == ['直接启动', '打开启动向导', '安装补全'] + assert [capability.slot for capability in database_capabilities] == ['global', 'utility'] + assert [capability.kind for capability in database_capabilities] == ['wizard_entry', 'preview'] + assert [capability.hint_label for capability in database_capabilities] == ['打开升级向导', '初始化预演'] + assert [capability.slot for capability in ops_capabilities] == ['primary', 'secondary', 'global'] + assert [capability.kind for capability in ops_capabilities] == [ + 'low_risk_action', + 'low_risk_action', + 'wizard_entry', + ] + assert [capability.hint_label for capability in ops_capabilities] == [ + '数据库探活', + 'Redis 探活', + '打开生产巡检向导', + ] + assert [capability.slot for capability in crypto_capabilities] == ['primary', 'global'] + assert [capability.kind for capability in crypto_capabilities] == ['wizard_entry', 'preview'] + assert [capability.hint_label for capability in crypto_capabilities] == ['密钥生成', '执行轮换预演'] + + +def test_action_registry_resolves_browser_action_for_job_record() -> None: + """ + 校验任务页会解析出执行一次、暂停/恢复和同步动作。 + + :return: None + """ + record = adapters_module.BrowserRecordSnapshot( + key='job:101', + title='同步任务', + status='warn', + summary='暂停 · Cron 0/30 * * * * ?', + metadata_lines=[], + detail_sections=[], + ) + + primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='jobs', + slot='primary', + record=record, + env='dev', + ) + secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='jobs', + slot='secondary', + record=record, + env='dev', + ) + global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='jobs', + slot='global', + record=record, + env='dev', + ) + + assert primary_action is not None + assert primary_action.command_args == ('job', 'run-once', '101') + assert secondary_action is not None + assert secondary_action.command_args == ('job', 'resume', '101') + assert global_action is not None + assert global_action.command_args == ('job', 'sync') + + +def test_action_registry_resolves_browser_action_for_cache_and_gen_entries() -> None: + """ + 校验缓存页和代码生成页会解析出外部向导动作。 + + :return: None + """ + cache_record = adapters_module.BrowserRecordSnapshot( + key='cache:sys_config', + title='sys_config', + status='ok', + summary='系统参数缓存', + metadata_lines=[], + detail_sections=[], + ) + gen_record = adapters_module.BrowserRecordSnapshot( + key='gen:201', + title='sys_user', + status='ok', + summary='生成类 SysUser · 模块 system', + metadata_lines=[], + detail_sections=[], + ) + + cache_global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='cache', + slot='global', + record=cache_record, + env='dev', + ) + cache_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='cache', + slot='utility', + record=cache_record, + env='dev', + ) + gen_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='gen', + slot='primary', + record=gen_record, + env='dev', + ) + gen_secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='gen', + slot='secondary', + record=gen_record, + env='dev', + ) + gen_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='gen', + slot='utility', + record=gen_record, + env='dev', + ) + gen_global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='gen', + slot='global', + record=gen_record, + env='dev', + ) + + assert cache_global_action is not None + assert cache_global_action.execution_mode == 'external' + assert cache_global_action.command_args == ( + 'wizard', + 'cache-clear', + '--output=text', + '--default-env=dev', + '--default-mode=cache-name', + '--default-cache-name=sys_config', + '--default-dry-run', + ) + assert cache_utility_action is not None + assert cache_utility_action.execution_mode == 'nested_json' + assert gen_primary_action is not None + assert gen_primary_action.execution_mode == 'external' + assert gen_primary_action.command_args == ( + 'wizard', + 'gen-export', + '--output=text', + '--default-env=dev', + '--default-table-names=sys_user', + '--default-mode=zip', + '--default-dry-run', + ) + assert gen_primary_action.refresh_view is False + assert gen_secondary_action is not None + assert gen_secondary_action.execution_mode == 'external' + assert gen_secondary_action.command_args == ( + 'wizard', + 'gen-import', + '--output=text', + '--default-env=dev', + '--default-table-names=sys_user', + '--default-dry-run', + ) + assert gen_secondary_action.refresh_view is False + assert gen_global_action is not None + assert gen_global_action.execution_mode == 'nested_json' + assert gen_global_action.command_args == ('gen', 'export', 'sys_user', '--dry-run', '--mode=zip') + assert gen_utility_action is not None + assert gen_utility_action.execution_mode == 'nested_json' + assert gen_utility_action.command_args == ('gen', 'sync-db', 'sys_user') + + +def test_action_template_factories_build_expected_job_and_gen_templates() -> None: + """ + 校验领域动作模板工厂会生成符合预期的命令参数。 + + :return: None + """ + job_record = adapters_module.BrowserRecordSnapshot( + key='job:88', + title='通知任务', + status='ok', + summary='运行中 · Cron 0 0/5 * * * ?', + metadata_lines=[], + detail_sections=[], + ) + gen_record = adapters_module.BrowserRecordSnapshot( + key='gen:301', + title='sys_notice', + status='ok', + summary='生成类 SysNotice · 模块 system', + metadata_lines=[], + detail_sections=[], + ) + + run_once_template = action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY.create_run_once_template() + toggle_template = action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY.create_toggle_template() + export_template = action_bootstrap_module._GEN_ACTION_TEMPLATE_FACTORY.create_export_wizard_template() + + assert run_once_template.command_builder(job_record, 'dev') == ('job', 'run-once', '88') + assert toggle_template.command_builder(job_record, 'dev') == ('job', 'pause', '88') + assert toggle_template.label_builder is not None + assert toggle_template.label_builder(job_record, 'dev') == '暂停任务' + assert export_template.command_builder(gen_record, 'dev') == ( + 'wizard', + 'gen-export', + '--output=text', + '--default-env=dev', + '--default-table-names=sys_notice', + '--default-mode=zip', + '--default-dry-run', + ) + + +def test_action_registry_builder_assembles_expected_slots() -> None: + """ + 校验动作注册表构建器会装配浏览页与详情页动作槽位。 + + :return: None + """ + registry = action_assembly_module.TuiActionRegistryBuilder( + jobs=action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY, + cache=action_bootstrap_module._CACHE_ACTION_TEMPLATE_FACTORY, + gen=action_bootstrap_module._GEN_ACTION_TEMPLATE_FACTORY, + static=action_bootstrap_module._STATIC_ACTION_TEMPLATE_FACTORY, + spec_factory=action_bootstrap_module._ACTION_SPEC_FACTORY, + ).build() + + assert sorted(registry.browser_resolvers) == ['cache', 'configs', 'gen', 'jobs'] + assert sorted(registry.detail_resolvers) == ['app', 'crypto', 'database', 'ops'] + assert sorted(registry.browser_resolvers['gen'].slot_templates) == ['global', 'primary', 'secondary', 'utility'] + assert sorted(registry.detail_resolvers['ops'].slot_templates) == ['global', 'primary', 'secondary'] + + +def test_action_registry_resolves_detail_action_for_database_and_ops_wizard_entries() -> None: + """ + 校验详情页会解析出数据库升级和生产巡检向导动作。 + + :return: None + """ + database_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='database', + slot='global', + env='dev', + ) + database_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='database', + slot='utility', + env='dev', + ) + app_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='app', + slot='global', + env='dev', + ) + app_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='app', + slot='primary', + env='dev', + ) + app_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='app', + slot='utility', + env='dev', + ) + ops_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='ops', + slot='primary', + env='dev', + ) + ops_secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='ops', + slot='secondary', + env='dev', + ) + ops_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='ops', + slot='global', + env='dev', + ) + crypto_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='crypto', + slot='global', + env='dev', + ) + crypto_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='crypto', + slot='primary', + env='dev', + ) + unknown_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='dashboard', + slot='global', + env='dev', + ) + + assert app_primary_action is not None + assert app_primary_action.execution_mode == 'external' + assert app_primary_action.command_args == ('app', 'run', '--env=dev') + assert app_primary_action.refresh_view is False + assert app_action is not None + assert app_action.execution_mode == 'external' + assert app_action.command_args == ('wizard', 'app-run') + assert app_action.refresh_view is False + assert app_utility_action is not None + assert app_utility_action.execution_mode == 'external' + assert app_utility_action.command_args == ('completion', 'install', '--activate') + assert app_utility_action.refresh_view is False + assert database_action is not None + assert database_action.execution_mode == 'external' + assert database_action.command_args == ( + 'wizard', + 'db-upgrade', + '--output=text', + '--default-env=dev', + '--default-revision=head', + '--default-dry-run', + ) + assert database_utility_action is not None + assert database_utility_action.execution_mode == 'nested_json' + assert database_utility_action.command_args == ('db', 'init', '--dry-run') + assert ops_primary_action is not None + assert ops_primary_action.execution_mode == 'nested_json' + assert ops_primary_action.command_args == ('ops', 'ping-db') + assert ops_secondary_action is not None + assert ops_secondary_action.execution_mode == 'nested_json' + assert ops_secondary_action.command_args == ('ops', 'ping-redis') + assert ops_action is not None + assert ops_action.execution_mode == 'external' + assert ops_action.command_args == ( + 'wizard', + 'prod-check', + '--output=text', + '--default-env=dev', + '--default-include-config', + ) + assert crypto_primary_action is not None + assert crypto_primary_action.execution_mode == 'external' + assert crypto_primary_action.command_args == ('crypto', 'keygen', '--env=dev', '--output=text') + assert crypto_primary_action.refresh_view is False + assert crypto_action is not None + assert crypto_action.execution_mode == 'nested_json' + assert crypto_action.command_args == ('crypto', 'rotate', '--dry-run') + assert unknown_action is None + + +def test_action_execution_service_execute_appends_env_output_and_yes(monkeypatch: MonkeyPatch) -> None: + """ + 校验 TUI 动作执行时会复用 CLI JSON 输出并自动附带确认参数。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + calls: list[tuple[str, ...]] = [] + + def _fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + assert parse_json is True + calls.append(arguments) + return SimpleNamespace(payload={'ok': True, 'message': '参数缓存刷新成功'}) + + monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run', _fake_run_nested_cli_command) + spec = actions_module.TuiActionSpec( + action_id='config-sync-cache', + label='刷新参数缓存', + command_args=('config', 'sync-cache'), + preview_title='刷新参数缓存', + preview_lines=['line a'], + ) + + result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute(spec, 'dev') + + assert result.ok is True + assert calls == [('config', 'sync-cache', '--env=dev', '--output=json', '--yes')] + + +def test_action_execution_service_execute_skips_yes_for_readonly_action(monkeypatch: MonkeyPatch) -> None: + """ + 校验只读动作执行时不会错误追加 `--yes`。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + calls: list[tuple[str, ...]] = [] + + def _fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + assert parse_json is True + calls.append(arguments) + return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'}) + + monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run', _fake_run_nested_cli_command) + spec = actions_module.TuiActionSpec( + action_id='ops-ping-db', + label='数据库探活', + command_args=('ops', 'ping-db'), + preview_title='数据库探活', + preview_lines=['line a'], + append_yes=False, + ) + + result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute(spec, 'dev') + + assert result.ok is True + assert calls == [('ops', 'ping-db', '--env=dev', '--output=json')] + + +def test_action_execution_service_execute_external_uses_live_nested_command(monkeypatch: MonkeyPatch) -> None: + """ + 校验外部交互动作会通过 live nested CLI helper 执行。 + + :param monkeypatch: pytest monkeypatch 工具 + :return: None + """ + calls: list[tuple[str, ...]] = [] + + def _fake_run_nested_cli_command_live(*arguments: str) -> SimpleNamespace: + calls.append(arguments) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run_live', _fake_run_nested_cli_command_live) + spec = actions_module.TuiActionSpec( + action_id='wizard-cache-clear', + label='打开缓存清理向导', + command_args=('wizard', 'cache-clear', '--output=text'), + preview_title='打开缓存清理向导', + preview_lines=['line a'], + execution_mode='external', + ) + + result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute_external(spec) + + assert result.ok is True + assert result.message == '外部交互命令已执行完成' + assert calls == [('wizard', 'cache-clear', '--output=text')] + + +def test_action_presentation_service_build_browser_action_hint_matches_supported_views() -> None: + """ + 校验浏览页动作提示会按页面输出不同文案。 + + :return: None + """ + assert '[X]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs') + assert '[Y]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('configs') + assert '[W]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('cache') + assert '[Y] 清理向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('cache') + assert '[X] 导出向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen') + assert '[Z] 导入向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen') + assert '[Y] 导出预演' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen') + assert '[W] 同步表结构' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen') + assert '失败聚合' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs') + assert '浏览键' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs') + + +def test_action_presentation_service_build_detail_action_hint_matches_supported_views() -> None: + """ + 校验详情页动作提示会按页面输出不同文案。 + + :return: None + """ + assert '[X] 直接启动' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app') + assert '[Y] 打开启动向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app') + assert '[W] 安装补全' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app') + assert '[Y] 打开升级向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('database') + assert '[X] 数据库探活' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops') + assert '[Z] Redis 探活' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops') + assert '[Y] 打开生产巡检向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops') + assert '[X] 密钥生成' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('crypto') + assert '[Y] 执行轮换预演' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('crypto') + assert ( + '环境映射、应用配置、启动前检查、补全诊断和路由状态,再决定是安装补全、直接启动应用还是进入启动向导' + in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app') + ) + + +def test_action_execution_service_build_result_lines_surfaces_common_payload_fields() -> None: + """ + 校验动作结果详情会提取常见结果字段。 + + :return: None + """ + spec = actions_module.TuiActionSpec( + action_id='job-run-once', + label='执行一次任务', + command_args=('job', 'run-once', '1'), + preview_title='执行一次任务', + preview_lines=['line a'], + ) + result = actions_module.TuiActionResult( + spec=spec, + payload={ + 'ok': True, + 'message': '执行完成', + 'serviceMessage': '调度器已触发', + 'hint': '可继续刷新页面确认状态', + 'jobId': 1, + }, + ) + + lines = actions_module.TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result) + + assert any('结果: 成功' in line for line in lines) + assert any('服务反馈: 调度器已触发' in line for line in lines) + assert any('摘要: 执行完成' in line for line in lines) + assert any('建议: 可继续刷新页面确认状态' in line for line in lines) + assert any('任务 ID: 1' in line for line in lines) + + +def test_action_execution_service_build_result_lines_surfaces_external_exit_code() -> None: + """ + 校验外部交互动作结果会展示退出码。 + + :return: None + """ + spec = actions_module.TuiActionSpec( + action_id='wizard-gen-export', + label='打开导出向导', + command_args=('wizard', 'gen-export', '--output=text'), + preview_title='打开导出向导', + preview_lines=['line a'], + execution_mode='external', + ) + result = actions_module.TuiActionResult( + spec=spec, + external_exit_code=1, + external_message='外部交互命令执行失败,退出码 1', + ) + + lines = actions_module.TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result) + + assert any('结果: 失败' in line for line in lines) + assert any('退出码: 1' in line for line in lines) diff --git a/shierfeng-fastapi-backend/tests/cli/tui/adapters/conftest.py b/shierfeng-fastapi-backend/tests/cli/tui/adapters/conftest.py new file mode 100644 index 0000000..0bc8ca7 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/adapters/conftest.py @@ -0,0 +1,87 @@ +import asyncio +import importlib +import sys +from collections.abc import Callable +from pathlib import Path +from types import ModuleType + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parents[4] + +sys.path.insert(0, str(BACKEND_DIR)) + + +def _load_adapter_module(module_name: str) -> ModuleType: + sys.modules.pop(module_name, None) + sys.modules.pop('cli', None) + return importlib.import_module(module_name) + + +@pytest.fixture +def app_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.app') + + +@pytest.fixture +def cache_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.cache') + + +@pytest.fixture +def crypto_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.crypto') + + +@pytest.fixture +def database_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.database') + + +@pytest.fixture +def jobs_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.jobs') + + +@pytest.fixture +def gen_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.gen') + + +@pytest.fixture +def configs_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.configs') + + +@pytest.fixture +def ops_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.ops') + + +@pytest.fixture +def health_adapter() -> ModuleType: + return _load_adapter_module('cli.tui.adapters.health') + + +@pytest.fixture +def load_adapter_module() -> Callable[[str], ModuleType]: + return _load_adapter_module + + +@pytest.fixture(autouse=True) +def dispose_async_db_engine_after_test() -> None: + """ + 在每个 TUI adapter 测试结束后尝试释放全局异步数据库连接池。 + + 这些适配器测试会按需导入运行时模块;若其中某些路径触发真实数据库访问, + 模块级 `async_engine` 可能在测试进程结束前仍持有连接,从而在 GC 阶段产生 + SQLAlchemy 未归还连接告警。这里统一在测试后主动 `dispose()`,将清理职责收口 + 到测试夹具而非业务代码。 + + :return: None + """ + yield + database_module = sys.modules.get('config.database') + async_engine = getattr(database_module, 'async_engine', None) if database_module is not None else None + if async_engine is not None: + asyncio.run(async_engine.dispose()) diff --git a/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_app_ops_snapshots.py b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_app_ops_snapshots.py new file mode 100644 index 0000000..00af911 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_app_ops_snapshots.py @@ -0,0 +1,345 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_collect_app_page_snapshot_builds_env_config_and_route_sections( + monkeypatch: MonkeyPatch, + app_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('app', 'env'): + return SimpleNamespace( + payload={ + 'ok': True, + 'runtime': { + 'cliEnv': 'dev', + 'configEnv': 'dev', + 'appEnv': 'dev', + 'envFile': '.env.dev', + 'envFileExists': True, + 'backendDir': '/srv/backend', + 'pythonExecutable': '/opt/conda/envs/shierfeng-fastapi/bin/python', + }, + } + ) + if arguments[0:2] == ('app', 'config'): + return SimpleNamespace( + payload={ + 'ok': True, + 'config': { + 'name': 'ShiErFeng', + 'host': '127.0.0.1', + 'port': 8000, + 'rootPath': '/', + 'workers': 2, + 'reload': True, + 'disableSwagger': False, + 'disableRedoc': True, + 'logLevel': 'INFO', + 'dbType': 'mysql', + 'dbHost': '127.0.0.1', + 'dbPort': 3306, + 'dbDatabase': 'shierfeng', + 'redisHost': '127.0.0.1', + 'redisPort': 6379, + 'transportCryptoEnabled': True, + 'transportCryptoMode': 'strict', + }, + } + ) + if arguments[0:2] == ('app', 'routes'): + return SimpleNamespace( + payload={ + 'ok': True, + 'count': 3, + 'routes': [], + 'groupedRoutes': {'system': [{}, {}], 'monitor': [{}]}, + } + ) + if arguments[0:2] == ('app', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'env': 'dev', + 'database': {'ok': True, 'message': '数据库连接成功'}, + 'redis': {'ok': True, 'message': 'Redis连接成功'}, + 'crypto': {'ok': True, 'message': '传输加密配置有效'}, + } + ) + if arguments[0:2] == ('completion', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': 'completion 诊断信息已生成', + 'activeShell': 'bash', + 'projectDir': '/srv/backend', + 'envChoices': ['dev', 'prod'], + 'completeEnvVar': '_SHIERFENG_COMPLETE', + 'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate', + 'shells': { + 'bash': { + 'supported': True, + 'detected': True, + 'targetFile': '/tmp/shierfeng.bash', + 'targetFileExists': True, + 'rcFile': '/Users/demo/.bashrc', + 'rcFileExists': True, + 'autoDiscovery': False, + 'sourceCommand': 'source /tmp/shierfeng.bash', + 'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate', + }, + 'zsh': {'supported': True, 'detected': False, 'autoDiscovery': False}, + 'fish': {'supported': True, 'detected': False, 'autoDiscovery': True}, + 'powershell': { + 'supported': True, + 'detected': False, + 'autoDiscovery': False, + 'sourceCommand': '. "/tmp/shierfeng.ps1"', + 'recommendedInstallCommand': 'shierfeng completion install --shell=powershell --activate', + }, + }, + } + ) + if arguments[0:2] == ('completion', 'show'): + return SimpleNamespace( + returncode=0, + stdout='_SHIERFENG_COMPLETE=bash_complete\n_shierfeng_completion() {\n COMPREPLY=()\n}\n', + stderr='', + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'count': 3, + 'routes': [ + { + 'path': '/system/user/list', + 'methods': ['GET'], + 'summary': '查询用户列表', + } + ], + 'groupedRoutes': { + 'system': [{}, {}], + 'monitor': [{}], + }, + } + ) + + monkeypatch.setattr(app_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = app_adapter.APP_DETAIL_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '应用' + assert ( + snapshot.subtitle + == '聚焦:环境映射 / 配置摘要 / 启动前检查 | 当前已加载 3 条注册路由,可继续查看环境映射、配置摘要、启动前检查、补全诊断与路由状态' + ) + assert [section.title for section in snapshot.sections] == [ + '总览判断', + '环境解析', + '应用配置', + '依赖配置', + '启动前检查', + '补全诊断', + '补全脚本预览', + '补全安装入口', + '路由摘要', + '启动入口', + ] + assert snapshot.search is not None + assert snapshot.search.placeholder == '按分区或内容搜索' + assert any('CLI 环境: dev' in line for line in snapshot.sections[0].lines) + assert any('CLI 目标环境: dev' in line for line in snapshot.sections[1].lines) + assert any('监听地址: 127.0.0.1:8000' in line for line in snapshot.sections[2].lines) + assert any('传输加密: 开启' in line for line in snapshot.sections[3].lines) + assert any('数据库: 正常' in line for line in snapshot.sections[4].lines) + assert any('活动 Shell: bash' in line for line in snapshot.sections[5].lines) + assert any('source /tmp/shierfeng.bash' in line for line in snapshot.sections[5].lines) + assert any('Shell: bash' in line for line in snapshot.sections[6].lines) + assert any('_SHIERFENG_COMPLETE=bash_complete' in line for line in snapshot.sections[6].lines) + assert any('completion install --activate --output=text' in line for line in snapshot.sections[7].lines) + assert any('system · 2 条' in line for line in snapshot.sections[8].lines) + assert any('app run --env=dev' in line for line in snapshot.sections[9].lines) + + +def test_collect_app_page_snapshot_applies_query_filter( + monkeypatch: MonkeyPatch, + app_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('app', 'env'): + return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}}) + if arguments[0:2] == ('app', 'config'): + return SimpleNamespace( + payload={ + 'ok': True, + 'config': { + 'name': 'ShiErFeng', + 'host': '127.0.0.1', + 'port': 8000, + 'rootPath': '/', + 'workers': 2, + 'reload': True, + 'disableSwagger': False, + 'disableRedoc': True, + 'logLevel': 'INFO', + 'dbType': 'mysql', + 'dbHost': '127.0.0.1', + 'dbPort': 3306, + 'dbDatabase': 'shierfeng', + 'redisHost': '127.0.0.1', + 'redisPort': 6379, + 'transportCryptoEnabled': True, + 'transportCryptoMode': 'strict', + }, + } + ) + if arguments[0:2] == ('completion', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': 'completion 诊断信息已生成', + 'activeShell': 'bash', + 'projectDir': '/srv/backend', + 'envChoices': ['dev'], + 'completeEnvVar': '_SHIERFENG_COMPLETE', + 'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate', + 'shells': {'bash': {'supported': True, 'detected': True, 'autoDiscovery': False}}, + } + ) + return SimpleNamespace(payload={'ok': True, 'count': 1, 'routes': [], 'groupedRoutes': {}}) + + monkeypatch.setattr(app_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = app_adapter.APP_DETAIL_ADAPTER.collect_snapshot('dev', query='路由') + + assert [section.title for section in snapshot.sections] == ['总览判断', '路由摘要'] + assert snapshot.search is not None + assert snapshot.search.query == '路由' + + +def test_collect_ops_page_snapshot_builds_health_dependency_and_server_sections( + monkeypatch: MonkeyPatch, + ops_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('ops', 'health'): + return SimpleNamespace( + payload={ + 'ok': True, + 'env': 'dev', + 'database': {'ok': True, 'message': '数据库连接成功'}, + 'redis': {'ok': True, 'message': 'Redis连接成功'}, + } + ) + if arguments[0:2] == ('ops', 'ping-db'): + return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'}) + if arguments[0:2] == ('ops', 'ping-redis'): + return SimpleNamespace(payload={'ok': True, 'message': 'Redis连接成功'}) + if arguments[0:2] == ('ops', 'deps'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': '核心运行依赖已安装', + 'missingRequired': [], + 'packages': { + 'python': {'installed': True, 'version': '3.10.0'}, + 'fastapi': {'installed': True, 'version': '0.111.0'}, + 'sqlalchemy': {'installed': True, 'version': '2.0.0'}, + 'redis': {'installed': True, 'version': '5.0.0'}, + 'typer': {'installed': True, 'version': '0.12.0'}, + 'alembic': {'installed': True, 'version': '1.13.0'}, + }, + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'server': { + 'sys': { + 'computerName': 'shierfeng-node', + 'computerIp': '10.0.0.8', + 'osName': 'Linux', + 'osArch': 'x86_64', + }, + 'cpu': {'cpuNum': 8, 'used': 21.5}, + 'mem': {'total': '16 GB', 'usage': 48.2}, + 'py': {'version': '3.10.0', 'runTime': '2天4小时', 'used': '256 MB', 'total': '1 GB'}, + 'sysFiles': [ + { + 'dirName': '/', + 'used': '120 GB', + 'total': '256 GB', + 'usage': '46%', + 'free': '136 GB', + } + ], + }, + } + ) + + monkeypatch.setattr(ops_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = ops_adapter.OPS_DETAIL_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '运维' + assert ( + snapshot.subtitle + == '聚焦:数据库连通性 / Redis 连通性 / 依赖版本 | 基础探活和依赖状态正常,可继续查看服务器资源与磁盘样本' + ) + assert [section.title for section in snapshot.sections] == [ + '总览判断', + '健康检查', + '数据库探活', + 'Redis 探活', + '依赖版本', + '服务器摘要', + '磁盘样本', + '生产巡检入口', + ] + assert any('探活状态: 正常' in line for line in snapshot.sections[0].lines) + assert any('数据库: 正常' in line for line in snapshot.sections[1].lines) + assert any('数据库连接: 正常' in line for line in snapshot.sections[2].lines) + assert any('Redis 连接: 正常' in line for line in snapshot.sections[3].lines) + assert any('fastapi · 已安装' in line for line in snapshot.sections[4].lines) + assert any('主机名: shierfeng-node' in line for line in snapshot.sections[5].lines) + assert any('使用率 46%' in line for line in snapshot.sections[6].lines) + assert any('wizard prod-check' in line for line in snapshot.sections[7].lines) + assert snapshot.search is not None + assert snapshot.search.placeholder == '按分区或内容搜索' + + +def test_collect_ops_page_snapshot_surfaces_health_risk_subtitle( + monkeypatch: MonkeyPatch, + ops_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('ops', 'health'): + return SimpleNamespace( + payload={ + 'ok': False, + 'env': 'dev', + 'database': {'ok': False, 'message': '数据库连接失败'}, + 'redis': {'ok': True, 'message': 'Redis连接成功'}, + } + ) + if arguments[0:2] == ('ops', 'ping-db'): + return SimpleNamespace(payload={'ok': False, 'message': '数据库连接失败'}) + if arguments[0:2] == ('ops', 'ping-redis'): + return SimpleNamespace(payload={'ok': True, 'message': 'Redis连接成功'}) + if arguments[0:2] == ('ops', 'deps'): + return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}, 'message': '依赖正常'}) + return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}, 'py': {}}}) + + monkeypatch.setattr(ops_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = ops_adapter.OPS_DETAIL_ADAPTER.collect_snapshot('dev') + + assert ( + snapshot.subtitle + == '聚焦:数据库连通性 / Redis 连通性 / 依赖版本 | 运维探活存在异常,优先核对数据库/Redis 连通性与依赖版本' + ) diff --git a/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_cache_db_crypto_snapshots.py b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_cache_db_crypto_snapshots.py new file mode 100644 index 0000000..42dee79 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_cache_db_crypto_snapshots.py @@ -0,0 +1,346 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_collect_cache_page_snapshot_builds_cache_browser_records( + monkeypatch: MonkeyPatch, + cache_adapter: ModuleType, +) -> None: + calls: list[tuple[str, ...]] = [] + + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + calls.append(arguments) + if arguments[0:2] == ('cache', 'stats'): + return SimpleNamespace( + payload={ + 'ok': True, + 'dbSize': 128, + 'info': { + 'redis_version': '7.0', + 'connected_clients': 12, + 'used_memory_human': '16M', + }, + 'commandStats': [{'name': 'get', 'value': 88}], + 'cacheNames': [{'cacheName': 'sys_config', 'remark': '系统参数缓存'}], + } + ) + if arguments[0:2] == ('cache', 'keys'): + return SimpleNamespace( + payload={ + 'ok': True, + 'cacheName': 'sys_config', + 'count': 2, + 'keys': ['site_name', 'site_logo'], + } + ) + if arguments[0:2] == ('cache', 'get'): + return SimpleNamespace( + payload={ + 'ok': True, + 'cacheName': 'sys_config', + 'cacheKey': arguments[3], + 'cacheValue': 'hello-world', + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'cacheName': 'sys_config', + 'cacheKey': arguments[3], + 'ttlSeconds': 3600, + 'persistent': False, + 'expires': True, + } + ) + + monkeypatch.setattr(cache_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = cache_adapter.CACHE_BROWSER_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '缓存' + assert ( + snapshot.subtitle + == '聚焦:Redis 键数 / 连接数 / 缓存名前缀 | 缓存基线正常,当前已加载 1 个缓存名,Redis 键数 128,连接数 12,可继续查看键列表、键值样本和 TTL' + ) + assert len(snapshot.records) == 1 + assert snapshot.records[0].title == 'sys_config' + detail_sections = snapshot.records[0].resolve_detail_sections() + assert [section.title for section in detail_sections] == [ + '键摘要', + '键列表', + '键详情 · site_name', + '键详情 · site_logo', + ] + assert any('site_name' in line for line in detail_sections[1].lines) + assert any('TTL: 3600 秒' in line for line in detail_sections[2].lines) + assert any('hello-world' in line for line in detail_sections[2].lines) + assert any('TTL: 3600 秒' in line for line in detail_sections[3].lines) + assert snapshot.shared_sections[0].title == '总览判断' + assert any('已登记缓存名: 1 个' in line for line in snapshot.shared_sections[0].lines) + assert any('当前匹配: 1 个' in line for line in snapshot.shared_sections[0].lines) + assert snapshot.shared_sections[1].title == 'Redis 摘要' + assert snapshot.shared_sections[2].title == '命令统计' + assert snapshot.shared_sections[3].title == '缓存清理入口' + assert any('wizard cache-clear' in line for line in snapshot.shared_sections[3].lines) + assert any(call[0:2] == ('cache', 'get') for call in calls) + assert any(call[0:2] == ('cache', 'ttl') for call in calls) + + +def test_cache_browser_adapter_collect_snapshot_exposes_search_context( + monkeypatch: MonkeyPatch, + cache_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('cache', 'stats'): + return SimpleNamespace( + payload={ + 'ok': True, + 'dbSize': 8, + 'info': { + 'redis_version': '7.0', + 'connected_clients': 2, + 'used_memory_human': '1M', + }, + 'commandStats': [], + 'cacheNames': [{'cacheName': 'sys_config', 'remark': '系统参数缓存'}], + } + ) + return SimpleNamespace(payload={'ok': False, 'message': 'unexpected'}) + + monkeypatch.setattr(cache_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = cache_adapter.CACHE_BROWSER_ADAPTER.collect_snapshot('dev', query='sys') + + assert snapshot.title == '缓存' + assert snapshot.search is not None + assert snapshot.search.query == 'sys' + assert snapshot.records + assert snapshot.records[0].title == 'sys_config' + + +def test_collect_database_page_snapshot_builds_check_heads_and_history_sections( + monkeypatch: MonkeyPatch, + database_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('db', 'current'): + return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'}) + if arguments[0:2] == ('db', 'check'): + return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'}) + if arguments[0:2] == ('db', 'heads'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': '已读取 Alembic heads', + 'count': 1, + 'items': [ + { + 'revision': '202604300001', + 'downRevisions': ['202604290001'], + 'branchLabels': ['main'], + 'dependsOn': [], + 'doc': 'create user table', + 'path': '/srv/backend/alembic/versions/202604300001_create_user.py', + } + ], + } + ) + if arguments[0:2] == ('db', 'history'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': '已读取 Alembic 历史版本', + 'count': 2, + 'totalCount': 2, + 'limit': 8, + 'items': [ + { + 'revision': '202604300001', + 'downRevisions': ['202604290001'], + 'branchLabels': ['main'], + 'dependsOn': [], + 'doc': 'create user table', + 'path': '/srv/backend/alembic/versions/202604300001_create_user.py', + }, + { + 'revision': '202604290001', + 'downRevisions': [], + 'branchLabels': [], + 'dependsOn': [], + 'doc': 'init schema', + 'path': '/srv/backend/alembic/versions/202604290001_init_schema.py', + }, + ], + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'config': { + 'dbType': 'mysql', + 'dbHost': '127.0.0.1', + 'dbPort': 3306, + 'dbDatabase': 'shierfeng', + }, + } + ) + + monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '数据库' + assert ( + snapshot.subtitle + == '聚焦:迁移版本 / 连接状态 / Heads | 数据库基线正常,当前 revision 202604300001,可继续查看连接、heads 和历史版本' + ) + assert [section.title for section in snapshot.sections] == [ + '总览判断', + '迁移版本', + '连接信息', + '连通性检查', + 'Heads 状态', + '历史版本', + '初始化预演入口', + '升级入口', + ] + assert any('当前 revision: 202604300001' in line for line in snapshot.sections[0].lines) + assert any('迁移版本: 202604300001' in line for line in snapshot.sections[1].lines) + assert any('连接地址: 127.0.0.1:3306' in line for line in snapshot.sections[2].lines) + assert any('数据库连接: 正常' in line for line in snapshot.sections[3].lines) + assert any('Heads 数量: 1' in line for line in snapshot.sections[4].lines) + assert any('Head 01 · 202604300001' in line for line in snapshot.sections[4].lines) + assert any('总版本数: 2' in line for line in snapshot.sections[5].lines) + assert any('版本 02 · 202604290001' in line for line in snapshot.sections[5].lines) + assert any('db init --dry-run --output=text' in line for line in snapshot.sections[6].lines) + assert any('wizard db-upgrade' in line for line in snapshot.sections[7].lines) + assert snapshot.search is not None + assert snapshot.search.placeholder == '按 revision 搜索' + + +def test_collect_database_page_snapshot_surfaces_failed_heads( + monkeypatch: MonkeyPatch, + database_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('db', 'current'): + return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'}) + if arguments[0:2] == ('db', 'check'): + return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'}) + if arguments[0:2] == ('db', 'heads'): + return SimpleNamespace(payload={'ok': False, 'message': '读取 Alembic heads 失败', 'error': 'broken graph'}) + if arguments[0:2] == ('db', 'history'): + return SimpleNamespace(payload={'ok': True, 'count': 0, 'totalCount': 0, 'limit': 8, 'items': []}) + return SimpleNamespace( + payload={ + 'ok': True, + 'config': { + 'dbType': 'mysql', + 'dbHost': '127.0.0.1', + 'dbPort': 3306, + 'dbDatabase': 'shierfeng', + }, + } + ) + + monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev') + + assert snapshot.subtitle == '聚焦:迁移版本 / 连接状态 / Heads | 数据库存在迁移分叉风险,优先确认 heads 和历史版本' + assert snapshot.sections[0].title == '总览判断' + assert snapshot.sections[0].status == 'warn' + assert snapshot.sections[4].title == 'Heads 状态' + assert snapshot.sections[4].status == 'fail' + assert any('读取 Alembic heads 失败' in line for line in snapshot.sections[4].lines) + + +def test_collect_database_page_snapshot_applies_query_filter( + monkeypatch: MonkeyPatch, + database_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('db', 'current'): + return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'}) + if arguments[0:2] == ('db', 'check'): + return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'}) + if arguments[0:2] == ('db', 'heads'): + return SimpleNamespace(payload={'ok': True, 'count': 1, 'items': []}) + if arguments[0:2] == ('db', 'history'): + return SimpleNamespace(payload={'ok': True, 'count': 0, 'totalCount': 0, 'limit': 8, 'items': []}) + return SimpleNamespace( + payload={ + 'ok': True, + 'config': {'dbType': 'mysql', 'dbHost': '127.0.0.1', 'dbPort': 3306, 'dbDatabase': 'shierfeng'}, + } + ) + + monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev', query='历史') + + assert [section.title for section in snapshot.sections] == ['历史版本'] + assert snapshot.search is not None + assert snapshot.search.query == '历史' + + +def test_collect_crypto_page_snapshot_builds_validate_and_public_key_sections( + monkeypatch: MonkeyPatch, + crypto_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('crypto', 'validate'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': '传输加密配置校验通过', + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'publicKey': { + 'kid': 'default', + 'alg': 'RSA-OAEP', + 'envelopeVersion': 'v1', + 'expireAt': '2099-12-31T23:59:59', + 'supportedKids': ['default', 'legacy-a'], + 'publicKey': '-----BEGIN PUBLIC KEY-----\nAAA\nBBB\n-----END PUBLIC KEY-----', + }, + } + ) + + monkeypatch.setattr(crypto_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = crypto_adapter.CRYPTO_DETAIL_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '传输加密' + assert ( + snapshot.subtitle + == '聚焦:运行校验 / 公钥身份 / 兼容版本 | 当前 KID default,兼容版本 2 个,可继续查看公钥身份、兼容版本与轮换预演入口' + ) + assert [section.title for section in snapshot.sections] == [ + '总览判断', + '运行校验', + '公钥身份', + '兼容版本', + '公钥预览', + '密钥生成入口', + '轮换预演入口', + ] + assert any('运行校验: 通过' in line for line in snapshot.sections[0].lines) + assert any('状态: 通过' in line for line in snapshot.sections[1].lines) + assert any('KID: default' in line for line in snapshot.sections[2].lines) + assert any('支持版本: legacy-a' in line for line in snapshot.sections[3].lines) + assert any('BEGIN PUBLIC KEY' in line for line in snapshot.sections[4].lines) + assert any('crypto keygen' in line for line in snapshot.sections[5].lines) + assert any('crypto rotate' in line for line in snapshot.sections[6].lines) + assert snapshot.search is not None + assert snapshot.search.placeholder == '按分区或内容搜索' diff --git a/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_configs_dashboard_snapshots.py b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_configs_dashboard_snapshots.py new file mode 100644 index 0000000..9d8fb04 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_configs_dashboard_snapshots.py @@ -0,0 +1,494 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_collect_dashboard_snapshot_adds_risk_heat_panel( + monkeypatch: MonkeyPatch, + health_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('app', 'env'): + return SimpleNamespace( + payload={ + 'ok': True, + 'runtime': { + 'cliEnv': 'dev', + 'configEnv': 'dev', + 'envFile': '.env.dev', + 'envFileExists': True, + }, + } + ) + if arguments[0:2] == ('app', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': False, + 'database': {'ok': False, 'message': '数据库连接失败'}, + 'redis': {'ok': True, 'message': 'Redis 正常'}, + 'crypto': {'ok': True, 'message': '加密组件正常'}, + } + ) + if arguments[0:2] == ('app', 'routes'): + return SimpleNamespace( + payload={ + 'ok': True, + 'count': 3, + 'routes': [], + 'groupedRoutes': {'system': [{}, {}], 'monitor': [{}]}, + } + ) + if arguments[0:2] == ('db', 'current'): + return SimpleNamespace( + payload={ + 'ok': False, + 'currentRevision': '-', + 'message': 'revision unavailable', + } + ) + if arguments[0:2] == ('ops', 'deps'): + return SimpleNamespace( + payload={ + 'ok': True, + 'message': '核心运行依赖已安装', + 'missingRequired': [], + 'packages': { + 'python': {'installed': True, 'version': '3.10.0'}, + 'fastapi': {'installed': True, 'version': '0.111.0'}, + 'sqlalchemy': {'installed': True, 'version': '2.0.0'}, + 'redis': {'installed': True, 'version': '5.0.0'}, + }, + } + ) + if arguments[0:2] == ('ops', 'server-info'): + return SimpleNamespace( + payload={ + 'ok': True, + 'server': { + 'sys': {'computerName': 'shierfeng-node', 'computerIp': '10.0.0.8'}, + 'cpu': {'used': 18.2}, + 'mem': {'usage': 42.5}, + }, + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'dbSize': 128, + 'info': {'redis_version': '7.0', 'connected_clients': 12}, + 'cacheNames': ['sys_config'], + } + ) + + monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev') + + assert any(panel.title == '总览判断' for panel in snapshot.panels) + assert any(panel.title == '建议摘要' for panel in snapshot.panels) + assert any(panel.title == '风险摘要' for panel in snapshot.panels) + assert any(panel.title == '依赖版本' for panel in snapshot.panels) + assert any(panel.title == '服务器摘要' for panel in snapshot.panels) + app_panel = next(panel for panel in snapshot.panels if panel.title == '应用摘要') + conclusion_panel = next(panel for panel in snapshot.panels if panel.title == '总览判断') + entry_panel = next(panel for panel in snapshot.panels if panel.title == '建议摘要') + assert any('当前环境: dev | CLI 目标环境: dev' in line for line in app_panel.lines) + assert any('注册路由: 3 条 | 标签分组: 2 个' in line for line in app_panel.lines) + assert conclusion_panel.status == 'fail' + assert any('数据库存在异常' in line for line in conclusion_panel.lines) + assert any('优先进入数据库页面' in line for line in conclusion_panel.lines) + assert entry_panel.status == 'fail' + assert any('[B] 数据库' in line for line in entry_panel.lines) + assert any('[O] 运维' in line for line in entry_panel.lines) + assert any('聚焦:迁移版本 / 连接状态 / Heads' in line for line in entry_panel.lines) + assert any('聚焦:数据库连通性 / Redis 连通性 / 依赖版本' in line for line in entry_panel.lines) + heat_panel = next(panel for panel in snapshot.panels if panel.title == '风险摘要') + assert heat_panel.status == 'fail' + assert any('HOT-01' in line for line in heat_panel.lines) + assert any(metric.title == '依赖通过率' and '[' in metric.value for metric in snapshot.metrics) + + +def test_dashboard_adapter_collect_snapshot_exposes_metrics( + monkeypatch: MonkeyPatch, + health_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('app', 'env'): + return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}}) + if arguments[0:2] == ('app', 'routes'): + return SimpleNamespace(payload={'ok': True, 'count': 2, 'routes': [], 'groupedRoutes': {}}) + if arguments[0:2] == ('app', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'database': {'ok': True, 'message': '数据库正常'}, + 'redis': {'ok': True, 'message': 'Redis 正常'}, + 'crypto': {'ok': True, 'message': '加密正常'}, + } + ) + if arguments[0:2] == ('db', 'current'): + return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'}) + if arguments[0:2] == ('ops', 'deps'): + return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}}) + if arguments[0:2] == ('ops', 'server-info'): + return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}}}) + return SimpleNamespace(payload={'ok': True, 'dbSize': 27, 'info': {}, 'cacheNames': []}) + + monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev') + + assert snapshot.env == 'dev' + assert snapshot.metrics + assert any(metric.title == '依赖通过率' for metric in snapshot.metrics) + assert any(panel.title == '总览判断' for panel in snapshot.panels) + + +def test_collect_dashboard_snapshot_uses_focus_hints_for_healthy_business_entry( + monkeypatch: MonkeyPatch, + health_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('app', 'env'): + return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}}) + if arguments[0:2] == ('app', 'routes'): + return SimpleNamespace( + payload={'ok': True, 'count': 4, 'routes': [], 'groupedRoutes': {'system': [{}, {}]}} + ) + if arguments[0:2] == ('app', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'database': {'ok': True, 'message': '数据库正常'}, + 'redis': {'ok': True, 'message': 'Redis 正常'}, + 'crypto': {'ok': True, 'message': '加密组件正常'}, + } + ) + if arguments[0:2] == ('db', 'current'): + return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'}) + if arguments[0:2] == ('ops', 'deps'): + return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}, 'message': '依赖正常'}) + if arguments[0:2] == ('ops', 'server-info'): + return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}}}) + return SimpleNamespace( + payload={ + 'ok': True, + 'dbSize': 27, + 'info': {'redis_version': '7.2.6', 'connected_clients': 1}, + 'cacheNames': [], + } + ) + + monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev') + + entry_panel = next(panel for panel in snapshot.panels if panel.title == '建议摘要') + + assert entry_panel.status == 'ok' + assert any('聚焦:失败聚合 / 暂停任务 / 执行轨迹' in line for line in entry_panel.lines) + assert any('聚焦:高风险配置 / 值不一致 / 缓存漂移' in line for line in entry_panel.lines) + assert any('生成前校验' in line and '代码预览' in line for line in entry_panel.lines) + + +def test_collect_configs_page_snapshot_surfaces_failure_message( + monkeypatch: MonkeyPatch, + configs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del arguments, parse_json + return SimpleNamespace( + payload={ + 'ok': False, + 'message': '读取参数配置诊断信息失败', + 'error': 'database unavailable', + } + ) + + monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '参数配置' + assert snapshot.records[0].status == 'fail' + assert snapshot.shared_sections[0].title == '总览判断' + assert snapshot.shared_sections[1].title == '配置巡检' + assert any('读取参数配置诊断信息失败' in line for line in snapshot.shared_sections[1].lines) + assert any('database unavailable' in line for line in snapshot.shared_sections[1].lines) + + +def test_configs_browser_adapter_collect_snapshot_exposes_search_context( + monkeypatch: MonkeyPatch, + configs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('config', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'databaseCount': 1, + 'cacheCount': 1, + 'missingInCacheCount': 0, + 'orphanInCacheCount': 0, + 'mismatchCount': 0, + 'missingInCache': [], + 'orphanInCache': [], + 'mismatchKeys': [], + } + ) + if arguments[0:2] == ('config', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'configId': 1, + 'configKey': 'site_name', + 'configName': '站点名称', + 'configType': 'Y', + 'configValue': 'ShiErFeng', + } + ] + }, + } + ) + return SimpleNamespace(payload={'ok': False, 'message': 'unexpected'}) + + monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', query='site') + + assert snapshot.title == '参数配置' + assert snapshot.search is not None + assert snapshot.search.query == 'site' + assert snapshot.records + assert snapshot.records[0].title == 'site_name' + + +def test_collect_configs_page_snapshot_builds_browser_records( + monkeypatch: MonkeyPatch, + configs_adapter: ModuleType, +) -> None: + calls: list[tuple[str, ...]] = [] + + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + calls.append(arguments) + if arguments[0:2] == ('config', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'databaseCount': 3, + 'cacheCount': 3, + 'missingInCacheCount': 0, + 'orphanInCacheCount': 0, + 'mismatchCount': 1, + 'mismatchKeys': ['site_name'], + } + ) + if arguments[0:2] == ('config', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'configId': 1, + 'configKey': 'site_name', + 'configName': '站点名称', + 'configType': 'Y', + 'configValue': 'ShiErFeng', + } + ] + }, + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'key': 'site_name', + 'source': 'both', + 'inSync': False, + 'database': { + 'configId': 1, + 'configKey': 'site_name', + 'configName': '站点名称', + 'configValue': 'ShiErFeng', + 'configType': 'Y', + 'remark': '数据库值', + }, + 'cache': { + 'configId': 1, + 'configKey': 'site_name', + 'configName': '站点名称', + 'configValue': 'ShiErFeng-Cache', + 'configType': 'Y', + 'remark': '缓存值', + }, + } + ) + + monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '参数配置' + assert ( + snapshot.subtitle + == '聚焦:高风险配置 / 值不一致 / 缓存漂移 | 当前筛选:全部,已匹配 1 项配置,值不一致 1 项,缓存漂移 0 项' + ) + assert snapshot.active_filter_key == 'all' + assert [option.key for option in snapshot.filters] == ['all', 'risky', 'mismatch', 'cache-drift'] + assert len(snapshot.records) == 1 + assert snapshot.records[0].title == 'site_name' + assert snapshot.records[0].status == 'fail' + detail_sections = snapshot.records[0].resolve_detail_sections() + assert [section.title for section in detail_sections] == ['同步状态', '数据库配置', '缓存配置'] + assert any('数据库与缓存一致: 否' in line for line in detail_sections[0].lines) + assert any('键值: ShiErFeng' in line for line in detail_sections[1].lines) + assert any('键值: ShiErFeng-Cache' in line for line in detail_sections[2].lines) + assert snapshot.shared_sections[0].title == '总览判断' + assert any('值不一致: 1 项' in line for line in snapshot.shared_sections[0].lines) + assert snapshot.shared_sections[1].title == '配置巡检' + assert snapshot.shared_sections[2].title == '高风险配置' + assert snapshot.shared_sections[3].title == '异常样本' + assert snapshot.shared_sections[4].title == '配置变更入口' + assert any('config set site_name ShiErFeng --output=text' in line for line in snapshot.shared_sections[4].lines) + assert any(call[0:2] == ('config', 'get') for call in calls) + + +def test_collect_configs_page_snapshot_sorts_and_filters_risky_records( + monkeypatch: MonkeyPatch, + configs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('config', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'databaseCount': 3, + 'cacheCount': 3, + 'missingInCacheCount': 1, + 'orphanInCacheCount': 1, + 'mismatchCount': 1, + 'missingInCache': ['upload_mode'], + 'orphanInCache': ['legacy_toggle'], + 'mismatchKeys': ['site_name'], + } + ) + if arguments[0:2] == ('config', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'configId': 2, + 'configKey': 'upload_mode', + 'configName': '上传模式', + 'configType': 'N', + 'configValue': 'local', + }, + { + 'configId': 1, + 'configKey': 'site_name', + 'configName': '站点名称', + 'configType': 'Y', + 'configValue': 'ShiErFeng', + }, + { + 'configId': 3, + 'configKey': 'normal_timeout', + 'configName': '会话超时', + 'configType': 'N', + 'configValue': '30m', + }, + ] + }, + } + ) + return SimpleNamespace(payload={'ok': True, 'key': '-', 'source': 'both', 'inSync': True}) + + monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + all_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev') + risky_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='risky') + mismatch_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='mismatch') + cache_drift_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='cache-drift') + + assert [record.title for record in all_snapshot.records] == [ + 'site_name', + 'upload_mode', + 'legacy_toggle', + 'normal_timeout', + ] + assert [record.status for record in all_snapshot.records] == ['fail', 'warn', 'warn', 'ok'] + assert any('[值不一致] site_name' in line for line in all_snapshot.shared_sections[2].lines) + assert any('[缓存缺失] upload_mode' in line for line in all_snapshot.shared_sections[2].lines) + assert any('[缓存孤立] legacy_toggle' in line for line in all_snapshot.shared_sections[2].lines) + + assert risky_snapshot.active_filter_key == 'risky' + assert [record.title for record in risky_snapshot.records] == ['site_name', 'upload_mode', 'legacy_toggle'] + assert [record.title for record in mismatch_snapshot.records] == ['site_name'] + assert [record.title for record in cache_drift_snapshot.records] == ['upload_mode', 'legacy_toggle'] + + +def test_collect_configs_page_snapshot_applies_query_filter( + monkeypatch: MonkeyPatch, + configs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('config', 'doctor'): + return SimpleNamespace( + payload={ + 'ok': True, + 'databaseCount': 2, + 'cacheCount': 2, + 'missingInCacheCount': 0, + 'orphanInCacheCount': 0, + 'mismatchCount': 0, + 'missingInCache': [], + 'orphanInCache': [], + 'mismatchKeys': [], + } + ) + if arguments[0:2] == ('config', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'configId': 1, + 'configKey': 'site_name', + 'configName': '站点名称', + 'configType': 'Y', + 'configValue': 'ShiErFeng', + }, + { + 'configId': 2, + 'configKey': 'upload_mode', + 'configName': '上传模式', + 'configType': 'N', + 'configValue': 'local', + }, + ] + }, + } + ) + return SimpleNamespace(payload={'ok': True, 'key': '-', 'source': 'both', 'inSync': True}) + + monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', query='site') + + assert [record.title for record in snapshot.records] == ['site_name'] + assert snapshot.search is not None + assert snapshot.search.query == 'site' diff --git a/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_jobs_gen_snapshots.py b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_jobs_gen_snapshots.py new file mode 100644 index 0000000..6e16a64 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/adapters/test_jobs_gen_snapshots.py @@ -0,0 +1,439 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_collect_jobs_page_snapshot_links_first_job_detail_and_logs( + monkeypatch: MonkeyPatch, + jobs_adapter: ModuleType, +) -> None: + calls: list[tuple[str, ...]] = [] + + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + calls.append(arguments) + if arguments[0:2] == ('job', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'jobId': 101, + 'jobName': '同步任务', + 'status': '0', + 'cronExpression': '0/30 * * * * ?', + } + ] + }, + } + ) + if arguments[0:2] == ('job', 'detail'): + return SimpleNamespace( + payload={ + 'ok': True, + 'job': { + 'jobId': 101, + 'jobName': '同步任务', + 'jobGroup': 'DEFAULT', + 'status': '0', + 'cronExpression': '0/30 * * * * ?', + 'invokeTarget': 'demo.sync', + }, + } + ) + if arguments[0:2] == ('job', 'logs') and '--status=1' in arguments: + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'jobLogId': 2, + 'jobName': '同步任务', + 'status': '1', + 'jobMessage': '执行失败', + 'exceptionInfo': 'traceback...', + 'createTime': '2026-04-30 10:01:00', + } + ] + }, + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'jobLogId': 1, + 'jobName': '同步任务', + 'status': '0', + 'jobMessage': '执行成功', + 'createTime': '2026-04-30 10:00:00', + } + ] + }, + } + ) + + monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '任务' + assert ( + snapshot.subtitle + == '聚焦:失败聚合 / 暂停任务 / 执行轨迹 | 当前筛选:全部,已匹配 1 条任务,失败任务 1 个,暂停任务 0 个' + ) + assert len(snapshot.records) == 1 + assert snapshot.records[0].title == '同步任务' + assert snapshot.shared_sections[0].title == '总览判断' + assert any('存在失败任务' in line for line in snapshot.shared_sections[0].lines) + assert any('失败任务: 1 个' in line for line in snapshot.shared_sections[0].lines) + assert snapshot.shared_sections[1].title == '失败聚合' + assert any('失败日志: 1 条' in line for line in snapshot.shared_sections[1].lines) + assert any('同步任务 · 1 次' in line for line in snapshot.shared_sections[1].lines) + detail_sections = snapshot.records[0].resolve_detail_sections() + assert [section.title for section in detail_sections] == [ + '任务摘要', + '调度配置', + '执行摘要', + '最近执行记录', + '失败执行记录', + ] + assert any('成功率信号:' in line for line in detail_sections[2].lines) + assert any('最近一次执行:' in line for line in detail_sections[2].lines) + assert any('执行轨道:' in line for line in detail_sections[2].lines) + assert any('轨道窗口:' in line for line in detail_sections[2].lines) + assert any('2026-04-30 10:00:00' in line for line in detail_sections[3].lines) + assert any('o 状态: 成功' in line for line in detail_sections[3].lines) + assert any('轨道: ├─采集 ├─执行 └─落盘' in line for line in detail_sections[3].lines) + assert any('轨迹: ●─●─◎' in line for line in detail_sections[3].lines) + assert any('异常: traceback...' in line for line in detail_sections[4].lines) + assert any(call[0:2] == ('job', 'detail') for call in calls) + assert any(call[0:2] == ('job', 'logs') and '--job-name=同步任务' in call for call in calls) + assert any(call[0:2] == ('job', 'logs') and '--status=1' in call for call in calls) + + +def test_collect_jobs_page_snapshot_applies_failed_filter( + monkeypatch: MonkeyPatch, + jobs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('job', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'jobId': 101, + 'jobName': '失败任务', + 'status': '0', + 'cronExpression': '0/30 * * * * ?', + }, + { + 'jobId': 102, + 'jobName': '暂停任务', + 'status': '1', + 'cronExpression': '0/45 * * * * ?', + }, + { + 'jobId': 103, + 'jobName': '正常任务', + 'status': '0', + 'cronExpression': '0/50 * * * * ?', + }, + ] + }, + } + ) + if arguments[0:2] == ('job', 'logs') and '--status=1' in arguments: + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'jobLogId': 2, + 'jobName': '失败任务', + 'status': '1', + 'jobMessage': '执行失败', + 'createTime': '2026-04-30 10:01:00', + } + ] + }, + } + ) + return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}}) + + monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='failed') + + assert snapshot.active_filter_key == 'failed' + assert [option.key for option in snapshot.filters] == ['all', 'failed', 'paused', 'ok'] + assert len(snapshot.records) == 1 + assert snapshot.records[0].title == '失败任务' + assert '当前筛选:失败' in snapshot.subtitle + assert snapshot.shared_sections[0].title == '总览判断' + assert any('当前筛选: 失败' in line for line in snapshot.shared_sections[0].lines) + assert snapshot.shared_sections[1].title == '失败聚合' + assert any('涉及任务: 1 个' in line for line in snapshot.shared_sections[1].lines) + assert snapshot.search is not None + assert snapshot.search.placeholder == '按任务名搜索' + + +def test_collect_jobs_page_snapshot_applies_query_filter( + monkeypatch: MonkeyPatch, + jobs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('job', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'jobId': 1, + 'jobName': 'sync-user', + 'jobGroup': 'default', + 'status': '0', + 'cronExpression': '*', + }, + { + 'jobId': 2, + 'jobName': 'clean-cache', + 'jobGroup': 'ops', + 'status': '0', + 'cronExpression': '*', + }, + ] + }, + } + ) + return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}}) + + monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev', query='sync') + + assert [record.title for record in snapshot.records] == ['sync-user'] + assert snapshot.search is not None + assert snapshot.search.query == 'sync' + + +def test_collect_jobs_page_snapshot_surfaces_failure_message( + monkeypatch: MonkeyPatch, + jobs_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del arguments, parse_json + return SimpleNamespace( + payload={ + 'ok': False, + 'message': '读取定时任务列表失败', + 'error': 'database unavailable', + } + ) + + monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev') + + assert snapshot.subtitle.startswith('任务数据不可用:') + assert snapshot.records[0].status == 'fail' + assert any('读取定时任务列表失败' in line for line in snapshot.records[0].detail_sections[0].lines) + + +def test_collect_gen_page_snapshot_links_first_table_detail( + monkeypatch: MonkeyPatch, + gen_adapter: ModuleType, +) -> None: + calls: list[tuple[str, ...]] = [] + + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + calls.append(arguments) + if arguments[0:2] == ('gen', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'tableId': 201, + 'tableName': 'sys_user', + 'className': 'SysUser', + 'moduleName': 'system', + } + ] + }, + } + ) + if arguments[0:2] == ('gen', 'detail'): + return SimpleNamespace( + payload={ + 'ok': True, + 'tableId': 201, + 'tableName': 'sys_user', + 'columnCount': 12, + 'detail': { + 'info': { + 'className': 'SysUser', + 'moduleName': 'system', + 'businessName': 'user', + 'functionName': '用户管理', + }, + 'rows': [ + { + 'columnName': 'user_id', + 'columnType': 'bigint', + 'isPk': '1', + 'isRequired': '1', + 'queryType': 'EQ', + } + ], + }, + } + ) + if arguments[0:2] == ('gen', 'preview'): + return SimpleNamespace( + payload={ + 'ok': True, + 'tableId': 201, + 'templateCount': 2, + 'preview': { + 'api.py.vm': 'def list_users():\n return []', + 'index.vue.vm': '', + }, + } + ) + if arguments[0:2] == ('gen', 'export'): + return SimpleNamespace( + payload={ + 'ok': True, + 'env': 'dev', + 'mode': 'zip', + 'dryRun': True, + 'message': '代码导出预演完成', + 'tableNames': ['sys_user'], + 'results': [ + { + 'tableName': 'sys_user', + 'ok': True, + 'message': '已生成 6 份模板预演结果', + } + ], + } + ) + if arguments[0:2] == ('gen', 'db-list') and '--table-name=sys_user' in arguments: + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'tableName': 'sys_user', + 'tableComment': '用户表', + } + ] + }, + } + ) + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'tableName': 'sys_role', + 'tableComment': '角色表', + } + ] + }, + } + ) + + monkeypatch.setattr(gen_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = gen_adapter.GEN_BROWSER_ADAPTER.collect_snapshot('dev') + + assert snapshot.title == '代码生成' + assert ( + snapshot.subtitle + == '聚焦:生成前校验 / 同步预检查 / 代码预览 | 当前已匹配 1 张业务表,可导入物理表 1 张,可继续查看表定义、预检查和代码预览' + ) + assert len(snapshot.records) == 1 + assert snapshot.records[0].title == 'sys_user' + detail_sections = snapshot.records[0].resolve_detail_sections() + assert [section.title for section in detail_sections] == [ + '业务表摘要', + '生成配置', + '字段摘要', + '生成前校验', + '同步预检查', + '字段列表', + '代码预览', + '导出预览', + '导出入口', + ] + assert any('模板预览: 2 份' in line for line in detail_sections[3].lines) + assert any('数据库物理表: sys_user' in line for line in detail_sections[4].lines) + assert any('模板数量: 2' in line for line in detail_sections[6].lines) + assert any('api.py.vm' in line for line in detail_sections[6].lines) + assert any('代码导出预演完成' in line for line in detail_sections[7].lines) + assert any('wizard gen-export' in line for line in detail_sections[8].lines) + assert snapshot.shared_sections[0].title == '总览判断' + assert any('可导入物理表: 1 张' in line for line in snapshot.shared_sections[0].lines) + assert any('代码预览 / 导出预览 / 导入入口 / 建表入口' in line for line in snapshot.shared_sections[0].lines) + assert snapshot.shared_sections[1].title == '可导入数据表' + assert snapshot.shared_sections[2].title == '导入入口' + assert any( + 'gen import-table sys_role --dry-run --output=text' in line for line in snapshot.shared_sections[2].lines + ) + assert snapshot.shared_sections[3].title == '建表入口' + assert any('gen create-table --dry-run --sql' in line for line in snapshot.shared_sections[3].lines) + assert any(call[0:2] == ('gen', 'detail') for call in calls) + assert any(call[0:2] == ('gen', 'preview') for call in calls) + assert any(call[0:2] == ('gen', 'export') and '--dry-run' in call for call in calls) + assert any(call[0:2] == ('gen', 'db-list') and '--table-name=sys_user' in call for call in calls) + + +def test_gen_browser_adapter_collect_snapshot_exposes_search_context( + monkeypatch: MonkeyPatch, + gen_adapter: ModuleType, +) -> None: + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('gen', 'list'): + return SimpleNamespace( + payload={ + 'ok': True, + 'page': { + 'rows': [ + { + 'tableId': 201, + 'tableName': 'sys_user', + 'className': 'SysUser', + 'moduleName': 'system', + } + ] + }, + } + ) + return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}}) + + monkeypatch.setattr(gen_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command) + + snapshot = gen_adapter.GEN_BROWSER_ADAPTER.collect_snapshot('dev', query='sys') + + assert snapshot.title == '代码生成' + assert snapshot.search is not None + assert snapshot.search.query == 'sys' + assert snapshot.records + assert snapshot.records[0].title == 'sys_user' diff --git a/shierfeng-fastapi-backend/tests/cli/tui/conftest.py b/shierfeng-fastapi-backend/tests/cli/tui/conftest.py new file mode 100644 index 0000000..16c69b4 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/conftest.py @@ -0,0 +1,64 @@ +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parents[3] + + +def _ensure_backend_on_path() -> None: + if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + + +@pytest.fixture +def tui_base_modules() -> SimpleNamespace: + _ensure_backend_on_path() + sys.modules.pop('cli.main', None) + sys.modules.pop('cli.tui.commands', None) + sys.modules.pop('cli', None) + + dependency_error = importlib.import_module('cli.exit_codes').DEPENDENCY_ERROR + cli_main = importlib.import_module('cli.main') + cli_tui_commands = importlib.import_module('cli.tui.commands') + + return SimpleNamespace( + BACKEND_DIR=BACKEND_DIR, + DEPENDENCY_ERROR=dependency_error, + cli_main=cli_main, + cli_tui_commands=cli_tui_commands, + ) + + +@pytest.fixture +def tui_modules(tui_base_modules: SimpleNamespace) -> SimpleNamespace: + pytest.importorskip('textual') + _ensure_backend_on_path() + for module_name in ( + 'cli.tui.app', + 'cli.tui.screens.browser', + 'cli.tui.copy', + 'cli.tui.screens.dashboard', + 'cli.tui.screens.detail', + 'cli.tui.screens.interactions', + 'cli.tui.adapters', + 'cli.tui.search', + 'cli.tui.widgets', + 'cli', + ): + sys.modules.pop(module_name, None) + + return SimpleNamespace( + **tui_base_modules.__dict__, + cli_tui_app=importlib.import_module('cli.tui.app'), + cli_tui_browser=importlib.import_module('cli.tui.screens.browser'), + cli_tui_copy=importlib.import_module('cli.tui.copy'), + cli_tui_dashboard=importlib.import_module('cli.tui.screens.dashboard'), + cli_tui_detail=importlib.import_module('cli.tui.screens.detail'), + cli_tui_interactions=importlib.import_module('cli.tui.screens.interactions'), + cli_tui_adapters=importlib.import_module('cli.tui.adapters'), + cli_tui_search=importlib.import_module('cli.tui.search'), + cli_tui_widgets=importlib.import_module('cli.tui.widgets'), + ) diff --git a/shierfeng-fastapi-backend/tests/cli/tui/test_tui_browser_screen.py b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_browser_screen.py new file mode 100644 index 0000000..a0e0258 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_browser_screen.py @@ -0,0 +1,575 @@ +from types import SimpleNamespace +from typing import Any + +import pytest +from pytest import MonkeyPatch + + +def test_browser_screen_builds_sections_from_selected_record(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='Jobs', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='Job A', + status='ok', + summary='summary a', + metadata_lines=[], + detail_sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='Job Detail', + status='ok', + lines=['detail a'], + ) + ], + ) + ], + shared_sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Recent Logs', status='warn', lines=['log a']) + ], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + sections = screen._get_sections_for_record(0) + + assert [section.title for section in sections] == ['Job Detail', 'Recent Logs'] + + +def test_browser_screen_resolves_job_actions(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='Jobs', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='Job A', + status='ok', + summary='正常 · Cron 0/30 * * * * ?', + metadata_lines=[], + detail_sections=[], + ) + ], + shared_sections=[], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + primary_action = screen._resolve_action('primary') + secondary_action = screen._resolve_action('secondary') + global_action = screen._resolve_action('global') + + assert primary_action is not None + assert primary_action.command_args == ('job', 'run-once', '1') + assert secondary_action is not None + assert secondary_action.command_args == ('job', 'pause', '1') + assert global_action is not None + assert global_action.command_args == ('job', 'sync') + + +def test_browser_screen_builds_action_panel_with_feedback(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='Jobs', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='Job A', + status='ok', + summary='正常 · Cron 0/30 * * * * ?', + metadata_lines=[], + detail_sections=[], + ) + ], + shared_sections=[], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + screen._action_feedback_lines = ['动作名称: 执行一次任务', '结果: 成功'] + + lines = screen._build_record_action_lines(snapshot.records[0]) + + assert any('动作键 [X] · 执行一次任务' in line for line in lines) + assert any('浏览操作' in line for line in lines) + assert any('最近动作反馈' in line for line in lines) + assert any('结果: 成功' in line for line in lines) + + +@pytest.mark.asyncio +async def test_browser_screen_execute_external_action_suspends_app( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='代码生成', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='gen:201', + title='sys_user', + status='ok', + summary='生成类 SysUser · 模块 system', + metadata_lines=[], + detail_sections=[], + ) + ], + shared_sections=[], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='gen', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + action = tui_modules.cli_tui_browser.TUI_ACTION_REGISTRY.resolve_browser_action( + view_key='gen', + slot='primary', + record=snapshot.records[0], + env='dev', + ) + assert action is not None + + recorded_suspend: list[str] = [] + + class DummySuspend: + def __enter__(self) -> None: + recorded_suspend.append('enter') + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: Any, + ) -> None: + del exc_type, exc, tb + recorded_suspend.append('exit') + + def build_suspend() -> DummySuspend: + return DummySuspend() + + def refresh_current_view() -> None: + pass + + fake_app = SimpleNamespace( + suspend=build_suspend, + action_refresh_current_view=refresh_current_view, + ) + monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app)) + monkeypatch.setattr( + tui_modules.cli_tui_interactions, + 'TUI_ACTION_EXECUTION_SERVICE', + SimpleNamespace( + execute_external=lambda spec: tui_modules.cli_tui_browser.TuiActionResult( + spec=spec, + external_exit_code=0, + external_message='外部交互命令已执行完成', + ), + build_result_lines=lambda result: [], + ), + ) + monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None) + + async def fake_render_record_detail(*, eager: bool = False) -> None: + del eager + + monkeypatch.setattr(screen, '_render_record_detail', fake_render_record_detail) + + await screen._execute_action(action) + + assert recorded_suspend == ['enter', 'exit'] + + +def test_browser_screen_filter_shortcut_remembers_and_refreshes( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='任务', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='Job A', + status='ok', + summary='正常 · Cron 0/30 * * * * ?', + metadata_lines=[], + detail_sections=[], + ) + ], + shared_sections=[], + filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS), + active_filter_key='all', + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + recorded_filters: list[tuple[str, str]] = [] + refresh_calls: list[str] = [] + fake_app = SimpleNamespace( + remember_browser_filter=lambda view_key, filter_key: recorded_filters.append((view_key, filter_key)), + action_refresh_current_view=lambda: refresh_calls.append('refresh'), + ) + monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app)) + + screen.action_apply_filter_2() + + assert recorded_filters == [('jobs', 'failed')] + assert refresh_calls == ['refresh'] + + +@pytest.mark.asyncio +async def test_browser_screen_detail_loader_failure_falls_back_to_failure_section( + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='任务', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='任务一', + status='warn', + summary='摘要', + metadata_lines=[], + detail_sections=[], + detail_loader=lambda: (_ for _ in ()).throw(RuntimeError('boom')), + ) + ], + shared_sections=[], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + rendered_calls: list[bool] = [] + + async def fake_render_record_detail(*, eager: bool = False) -> None: + del eager + rendered_calls.append(True) + + screen._render_record_detail = fake_render_record_detail # type: ignore[method-assign] + screen._record_detail_request_id = 1 + + await screen._load_record_detail_async(0, 1) + + cached_sections = snapshot.records[0]._cached_detail_sections + + assert rendered_calls == [True] + assert cached_sections is not None + assert len(cached_sections) == 1 + assert cached_sections[0].status == 'fail' + assert cached_sections[0].title == '任务详情加载失败' + assert any('boom' in line for line in cached_sections[0].lines) + + +def test_browser_screen_search_submit_remembers_and_refreshes( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='任务', + subtitle='subtitle', + records=[], + shared_sections=[], + filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS), + active_filter_key='all', + search=tui_modules.cli_tui_search.PageSearchContext( + placeholder='按任务名搜索', + query='', + suggestions=['sync-user'], + ), + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + recorded_queries: list[tuple[str, str]] = [] + refresh_calls: list[str] = [] + fake_app = SimpleNamespace( + remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)), + action_refresh_current_view=lambda: refresh_calls.append('refresh'), + ) + monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app)) + + screen._handle_search_submitted('sync') + + assert recorded_queries == [('jobs', 'sync')] + assert refresh_calls == ['refresh'] + + +def test_browser_screen_support_builds_summary_actions_and_fallbacks( + tui_modules: SimpleNamespace, +) -> None: + support = tui_modules.cli_tui_browser.BrowserScreenSupport() + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='任务', + subtitle='subtitle', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='Job A', + status='ok', + summary='正常 · Cron 0/30 * * * * ?', + metadata_lines=[], + detail_sections=[], + ) + ], + shared_sections=[], + filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS), + active_filter_key='all', + search=tui_modules.cli_tui_search.PageSearchContext( + placeholder='按任务名搜索', + query='sync', + suggestions=['sync-user'], + ), + ) + + summary = support.build_summary_text(snapshot, 'jobs') + filter_bar = support.build_filter_bar_text(snapshot) + action = support.resolve_action(active_view='jobs', slot='primary', record=snapshot.records[0], env='dev') + action_lines = support.build_record_action_lines( + active_view='jobs', + record=snapshot.records[0], + env='dev', + feedback_lines=['结果: 成功'], + ) + empty_record = support.build_empty_record() + loading_section = support.build_loading_section() + failure_sections = support.build_detail_load_failure_sections(RuntimeError('boom'), '任务详情') + empty_section = support.build_empty_section() + + assert '当前页共 1 条记录' in summary + assert '当前搜索 · sync' in filter_bar + assert action is not None + assert action.command_args == ('job', 'run-once', '1') + assert any('结果: 成功' in line for line in action_lines) + assert empty_record.title == tui_modules.cli_tui_copy.TUI_COPY.build_browser_empty_record_copy('title') + assert loading_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_browser_loading_copy('title') + assert failure_sections[0].status == 'fail' + assert failure_sections[0].title == '任务详情加载失败' + assert empty_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_detail_empty_section_copy('title') + + +def test_browser_screen_focus_service_moves_and_scrolls_between_focus_targets( + tui_modules: SimpleNamespace, +) -> None: + focus_service = tui_modules.cli_tui_browser.BrowserScreenFocusService() + focused: list[str] = [] + sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar')) + navigator = SimpleNamespace(focus=lambda: focused.append('records')) + sections = SimpleNamespace(focus=lambda: focused.append('sections')) + summary = SimpleNamespace(focus=lambda: focused.append('summary')) + detail = SimpleNamespace(focus=lambda: focused.append('detail')) + workspace_main = object() + fake_app = SimpleNamespace(focused=None) + fake_screen = SimpleNamespace( + app=fake_app, + query_one=lambda selector, widget_type=None: { + tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar, + tui_modules.cli_tui_widgets.RecordNavigator: navigator, + tui_modules.cli_tui_widgets.SectionNavigator: sections, + tui_modules.cli_tui_widgets.RecordSummaryView: summary, + tui_modules.cli_tui_widgets.SectionDetailView: detail, + '#workspace-main': workspace_main, + }[selector], + ) + + focus_service.move_focus(fake_screen, 1) + fake_app.focused = navigator + focus_service.move_focus(fake_screen, 1) + fake_app.focused = object() + scroll_target = focus_service.get_scroll_target(fake_screen) + + assert focused == ['sidebar', 'sections'] + assert scroll_target is workspace_main + + +def test_browser_screen_clear_search_remembers_empty_and_refreshes( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='任务', + subtitle='subtitle', + records=[], + shared_sections=[], + filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS), + active_filter_key='all', + search=tui_modules.cli_tui_search.PageSearchContext( + placeholder='按任务名搜索', + query='sync', + suggestions=['sync-user'], + ), + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + recorded_queries: list[tuple[str, str]] = [] + refresh_calls: list[str] = [] + fake_app = SimpleNamespace( + remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)), + action_refresh_current_view=lambda: refresh_calls.append('refresh'), + ) + monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app)) + + screen.action_clear_search() + + assert recorded_queries == [('jobs', '')] + assert refresh_calls == ['refresh'] + + +def test_browser_screen_unmount_cancels_background_tasks(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='缓存', + subtitle='subtitle', + records=[], + shared_sections=[], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='cache', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + cancelled: list[str] = [] + + class DummyTask: + def __init__(self, name: str) -> None: + self.name = name + + def done(self) -> bool: + return False + + def cancel(self) -> None: + cancelled.append(self.name) + + screen._record_detail_task = DummyTask('detail') # type: ignore[assignment] + screen._action_task = DummyTask('action') # type: ignore[assignment] + request_id = screen._record_detail_request_id + + screen.on_unmount() + + assert cancelled == ['detail', 'action'] + assert screen._record_detail_request_id == request_id + 1 + + +def test_browser_screen_failure_sections_use_current_snapshot_title(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='缓存', + subtitle='subtitle', + records=[], + shared_sections=[], + ) + screen = tui_modules.cli_tui_browser.BrowserScreen( + snapshot, + env='dev', + active_view='cache', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + sections = screen._build_detail_load_failure_sections(RuntimeError('boom')) + + assert sections[0].title == '缓存详情加载失败' + + +@pytest.mark.asyncio +async def test_browser_screen_scrolls_in_small_viewport(tui_modules: SimpleNamespace) -> None: + thin_scrollbar_size = 1 + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + app.action_show_dashboard = lambda: None # type: ignore[method-assign] + app.current_view = 'jobs' + snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot( + title='任务', + subtitle='超长浏览', + records=[ + tui_modules.cli_tui_adapters.BrowserRecordSnapshot( + key='job:1', + title='任务一', + status='warn', + summary='摘要很长 摘要很长 摘要很长', + metadata_lines=[f'字段{i}: 值很长 值很长 值很长' for i in range(1, 18)], + detail_sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='明细分区', + status='warn', + lines=[f'明细{i}: 说明很长 说明很长 说明很长' for i in range(1, 30)], + ) + ], + ) + for _ in range(6) + ], + shared_sections=[], + ) + screen = app.screen_factory.build( + snapshot=snapshot, + env=app.env, + active_view=app.current_view, + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + action_feedback_lines=app.get_action_feedback_lines(app.current_view), + ) + + async with app.run_test(size=(80, 24)) as pilot: + app.screen_navigator.show(screen) + await pilot.pause() + await pilot.pause() + + main = app.screen.query_one('#workspace-main') + browser_body = app.screen.query_one('#browser-body') + browser_detail_pane = app.screen.query_one('#browser-detail-pane') + record_navigator = app.screen.query_one(tui_modules.cli_tui_widgets.RecordNavigator) + record_summary = app.screen.query_one(tui_modules.cli_tui_widgets.RecordSummaryView) + section_detail = app.screen.query_one(tui_modules.cli_tui_widgets.SectionDetailView) + + assert main.styles.scrollbar_size_vertical == thin_scrollbar_size + assert main.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert main.virtual_size.height > main.size.height + assert browser_body.styles.scrollbar_size_vertical == thin_scrollbar_size + assert browser_body.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert browser_body.virtual_size.width > browser_body.size.width + assert browser_detail_pane.styles.scrollbar_size_vertical == thin_scrollbar_size + assert browser_detail_pane.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert browser_detail_pane.virtual_size.height > browser_detail_pane.size.height + assert record_navigator.styles.scrollbar_size_vertical == thin_scrollbar_size + assert record_navigator.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert record_summary.styles.scrollbar_size_vertical == thin_scrollbar_size + assert record_summary.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert section_detail.styles.scrollbar_size_vertical == thin_scrollbar_size + assert section_detail.styles.scrollbar_size_horizontal == thin_scrollbar_size diff --git a/shierfeng-fastapi-backend/tests/cli/tui/test_tui_command_app.py b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_command_app.py new file mode 100644 index 0000000..a0ca574 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_command_app.py @@ -0,0 +1,490 @@ +import importlib +from types import SimpleNamespace +from typing import Any + +import pytest +import typer +from pytest import MonkeyPatch +from typer.testing import CliRunner + + +def test_tui_command_returns_dependency_error_when_textual_missing( + monkeypatch: MonkeyPatch, + tui_base_modules: SimpleNamespace, +) -> None: + runner = CliRunner() + monkeypatch.chdir(tui_base_modules.BACKEND_DIR) + + def raise_missing_textual(module_name: str) -> Any: + if module_name == 'cli.tui.app': + raise ModuleNotFoundError("No module named 'textual'", name='textual') + return importlib.import_module(module_name) + + monkeypatch.setattr(tui_base_modules.cli_tui_commands, 'import_module', raise_missing_textual) + isolated_cli = typer.Typer() + tui_base_modules.cli_tui_commands.TUI_COMMAND_REGISTRATION.register(isolated_cli) + + result = runner.invoke(isolated_cli, ['tui', '--env=dev']) + + assert result.exit_code == tui_base_modules.DEPENDENCY_ERROR + assert '当前环境未安装 TUI 可选依赖' in result.stdout + assert 'pip install -r requirements.txt' in result.stdout + + +def test_tui_app_mount_pushes_first_workspace_screen( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + recorded_calls: list[tuple[str, object]] = [] + + class FakeDashboardScreen: + def __init__( + self, + snapshot: object, + env: str, + active_view: str, + navigation_items: list[object], + refreshed_at: str, + ) -> None: + self.snapshot = snapshot + self.env = env + self.active_view = active_view + self.navigation_items = navigation_items + self.refreshed_at = refreshed_at + + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'dashboard', + lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]), + ) + monkeypatch.setattr(tui_modules.cli_tui_app, 'DashboardScreen', FakeDashboardScreen) + + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + monkeypatch.setattr(app, 'push_screen', lambda screen: recorded_calls.append(('push', screen))) + monkeypatch.setattr(app, 'switch_screen', lambda screen: recorded_calls.append(('switch', screen))) + + app.on_mount() + + assert recorded_calls + assert recorded_calls[0][0] == 'push' + assert isinstance(recorded_calls[0][1], FakeDashboardScreen) + assert recorded_calls[0][1].env == 'dev' + assert recorded_calls[0][1].active_view == 'dashboard' + assert recorded_calls[0][1].navigation_items + assert recorded_calls[0][1].refreshed_at + assert app.current_view == 'dashboard' + assert app.screen_navigator.initialized is True + + +def test_open_view_switches_to_expected_action(tui_modules: SimpleNamespace) -> None: + calls: list[str] = [] + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + + def fake_show_view(view_key: str) -> None: + calls.append(tui_modules.cli_tui_app.TUI_VIEW_REGISTRY.resolve_view_key(view_key)) + + app.show_view = fake_show_view # type: ignore[method-assign] + + app.open_view('jobs') + app.open_view('app') + app.open_view('ops') + app.open_view('crypto') + app.open_view('unknown') + + assert calls == ['jobs', 'app', 'ops', 'crypto', 'dashboard'] + + +def test_workspace_header_render_includes_particle_line(tui_modules: SimpleNamespace) -> None: + header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard') + + rendered = header.render() + rendered_text = rendered.plain + + assert rendered.__class__.__name__ == 'Text' + assert 'ShiErFeng 控制台' in rendered_text + assert '环境 DEV · 页面 总览' in rendered_text + assert any(symbol in rendered_text for symbol in ('█', '▓', '▒', '░', '·', '─')) + + +def test_workspace_header_centers_title_using_runtime_width(tui_modules: SimpleNamespace) -> None: + header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard') + header.styles.width = 140 + + first_line = header.render().plain.splitlines()[0] + + assert first_line.startswith(' ') + assert 'ShiErFeng 控制台' in first_line + + +def test_workspace_header_keeps_title_alignment_stable_across_frames( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard') + monotonic_values = iter([10.0, 10.0, 11.0, 11.0]) + workspace_module = importlib.import_module('cli.tui.widgets.workspace') + monkeypatch.setattr(workspace_module, 'monotonic', lambda: next(monotonic_values)) + + first_line_a = header.render().plain.splitlines()[0] + first_line_b = header.render().plain.splitlines()[0] + + assert first_line_a.index('ShiErFeng 控制台') == first_line_b.index('ShiErFeng 控制台') + + +def test_workspace_hero_pulses_border_with_expected_palette( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + hero = tui_modules.cli_tui_widgets.WorkspaceHero( + title='标题', + subtitle='副标题', + env='dev', + active_view='dashboard', + summary='摘要', + refreshed_at='2026-05-12 12:00:00', + ) + workspace_module = importlib.import_module('cli.tui.widgets.workspace') + monkeypatch.setattr(workspace_module, 'monotonic', lambda: 10.0) + original_border_text = str(hero.styles.border) + + hero._pulse_border() + + border_text = str(hero.styles.border) + + assert 'double' in border_text + assert border_text != original_border_text + + +def test_workspace_hero_render_uses_rich_text_for_title_glow(tui_modules: SimpleNamespace) -> None: + hero = tui_modules.cli_tui_widgets.WorkspaceHero( + title='标题', + subtitle='副标题', + env='dev', + active_view='dashboard', + summary='摘要', + refreshed_at='2026-05-12 12:00:00', + ) + + rendered = hero._build_render_text() + + assert rendered.__class__.__name__ == 'Text' + assert '标题' in rendered.plain + assert '副标题' in rendered.plain + assert '运行摘要 · 摘要' in rendered.plain + + +def test_tui_app_runner_runs_created_application(tui_modules: SimpleNamespace) -> None: + recorded_envs: list[str] = [] + recorded_runs: list[str] = [] + + class FakeApp: + def __init__(self, env: str) -> None: + recorded_envs.append(env) + + def run(self) -> None: + recorded_runs.append('run') + + runner = tui_modules.cli_tui_app.TuiAppRunner(FakeApp) + + runner.run('dev') + + assert recorded_envs == ['dev'] + assert recorded_runs == ['run'] + + +def test_tui_app_show_jobs_uses_remembered_filter( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + recorded_filter_keys: list[tuple[str, str]] = [] + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + app.remember_browser_filter('jobs', 'failed') + app.remember_browser_query('jobs', 'sync') + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'jobs', + lambda env, filter_key='all', query='': ( + recorded_filter_keys.append((filter_key, query)) + or tui_modules.cli_tui_app.BrowserPageSnapshot( + title='任务', + subtitle='subtitle', + records=[], + shared_sections=[], + filters=[], + active_filter_key=filter_key, + search=None, + ) + ), + ) + monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None) + app.action_show_jobs() + + assert recorded_filter_keys == [('failed', 'sync')] + + +def test_tui_app_show_configs_uses_remembered_filter( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + recorded_filter_keys: list[tuple[str, str]] = [] + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + app.remember_browser_filter('configs', 'cache-drift') + app.remember_browser_query('configs', 'site') + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'configs', + lambda env, filter_key='all', query='': ( + recorded_filter_keys.append((filter_key, query)) + or tui_modules.cli_tui_app.BrowserPageSnapshot( + title='参数配置', + subtitle='subtitle', + records=[], + shared_sections=[], + filters=[], + active_filter_key=filter_key, + search=None, + ) + ), + ) + monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None) + app.action_show_configs() + + assert recorded_filter_keys == [('cache-drift', 'site')] + + +def test_tui_app_show_cache_and_gen_use_remembered_query( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + cache_queries: list[str] = [] + gen_queries: list[str] = [] + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + app.remember_browser_query('cache', 'sys') + app.remember_browser_query('gen', 'user') + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'cache', + lambda env, query='': ( + cache_queries.append(query) + or tui_modules.cli_tui_app.BrowserPageSnapshot( + title='缓存', + subtitle='subtitle', + records=[], + shared_sections=[], + filters=[], + active_filter_key=None, + search=None, + ) + ), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'gen', + lambda env, query='': ( + gen_queries.append(query) + or tui_modules.cli_tui_app.BrowserPageSnapshot( + title='代码生成', + subtitle='subtitle', + records=[], + shared_sections=[], + filters=[], + active_filter_key=None, + search=None, + ) + ), + ) + monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None) + app.action_show_cache() + app.action_show_gen() + + assert cache_queries == ['sys'] + assert gen_queries == ['user'] + + +def test_tui_app_detail_views_use_remembered_query( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + database_queries: list[str] = [] + app_queries: list[str] = [] + ops_queries: list[str] = [] + crypto_queries: list[str] = [] + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + app.remember_browser_query('database', 'head') + app.remember_browser_query('app', 'route') + app.remember_browser_query('ops', 'disk') + app.remember_browser_query('crypto', 'kid') + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'database', + lambda env, query='': ( + database_queries.append(query) + or tui_modules.cli_tui_app.DetailPageSnapshot(title='数据库', subtitle='subtitle', sections=[]) + ), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'app', + lambda env, query='': ( + app_queries.append(query) + or tui_modules.cli_tui_app.DetailPageSnapshot(title='应用', subtitle='subtitle', sections=[]) + ), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'ops', + lambda env, query='': ( + ops_queries.append(query) + or tui_modules.cli_tui_app.DetailPageSnapshot(title='运维', subtitle='subtitle', sections=[]) + ), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'crypto', + lambda env, query='': ( + crypto_queries.append(query) + or tui_modules.cli_tui_app.DetailPageSnapshot(title='传输加密', subtitle='subtitle', sections=[]) + ), + ) + monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None) + app.action_show_database() + app.action_show_app() + app.action_show_ops() + app.action_show_crypto() + + assert database_queries == ['head'] + assert app_queries == ['route'] + assert ops_queries == ['disk'] + assert crypto_queries == ['kid'] + + +@pytest.mark.asyncio +async def test_tui_app_open_view_updates_screen_across_multiple_switches( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'dashboard', + lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'app', + lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot( + title='应用详情', + subtitle=query or 'app-subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='应用分区', + status='ok', + lines=['app-line'], + ) + ], + ), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'ops', + lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot( + title='运维详情', + subtitle=query or 'ops-subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='运维分区', + status='warn', + lines=['ops-line'], + ) + ], + ), + ) + + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.pause() + + assert type(app.screen).__name__ == 'DashboardScreen' + assert app.current_view == 'dashboard' + + app.open_view('app') + await pilot.pause() + await pilot.pause() + + assert type(app.screen).__name__ == 'DetailScreen' + assert app.current_view == 'app' + assert app.screen.snapshot.title == '应用详情' + + app.open_view('ops') + await pilot.pause() + await pilot.pause() + + assert type(app.screen).__name__ == 'DetailScreen' + assert app.current_view == 'ops' + assert app.screen.snapshot.title == '运维详情' + + +@pytest.mark.asyncio +async def test_tui_sidebar_highlight_updates_screen_across_multiple_switches( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'dashboard', + lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'app', + lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot( + title='应用详情', + subtitle='app-subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='应用分区', + status='ok', + lines=['app-line'], + ) + ], + ), + ) + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, + 'ops', + lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot( + title='运维详情', + subtitle='ops-subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='运维分区', + status='warn', + lines=['ops-line'], + ) + ], + ), + ) + + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.pause() + + assert type(app.screen).__name__ == 'DashboardScreen' + assert app.current_view == 'dashboard' + + await pilot.press('down') + await pilot.pause() + await pilot.pause() + + assert type(app.screen).__name__ == 'DetailScreen' + assert app.current_view == 'app' + assert app.screen.snapshot.title == '应用详情' + + await pilot.press('down') + await pilot.pause() + await pilot.pause() + + assert type(app.screen).__name__ == 'DetailScreen' + assert app.current_view == 'ops' + assert app.screen.snapshot.title == '运维详情' diff --git a/shierfeng-fastapi-backend/tests/cli/tui/test_tui_dashboard_search.py b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_dashboard_search.py new file mode 100644 index 0000000..118bfb6 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_dashboard_search.py @@ -0,0 +1,192 @@ +from types import SimpleNamespace + +import pytest + + +def test_build_filter_bar_text_includes_search_context(tui_modules: SimpleNamespace) -> None: + text = tui_modules.cli_tui_search.TUI_SEARCH_SERVICE.build_filter_bar_text( + tui_modules.cli_tui_search.JOB_FILTER_OPTIONS, + 'failed', + search_query='sync', + search_placeholder='按任务名搜索', + search_suggestions=['sync-job', 'sync-user'], + ) + + assert '当前筛选 · 失败' in text + assert '搜索器 · [/] 按任务名搜索' in text + assert '当前搜索 · sync | [Backspace] 清空' in text + assert '候选建议 · sync-job sync-user' in text + + +def test_tui_search_service_builds_filter_context_and_section_filtering( + tui_modules: SimpleNamespace, +) -> None: + service = tui_modules.cli_tui_search.TuiSearchService( + tui_modules.cli_tui_search.TuiSearchSuggestionProviderRegistry( + providers={ + 'jobs': tui_modules.cli_tui_search.SearchSuggestionProviderSpec( + '按任务名搜索', + lambda incomplete: ['sync-user', 'sync-role', 'cleanup'], + ), + 'app': tui_modules.cli_tui_search.SearchSuggestionProviderSpec('按分区或内容搜索'), + } + ) + ) + + option = service.resolve_filter_option(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS, 'failed') + filter_bar = service.build_filter_bar_text( + tui_modules.cli_tui_search.JOB_FILTER_OPTIONS, + 'failed', + search_query='sync', + search_placeholder='按任务名搜索', + search_suggestions=['sync-user', 'sync-role'], + ) + search_context = service.resolve_search_context('jobs', 'sync') + default_context = service.resolve_search_context('app', '总览', default_suggestions=['总览判断', '应用配置']) + sections = service.filter_detail_sections( + [ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='总览判断', status='ok', lines=['应用正常']), + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='路由摘要', status='warn', lines=['同步任务接口']), + ], + '同步', + ) + + assert option is not None + assert option.key == 'failed' + assert '当前筛选 · 失败' in filter_bar + assert search_context is not None + assert search_context.placeholder == '按任务名搜索' + assert search_context.suggestions[:2] == ['sync-user', 'sync-role'] + assert default_context is not None + assert default_context.suggestions == ['总览判断'] + assert [section.title for section in sections] == ['路由摘要'] + + +def test_tui_search_service_skips_provider_when_query_is_empty( + tui_modules: SimpleNamespace, +) -> None: + provider_calls: list[str] = [] + + def record_provider(incomplete: str) -> list[str]: + provider_calls.append(incomplete) + return ['site_name'] + + service = tui_modules.cli_tui_search.TuiSearchService( + tui_modules.cli_tui_search.TuiSearchSuggestionProviderRegistry( + providers={ + 'configs': tui_modules.cli_tui_search.SearchSuggestionProviderSpec( + '按配置键搜索', + record_provider, + ) + } + ) + ) + + search_context = service.resolve_search_context('configs', '') + + assert search_context is not None + assert search_context.placeholder == '按配置键搜索' + assert search_context.suggestions == [] + assert provider_calls == [] + + +def test_search_highlight_helper_wraps_matches(tui_modules: SimpleNamespace) -> None: + highlighted = tui_modules.cli_tui_search.TUI_SEARCH_HIGHLIGHTER.highlight('sync-user job', 'sync') + + assert highlighted == '【sync】-user job' + + +def test_dashboard_screen_support_builds_summary_signal_and_status_track( + tui_modules: SimpleNamespace, +) -> None: + support = tui_modules.cli_tui_dashboard.DashboardScreenSupport() + snapshot = tui_modules.cli_tui_app.DashboardSnapshot( + env='dev', + metrics=[], + panels=[ + tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='应用', status='ok', lines=['ok']), + tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='任务', status='warn', lines=['warn']), + tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='数据库', status='fail', lines=['fail']), + ], + ) + + summary = support.build_summary_text(snapshot) + status_track = support.build_status_track(snapshot) + signal_lines = support.build_signal_lines(snapshot) + + assert '共 3 个面板' in summary + assert status_track == 'o!x' + assert any('失败 01' in line for line in signal_lines) + + +def test_dashboard_screen_focus_service_moves_and_scrolls_between_focus_targets( + tui_modules: SimpleNamespace, +) -> None: + focus_service = tui_modules.cli_tui_dashboard.DashboardScreenFocusService() + focused: list[str] = [] + sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar')) + workspace_main = SimpleNamespace(focus=lambda: focused.append('main')) + fake_app = SimpleNamespace(focused=None) + fake_screen = SimpleNamespace( + app=fake_app, + query_one=lambda selector, widget_type=None: { + tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar, + '#workspace-main': workspace_main, + }[selector], + ) + + focus_service.move_focus(fake_screen, 1) + fake_app.focused = sidebar + focus_service.move_focus(fake_screen, 1) + fake_app.focused = object() + scroll_target = focus_service.get_scroll_target(fake_screen) + + assert focused == ['sidebar', 'main'] + assert scroll_target is workspace_main + + +@pytest.mark.asyncio +async def test_dashboard_grid_expands_to_fit_status_panels( + monkeypatch: pytest.MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + expected_dashboard_panel_count = 8 + min_expanded_dashboard_grid_height = 20 + snapshot = tui_modules.cli_tui_app.DashboardSnapshot( + env='dev', + metrics=[], + panels=[ + tui_modules.cli_tui_adapters.DashboardPanelSnapshot( + title=f'面板 {index + 1}', + status='ok' if index % 2 == 0 else 'warn', + lines=[ + '## 巡检摘要', + f'当前面板: {index + 1}', + '状态说明: 这里需要展示多行内容', + '', + '## 建议动作', + '进入对应分区继续查看详情', + ], + ) + for index in range(8) + ], + ) + + monkeypatch.setitem( + tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, 'dashboard', lambda env: snapshot + ) + + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + async with app.run_test(size=(160, 60)) as pilot: + await pilot.pause() + await pilot.pause() + + grid = app.screen.query_one('#dashboard-grid') + panels = list( + app.screen.query(tui_modules.cli_tui_widgets.StatusPanel).results(tui_modules.cli_tui_widgets.StatusPanel) + ) + + assert len(panels) == expected_dashboard_panel_count + assert grid.virtual_size.height > min_expanded_dashboard_grid_height + assert all(panel.content_size.height > 0 for panel in panels) + assert len({panel.region.height for panel in panels}) == 1 diff --git a/shierfeng-fastapi-backend/tests/cli/tui/test_tui_detail_screen.py b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_detail_screen.py new file mode 100644 index 0000000..533d607 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/tui/test_tui_detail_screen.py @@ -0,0 +1,394 @@ +from types import SimpleNamespace +from typing import Any + +import pytest +from pytest import MonkeyPatch + + +def test_detail_screen_updates_section_detail_view(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='Jobs', + subtitle='subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Section A', status='ok', lines=['line a']), + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Section B', status='warn', lines=['line b']), + ], + ) + screen = tui_modules.cli_tui_detail.DetailScreen( + snapshot, + env='dev', + active_view='jobs', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + recorded_sections: list[object] = [] + + class FakeSectionDetailView: + def show_section(self, section: object, query: str = '') -> None: + del query + recorded_sections.append(section) + + screen.query_one = lambda widget_type: FakeSectionDetailView() # type: ignore[method-assign] + + screen._update_selected_section(1) + + assert screen.selected_section_index == 1 + assert recorded_sections + assert recorded_sections[0].title == 'Section B' + + +def test_detail_screen_search_submit_remembers_and_refreshes( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='数据库', + subtitle='subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='历史版本', status='ok', lines=['line a']), + ], + search=tui_modules.cli_tui_search.PageSearchContext( + placeholder='按 revision 搜索', + query='', + suggestions=['head', 'base'], + ), + ) + screen = tui_modules.cli_tui_detail.DetailScreen( + snapshot, + env='dev', + active_view='database', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + recorded_queries: list[tuple[str, str]] = [] + refresh_calls: list[str] = [] + fake_app = SimpleNamespace( + remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)), + action_refresh_current_view=lambda: refresh_calls.append('refresh'), + ) + monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app)) + + screen._handle_search_submitted('head') + + assert recorded_queries == [('database', 'head')] + assert refresh_calls == ['refresh'] + + +def test_detail_screen_clear_search_remembers_empty_and_refreshes( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='数据库', + subtitle='subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='历史版本', status='ok', lines=['line a']), + ], + search=tui_modules.cli_tui_search.PageSearchContext( + placeholder='按 revision 搜索', + query='head', + suggestions=['head', 'base'], + ), + ) + screen = tui_modules.cli_tui_detail.DetailScreen( + snapshot, + env='dev', + active_view='database', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + recorded_queries: list[tuple[str, str]] = [] + refresh_calls: list[str] = [] + fake_app = SimpleNamespace( + remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)), + action_refresh_current_view=lambda: refresh_calls.append('refresh'), + ) + monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app)) + + screen.action_clear_search() + + assert recorded_queries == [('database', '')] + assert refresh_calls == ['refresh'] + + +@pytest.mark.asyncio +async def test_detail_screen_execute_external_action_suspends_app( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='数据库', + subtitle='subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='迁移版本', status='ok', lines=['line a']), + ], + ) + screen = tui_modules.cli_tui_detail.DetailScreen( + snapshot, + env='dev', + active_view='database', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + action = tui_modules.cli_tui_detail.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='database', + slot='global', + env='dev', + ) + assert action is not None + + recorded_suspend: list[str] = [] + + class DummySuspend: + def __enter__(self) -> None: + recorded_suspend.append('enter') + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: Any, + ) -> None: + del exc_type, exc, tb + recorded_suspend.append('exit') + + def build_suspend() -> DummySuspend: + return DummySuspend() + + def refresh_current_view() -> None: + pass + + fake_app = SimpleNamespace( + suspend=build_suspend, + action_refresh_current_view=refresh_current_view, + ) + monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app)) + monkeypatch.setattr( + tui_modules.cli_tui_interactions, + 'TUI_ACTION_EXECUTION_SERVICE', + SimpleNamespace( + execute_external=lambda spec: tui_modules.cli_tui_detail.TuiActionResult( + spec=spec, + external_exit_code=0, + external_message='外部交互命令已执行完成', + ), + build_result_lines=lambda result: [], + ), + ) + monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None) + + await screen._execute_action(action) + + assert recorded_suspend == ['enter', 'exit'] + + +@pytest.mark.asyncio +async def test_detail_screen_execute_nested_json_action_without_suspend( + monkeypatch: MonkeyPatch, + tui_modules: SimpleNamespace, +) -> None: + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='传输加密', + subtitle='subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='运行校验', status='ok', lines=['line a']), + ], + ) + screen = tui_modules.cli_tui_detail.DetailScreen( + snapshot, + env='dev', + active_view='crypto', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + action = tui_modules.cli_tui_detail.TUI_ACTION_REGISTRY.resolve_detail_action( + view_key='crypto', + slot='global', + env='dev', + ) + assert action is not None + assert action.execution_mode == 'nested_json' + + suspend_calls: list[str] = [] + execute_calls: list[tuple[object, str]] = [] + + class DummySuspend: + def __enter__(self) -> None: + suspend_calls.append('enter') + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: Any, + ) -> None: + del exc_type, exc, tb + suspend_calls.append('exit') + + def build_suspend() -> DummySuspend: + return DummySuspend() + + def refresh_current_view() -> None: + pass + + fake_app = SimpleNamespace( + suspend=build_suspend, + action_refresh_current_view=refresh_current_view, + ) + monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app)) + monkeypatch.setattr( + tui_modules.cli_tui_interactions, + 'TUI_ACTION_EXECUTION_SERVICE', + SimpleNamespace( + execute=lambda spec, env: ( + execute_calls.append((spec, env)) + or tui_modules.cli_tui_detail.TuiActionResult( + spec=spec, + payload={'ok': True, 'message': '轮换预演完成'}, + ) + ), + build_result_lines=lambda result: [], + ), + ) + monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None) + + await screen._execute_action(action) + + assert suspend_calls == [] + assert execute_calls == [(action, 'dev')] + + +def test_detail_screen_support_builds_summary_actions_and_fallbacks( + tui_modules: SimpleNamespace, +) -> None: + support = tui_modules.cli_tui_detail.DetailScreenSupport() + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='数据库', + subtitle='subtitle', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='迁移版本', status='ok', lines=['line a']), + tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Heads 状态', status='warn', lines=['line b']), + ], + search=tui_modules.cli_tui_search.PageSearchContext( + placeholder='按 revision 搜索', + query='head', + suggestions=['head'], + ), + ) + + summary = support.build_summary_text(snapshot, 'database') + query = support.current_search_query(snapshot) + action = support.resolve_action(active_view='database', slot='global', env='dev') + empty_section = support.build_empty_section() + + assert '2 个分区' in summary + assert query == 'head' + assert action is not None + assert action.command_args[:2] == ('wizard', 'db-upgrade') + assert empty_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_detail_empty_section_copy('title') + + +def test_detail_screen_focus_service_moves_and_scrolls_between_focus_targets( + tui_modules: SimpleNamespace, +) -> None: + focus_service = tui_modules.cli_tui_detail.DetailScreenFocusService() + focused: list[str] = [] + sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar')) + sections = SimpleNamespace(focus=lambda: focused.append('sections')) + detail = SimpleNamespace(focus=lambda: focused.append('detail')) + workspace_main = object() + fake_app = SimpleNamespace(focused=None) + fake_screen = SimpleNamespace( + app=fake_app, + query_one=lambda selector, widget_type=None: { + tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar, + tui_modules.cli_tui_widgets.SectionNavigator: sections, + tui_modules.cli_tui_widgets.SectionDetailView: detail, + '#workspace-main': workspace_main, + }[selector], + ) + + focus_service.move_focus(fake_screen, 1) + fake_app.focused = sections + focus_service.move_focus(fake_screen, 1) + fake_app.focused = object() + scroll_target = focus_service.get_scroll_target(fake_screen) + + assert focused == ['sidebar', 'detail'] + assert scroll_target is workspace_main + + +def test_detail_screen_unmount_cancels_action_task(tui_modules: SimpleNamespace) -> None: + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='数据库', + subtitle='subtitle', + sections=[], + ) + screen = tui_modules.cli_tui_detail.DetailScreen( + snapshot, + env='dev', + active_view='database', + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + refreshed_at='2026-04-30 10:00:00', + ) + + cancelled: list[str] = [] + + class DummyTask: + def done(self) -> bool: + return False + + def cancel(self) -> None: + cancelled.append('action') + + screen._action_task = DummyTask() # type: ignore[assignment] + + screen.on_unmount() + + assert cancelled == ['action'] + + +@pytest.mark.asyncio +async def test_detail_screen_scrolls_in_small_viewport(tui_modules: SimpleNamespace) -> None: + thin_scrollbar_size = 1 + app = tui_modules.cli_tui_app.ShierfengTuiApp('dev') + app.action_show_dashboard = lambda: None # type: ignore[method-assign] + app.current_view = 'app' + snapshot = tui_modules.cli_tui_app.DetailPageSnapshot( + title='应用', + subtitle='超长详情', + sections=[ + tui_modules.cli_tui_adapters.DetailSectionSnapshot( + title='配置总览', + status='warn', + lines=[f'第{i:02d}行 内容很长 内容很长 内容很长 内容很长' for i in range(1, 40)], + ) + ], + ) + screen = app.screen_factory.build( + snapshot=snapshot, + env=app.env, + active_view=app.current_view, + navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS, + action_feedback_lines=app.get_action_feedback_lines(app.current_view), + ) + + async with app.run_test(size=(80, 24)) as pilot: + app.screen_navigator.show(screen) + await pilot.pause() + await pilot.pause() + + main = app.screen.query_one('#workspace-main') + detail_body = app.screen.query_one('#detail-body') + detail_view = app.screen.query_one(tui_modules.cli_tui_widgets.SectionDetailView) + + assert main.styles.scrollbar_size_vertical == thin_scrollbar_size + assert main.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert main.virtual_size.height > main.size.height + assert detail_body.styles.scrollbar_size_vertical == thin_scrollbar_size + assert detail_body.styles.scrollbar_size_horizontal == thin_scrollbar_size + assert detail_view.styles.scrollbar_size_vertical == thin_scrollbar_size + assert detail_view.styles.scrollbar_size_horizontal == thin_scrollbar_size diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/conftest.py b/shierfeng-fastapi-backend/tests/cli/wizard/conftest.py new file mode 100644 index 0000000..29f10f8 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/conftest.py @@ -0,0 +1,68 @@ +import importlib +import sys +from collections.abc import Callable +from pathlib import Path +from types import ModuleType + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parents[3] + +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + + +def _load_module(module_name: str) -> ModuleType: + sys.modules.pop(module_name, None) + sys.modules.pop('cli', None) + return importlib.import_module(module_name) + + +@pytest.fixture +def backend_dir() -> Path: + return BACKEND_DIR + + +@pytest.fixture +def load_module() -> Callable[[str], ModuleType]: + return _load_module + + +@pytest.fixture +def cli_main(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.main') + + +@pytest.fixture +def app_run_flow(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.wizard.flows.app_run') + + +@pytest.fixture +def cache_clear_flow(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.wizard.flows.cache_clear') + + +@pytest.fixture +def db_upgrade_flow(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.wizard.flows.db_upgrade') + + +@pytest.fixture +def gen_export_flow(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.wizard.flows.gen_export') + + +@pytest.fixture +def gen_import_flow(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.wizard.flows.gen_import') + + +@pytest.fixture +def prod_check_flow(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.wizard.flows.prod_check') + + +@pytest.fixture +def exit_codes(load_module: Callable[[str], ModuleType]) -> ModuleType: + return load_module('cli.exit_codes') diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_app_run.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_app_run.py new file mode 100644 index 0000000..53552f7 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_app_run.py @@ -0,0 +1,33 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_app_run_wizard_execs_nested_cli_after_confirmation( + monkeypatch: MonkeyPatch, + app_run_flow: ModuleType, +) -> None: + captured: dict[str, object] = {} + answers = iter(['dev', False, True]) + monkeypatch.setattr( + app_run_flow.AppRunWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': next(answers), + prompt_confirm=lambda prompt_text, default_value=False: next(answers), + ), + ) + monkeypatch.setattr( + app_run_flow.AppRunWizardFlow.context_factory, + 'build_readonly', + lambda env, output: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + app_run_flow.AppRunWizardFlow, + 'nested_live_command_runner', + lambda *arguments: captured.update({'arguments': arguments}), + ) + + app_run_flow.run_app_run_wizard() + + assert captured['arguments'] == ('app', 'run', '--env=dev') diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_cache_clear.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_cache_clear.py new file mode 100644 index 0000000..24897be --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_cache_clear.py @@ -0,0 +1,88 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_cache_clear_wizard_cancels_when_confirmation_rejected( + monkeypatch: MonkeyPatch, + cache_clear_flow: ModuleType, + exit_codes: ModuleType, +) -> None: + captured: dict[str, object] = {} + answers = iter(['dev', 'cache-name', 'sys_config', True, False]) + monkeypatch.setattr( + cache_clear_flow.CacheClearWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': next(answers), + prompt_choice=lambda prompt_text, choices, default_value: next(answers), + prompt_required_text=lambda prompt_text, default_value='': next(answers), + prompt_optional_text=lambda prompt_text, default_value='': '', + prompt_confirm=lambda prompt_text, default_value=False: next(answers), + ), + ) + monkeypatch.setattr( + cache_clear_flow.CacheClearWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + cache_clear_flow.CacheClearWizardFlow.execution_service, + 'complete_result', + lambda ctx, result: captured.update({'ctx': ctx, 'result': result}), + ) + + cache_clear_flow.run_cache_clear_wizard('text') + + assert captured['ctx'].env == 'dev' + assert captured['result'].exit_code == exit_codes.GUARD_REJECTED + assert captured['result'].data['message'] == '已取消向导执行:wizard cache-clear' + + +def test_cache_clear_wizard_accepts_default_context_values( + monkeypatch: MonkeyPatch, + cache_clear_flow: ModuleType, +) -> None: + captured_defaults: dict[str, object] = {} + captured_confirm_defaults: list[tuple[str, bool]] = [] + answers = iter(['dev', 'cache-name', 'sys_config', True, False]) + monkeypatch.setattr( + cache_clear_flow.CacheClearWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers), + prompt_choice=lambda prompt_text, choices, default_value: ( + captured_defaults.update({'mode': default_value}) or next(answers) + ), + prompt_required_text=lambda prompt_text, default_value='': ( + captured_defaults.update({'cache_name': default_value}) or next(answers) + ), + prompt_optional_text=lambda prompt_text, default_value='': '', + prompt_confirm=lambda prompt_text, default_value=False: ( + captured_confirm_defaults.append((prompt_text, default_value)) or next(answers) + ), + ), + ) + monkeypatch.setattr( + cache_clear_flow.CacheClearWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + cache_clear_flow.CacheClearWizardFlow.execution_service, + 'complete_result', + lambda ctx, result: None, + ) + + cache_clear_flow.run_cache_clear_wizard( + 'text', + default_env='dev', + default_mode='cache-name', + default_cache_name='sys_config', + default_dry_run=True, + ) + + assert captured_defaults['env'] == 'dev' + assert captured_defaults['mode'] == 'cache-name' + assert captured_defaults['cache_name'] == 'sys_config' + assert captured_confirm_defaults[0][1] is True diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_commands.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_commands.py new file mode 100644 index 0000000..8fe0ca3 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_commands.py @@ -0,0 +1,24 @@ +from pathlib import Path +from types import ModuleType + +from pytest import MonkeyPatch +from typer.testing import CliRunner + + +def test_wizard_help_lists_supported_subcommands( + monkeypatch: MonkeyPatch, + backend_dir: Path, + cli_main: ModuleType, +) -> None: + runner = CliRunner() + monkeypatch.chdir(backend_dir) + + result = runner.invoke(cli_main.CLI_APPLICATION_BUILDER.build(), ['wizard', '--help']) + + assert result.exit_code == 0 + assert 'app-run' in result.stdout + assert 'db-upgrade' in result.stdout + assert 'cache-clear' in result.stdout + assert 'gen-export' in result.stdout + assert 'gen-import' in result.stdout + assert 'prod-check' in result.stdout diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_db_upgrade.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_db_upgrade.py new file mode 100644 index 0000000..17e108e --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_db_upgrade.py @@ -0,0 +1,99 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_db_upgrade_wizard_executes_nested_cli_with_confirmed_inputs( + monkeypatch: MonkeyPatch, + db_upgrade_flow: ModuleType, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': 'dev', + prompt_required_text=lambda prompt_text, default_value='': 'head', + prompt_confirm=lambda prompt_text, default_value=False: True, + ), + ) + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace( + env=env, + output=output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ), + ) + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow, + 'nested_command_runner', + lambda *arguments, parse_json=False: SimpleNamespace( + returncode=0, + payload={'ok': True, 'message': '数据库已升级到 head', 'dryRun': True}, + ), + ) + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow.execution_service, + 'complete_payload', + lambda ctx, payload, default_exit_code=0: captured.update( + { + 'ctx': ctx, + 'payload': payload, + 'default_exit_code': default_exit_code, + } + ), + ) + + db_upgrade_flow.run_db_upgrade_wizard('json') + + assert captured['ctx'].env == 'dev' + assert captured['ctx'].output == 'json' + assert captured['ctx'].dry_run is True + assert captured['payload'] == {'ok': True, 'message': '数据库已升级到 head', 'dryRun': True} + + +def test_db_upgrade_wizard_accepts_default_context_values( + monkeypatch: MonkeyPatch, + db_upgrade_flow: ModuleType, +) -> None: + captured_defaults: dict[str, object] = {} + captured_confirm_defaults: list[tuple[str, bool]] = [] + confirm_answers = iter([True, False]) + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or 'dev', + prompt_required_text=lambda prompt_text, default_value='': ( + captured_defaults.update({'revision': default_value}) or 'head' + ), + prompt_confirm=lambda prompt_text, default_value=False: ( + captured_confirm_defaults.append((prompt_text, default_value)) or next(confirm_answers) + ), + ), + ) + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + db_upgrade_flow.DbUpgradeWizardFlow.execution_service, + 'complete_result', + lambda ctx, result: None, + ) + + db_upgrade_flow.run_db_upgrade_wizard( + 'text', + default_env='dev', + default_revision='head', + default_dry_run=True, + ) + + assert captured_defaults['env'] == 'dev' + assert captured_defaults['revision'] == 'head' + assert captured_confirm_defaults[0][1] is True diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_gen_export.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_gen_export.py new file mode 100644 index 0000000..bfd2793 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_gen_export.py @@ -0,0 +1,120 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_gen_export_wizard_executes_nested_cli_with_selected_tables( + monkeypatch: MonkeyPatch, + gen_export_flow: ModuleType, +) -> None: + captured: dict[str, object] = {} + answers = iter(['dev', 'sys_user,sys_role', 'zip', 'build/demo.zip', True, True]) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': next(answers), + prompt_required_text=lambda prompt_text, default_value='': next(answers), + prompt_choice=lambda prompt_text, choices, default_value: next(answers), + prompt_optional_text=lambda prompt_text, default_value='': next(answers), + prompt_confirm=lambda prompt_text, default_value=False: next(answers), + ), + ) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace( + env=env, + output=output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ), + ) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow, + 'nested_command_runner', + lambda *arguments, parse_json=False: SimpleNamespace( + returncode=0, + payload={ + 'ok': True, + 'message': '代码导出演练完成,未执行实际导出', + 'dryRun': True, + 'mode': 'zip', + 'tableNames': ['sys_user', 'sys_role'], + 'outputFile': '/tmp/demo.zip', + }, + ), + ) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow.execution_service, + 'complete_payload', + lambda ctx, payload, default_exit_code=0: captured.update( + { + 'ctx': ctx, + 'payload': payload, + 'default_exit_code': default_exit_code, + } + ), + ) + + gen_export_flow.run_gen_export_wizard('json') + + assert captured['ctx'].env == 'dev' + assert captured['ctx'].output == 'json' + assert captured['ctx'].dry_run is True + assert captured['payload']['tableNames'] == ['sys_user', 'sys_role'] + assert captured['payload']['mode'] == 'zip' + + +def test_gen_export_wizard_accepts_default_context_values( + monkeypatch: MonkeyPatch, + gen_export_flow: ModuleType, +) -> None: + captured_defaults: dict[str, object] = {} + captured_confirm_defaults: list[tuple[str, bool]] = [] + answers = iter(['dev', 'sys_user', 'zip', 'build/demo.zip', True, False]) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers), + prompt_required_text=lambda prompt_text, default_value='': ( + captured_defaults.update({'table_names': default_value}) or next(answers) + ), + prompt_choice=lambda prompt_text, choices, default_value: ( + captured_defaults.update({'mode': default_value}) or next(answers) + ), + prompt_optional_text=lambda prompt_text, default_value='': ( + captured_defaults.update({'output_file': default_value}) or next(answers) + ), + prompt_confirm=lambda prompt_text, default_value=False: ( + captured_confirm_defaults.append((prompt_text, default_value)) or next(answers) + ), + ), + ) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + gen_export_flow.GenExportWizardFlow.execution_service, + 'complete_result', + lambda ctx, result: None, + ) + + gen_export_flow.run_gen_export_wizard( + 'text', + default_env='dev', + default_table_names='sys_user', + default_mode='zip', + default_output_file='build/demo.zip', + default_dry_run=True, + ) + + assert captured_defaults['env'] == 'dev' + assert captured_defaults['table_names'] == 'sys_user' + assert captured_defaults['mode'] == 'zip' + assert captured_defaults['output_file'] == 'build/demo.zip' + assert captured_confirm_defaults[0][1] is True diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_gen_import.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_gen_import.py new file mode 100644 index 0000000..76336af --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_gen_import.py @@ -0,0 +1,164 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_gen_import_wizard_executes_nested_cli_with_selected_tables( + monkeypatch: MonkeyPatch, + gen_import_flow: ModuleType, +) -> None: + captured: dict[str, object] = {} + answers = iter(['dev', 'sys_user,sys_role', True, True]) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': next(answers), + prompt_required_text=lambda prompt_text, default_value='': next(answers), + prompt_confirm=lambda prompt_text, default_value=False: next(answers), + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace( + env=env, + output=output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow, + 'nested_command_runner', + lambda *arguments, parse_json=False: SimpleNamespace( + returncode=0, + payload={ + 'ok': True, + 'message': '物理表导入预演完成,未执行实际导入', + 'dryRun': True, + 'tableNames': ['sys_user', 'sys_role'], + }, + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow.execution_service, + 'complete_payload', + lambda ctx, payload, default_exit_code=0: captured.update( + { + 'ctx': ctx, + 'payload': payload, + 'default_exit_code': default_exit_code, + } + ), + ) + + gen_import_flow.run_gen_import_wizard('json') + + assert captured['ctx'].env == 'dev' + assert captured['ctx'].output == 'json' + assert captured['ctx'].dry_run is True + assert captured['payload']['tableNames'] == ['sys_user', 'sys_role'] + + +def test_gen_import_wizard_accepts_default_context_values( + monkeypatch: MonkeyPatch, + gen_import_flow: ModuleType, +) -> None: + captured_defaults: dict[str, object] = {} + captured_confirm_defaults: list[tuple[str, bool]] = [] + answers = iter(['dev', 'sys_user', True, False]) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers), + prompt_required_text=lambda prompt_text, default_value='': ( + captured_defaults.update({'table_names': default_value}) or next(answers) + ), + prompt_confirm=lambda prompt_text, default_value=False: ( + captured_confirm_defaults.append((prompt_text, default_value)) or next(answers) + ), + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow.execution_service, + 'complete_result', + lambda ctx, result: None, + ) + + gen_import_flow.run_gen_import_wizard( + 'text', + default_env='dev', + default_table_names='sys_user', + default_dry_run=True, + ) + + assert captured_defaults['env'] == 'dev' + assert captured_defaults['table_names'] == 'sys_user' + assert captured_confirm_defaults[0][1] is True + + +def test_gen_import_wizard_falls_back_to_failure_payload_when_nested_result_has_no_dict_payload( + monkeypatch: MonkeyPatch, + gen_import_flow: ModuleType, +) -> None: + captured: dict[str, object] = {} + answers = iter(['dev', 'sys_notice', True, True]) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='dev': next(answers), + prompt_required_text=lambda prompt_text, default_value='': next(answers), + prompt_confirm=lambda prompt_text, default_value=False: next(answers), + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow.context_factory, + 'build_regular', + lambda env, output, allow_prod, yes, dry_run: SimpleNamespace( + env=env, + output=output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow, + 'nested_command_runner', + lambda *arguments, parse_json=False: SimpleNamespace( + returncode=0, + stdout='OK SUCCESS\nmessage: 导入表结构演练完成,未执行实际写入', + stderr='', + payload=[], + ), + ) + monkeypatch.setattr( + gen_import_flow.GenImportWizardFlow.execution_service, + 'complete_payload', + lambda ctx, payload, default_exit_code=0: captured.update( + { + 'ctx': ctx, + 'payload': payload, + 'default_exit_code': default_exit_code, + } + ), + ) + + gen_import_flow.run_gen_import_wizard('text') + + assert captured['ctx'].env == 'dev' + assert captured['payload'] == { + 'ok': False, + 'message': '代码生成导入向导执行失败', + 'error': 'OK SUCCESS\nmessage: 导入表结构演练完成,未执行实际写入', + 'exit_code': 0, + } diff --git a/shierfeng-fastapi-backend/tests/cli/wizard/test_prod_check.py b/shierfeng-fastapi-backend/tests/cli/wizard/test_prod_check.py new file mode 100644 index 0000000..c956a4c --- /dev/null +++ b/shierfeng-fastapi-backend/tests/cli/wizard/test_prod_check.py @@ -0,0 +1,110 @@ +from types import ModuleType, SimpleNamespace + +from pytest import MonkeyPatch + + +def test_prod_check_wizard_aggregates_nested_cli_payloads( + monkeypatch: MonkeyPatch, + prod_check_flow: ModuleType, +) -> None: + captured: dict[str, object] = {} + answers = iter(['prod', True, True]) + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='prod': next(answers), + prompt_confirm=lambda prompt_text, default_value=False: next(answers), + ), + ) + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow.context_factory, + 'build_readonly', + lambda env, output: SimpleNamespace(env=env, output=output), + ) + + def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace: + del parse_json + if arguments[0:2] == ('app', 'env'): + return SimpleNamespace( + payload={ + 'ok': True, + 'runtime': { + 'cliEnv': 'prod', + 'configEnv': 'prod', + 'envFile': '.env.prod', + 'envFileExists': True, + }, + } + ) + if arguments[0:2] == ('app', 'doctor'): + return SimpleNamespace(payload={'ok': True, 'message': '应用启动前检查通过'}) + return SimpleNamespace( + payload={ + 'ok': True, + 'config': { + 'name': 'demo', + 'host': '0.0.0.0', + 'port': 9099, + 'dbType': 'mysql', + 'redisHost': '127.0.0.1', + 'redisPort': 6379, + }, + } + ) + + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow, + 'nested_command_runner', + fake_run_nested_cli_command, + ) + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow.execution_service, + 'complete_payload', + lambda ctx, payload, default_exit_code=0: captured.update({'ctx': ctx, 'payload': payload}), + ) + + prod_check_flow.run_prod_check_wizard('json') + + assert captured['ctx'].env == 'prod' + assert captured['payload']['ok'] is True + assert captured['payload']['doctor']['message'] == '应用启动前检查通过' + assert captured['payload']['runtime']['cliEnv'] == 'prod' + assert captured['payload']['config']['name'] == 'demo' + + +def test_prod_check_wizard_accepts_default_context_values( + monkeypatch: MonkeyPatch, + prod_check_flow: ModuleType, +) -> None: + captured_defaults: dict[str, object] = {} + confirm_answers = iter([True, False]) + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow, + 'prompt_service', + SimpleNamespace( + prompt_env=lambda default_env='prod': captured_defaults.update({'env': default_env}) or 'prod', + prompt_confirm=lambda prompt_text, default_value=False: ( + captured_defaults.update({'include_config': default_value}) or next(confirm_answers) + ), + ), + ) + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow.context_factory, + 'build_readonly', + lambda env, output: SimpleNamespace(env=env, output=output), + ) + monkeypatch.setattr( + prod_check_flow.ProdCheckWizardFlow.execution_service, + 'complete_result', + lambda ctx, result: None, + ) + + prod_check_flow.run_prod_check_wizard( + 'text', + default_env='prod', + default_include_config=True, + ) + + assert captured_defaults['env'] == 'prod' + assert captured_defaults['include_config'] is True diff --git a/shierfeng-fastapi-backend/tests/test_log_sanitize_util.py b/shierfeng-fastapi-backend/tests/test_log_sanitize_util.py new file mode 100644 index 0000000..d773264 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/test_log_sanitize_util.py @@ -0,0 +1,865 @@ +import asyncio +import json +import os +import sys +from collections.abc import Iterator +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from fastapi import Request +from loguru import logger as _logger + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from common.annotation.log_annotation import Log, RequestLogFieldRoot, ResponseLogFieldRoot +from common.enums import BusinessType +from config.env import LogConfig +from utils.log_util import LoggerInitializer, LogSanitizer, _build_text_assignment_patterns, _build_text_key_pattern + + +@pytest.fixture(autouse=True) +def enable_log_mask_for_tests() -> Iterator[None]: + with patch.object(LogConfig, 'log_mask_enabled', True): + yield + + +def test_sanitize_nested_log_payload() -> None: + payload = { + 'jsonBody': { + 'password': 'plain-password', + 'apiKey': 'sk-123456', + 'systemPrompt': 'top secret prompt', + 'phonenumber': '13812345678', + 'email': 'admin@example.com', + 'ipaddr': '192.168.1.10', + } + } + + sanitized = LogSanitizer.sanitize_data(payload) + + assert sanitized['jsonBody']['password'] == '******' + assert sanitized['jsonBody']['apiKey'] == '******' + assert sanitized['jsonBody']['systemPrompt'] == '******' + assert sanitized['jsonBody']['phonenumber'] == '138****5678' + assert sanitized['jsonBody']['email'] == 'a***n@example.com' + assert sanitized['jsonBody']['ipaddr'] == '192.168.1.10' + + +def test_ip_masking_can_be_enabled_by_partial_mask_field_configuration() -> None: + payload = { + 'jsonBody': { + 'ipaddr': '192.168.1.10', + 'login_ip': '192.168.1.11', + } + } + + with patch.object( + LogSanitizer, + '_PARTIAL_MASK_FIELDS', + LogSanitizer._PARTIAL_MASK_FIELDS | {'ip', 'ipaddr', 'operip', 'loginip'}, + ): + sanitized = LogSanitizer.sanitize_data(payload) + + assert sanitized['jsonBody']['ipaddr'] == '192.168.1.*' + assert sanitized['jsonBody']['login_ip'] == '192.168.1.*' + + +def test_sanitize_config_value_by_config_key() -> None: + payload = { + 'configKey': 'sys.transport.privateKey', + 'configValue': '-----BEGIN PRIVATE KEY-----abc', + } + + sanitized = LogSanitizer.sanitize_data(payload) + + assert sanitized['configValue'] == '******' + + +def test_sanitize_text_message() -> None: + text = 'Authorization=Bearer abc.def password=123456 api_key=sk-test' + expected_mask_count = 3 + + sanitized = LogSanitizer.sanitize_text(text) + + assert 'abc.def' not in sanitized + assert '123456' not in sanitized + assert 'sk-test' not in sanitized + assert sanitized.count('******') >= expected_mask_count + + +def test_sanitize_text_masks_configured_secret_fields() -> None: + text = ( + 'secret_key=my-secret private_key="-----BEGIN PRIVATE KEY-----abc" ' + "credential='cred-123' credentials=cred-list" + ) + expected_mask_count = 4 + + sanitized = LogSanitizer.sanitize_text(text) + + assert 'my-secret' not in sanitized + assert '-----BEGIN PRIVATE KEY-----abc' not in sanitized + assert 'cred-123' not in sanitized + assert 'cred-list' not in sanitized + assert sanitized.count('******') >= expected_mask_count + + +def test_sanitize_text_key_pattern_does_not_cross_lines() -> None: + text = 'header\nprivate\n key=value\nfooter' + + sanitized = LogSanitizer.sanitize_text(text) + + assert sanitized == text + + +def test_sanitize_stringified_json_preserves_newlines_after_masking() -> None: + text = '{\n "password": "123456",\n "userName": "admin"\n}' + + sanitized = LogSanitizer.sanitize_data({'operParam': text}) + + assert sanitized['operParam'] == '{\n "password": "******",\n "userName": "admin"\n}' + + +def test_sanitize_text_masks_configured_partial_fields() -> None: + text = 'email=admin@example.com phonenumber=13812345678 mobile="13812345679"' + + sanitized = LogSanitizer.sanitize_text(text) + + assert 'admin@example.com' not in sanitized + assert '13812345678' not in sanitized + assert '13812345679' not in sanitized + assert 'a***n@example.com' in sanitized + assert '138****5678' in sanitized + assert '138****5679' in sanitized + + +def test_sanitize_text_can_enable_ip_masking_by_partial_mask_field_configuration() -> None: + text = 'login_ip=192.168.1.10 ipaddr="192.168.1.11"' + ip_fields = LogSanitizer._PARTIAL_MASK_FIELDS | {'ip', 'ipaddr', 'operip', 'loginip'} + ip_key_pattern = '|'.join( + sorted( + { + _build_text_key_pattern(field_name) + for field_name in (*LogSanitizer._TEXT_PARTIAL_FIELDS, 'ip', 'ipaddr', 'oper_ip', 'login_ip') + }, + key=len, + reverse=True, + ) + ) + + with ( + patch.object(LogSanitizer, '_PARTIAL_MASK_FIELDS', ip_fields), + patch.object(LogSanitizer, '_PARTIAL_KV_PATTERNS', _build_text_assignment_patterns(ip_key_pattern)), + ): + sanitized = LogSanitizer.sanitize_text(text) + + assert '192.168.1.10' not in sanitized + assert '192.168.1.11' not in sanitized + assert '192.168.1.*' in sanitized + + +def test_sanitize_text_masks_python_repr_like_payload() -> None: + text = "ValueError({'secret_key': 'abc', 'password': '123456', 'api_key': 'sk-123'})" + expected_mask_count = 3 + + sanitized = LogSanitizer.sanitize_text(text) + + assert 'abc' not in sanitized + assert '123456' not in sanitized + assert 'sk-123' not in sanitized + assert sanitized.count('******') >= expected_mask_count + + +def test_sanitize_text_masks_alphanumeric_verification_code() -> None: + text = '短信验证码为A1B2' + + sanitized = LogSanitizer.sanitize_text(text) + + assert 'A1B2' not in sanitized + assert '******' in sanitized + + +def test_sanitize_text_returns_original_when_mask_disabled() -> None: + text = 'Authorization=Bearer abc.def password=123456 api_key=sk-test' + + with patch.object(LogConfig, 'log_mask_enabled', False): + sanitized = LogSanitizer.sanitize_text(text) + + assert sanitized == text + + +def test_build_json_payload_sanitizes_exception_and_extra() -> None: + initializer = LoggerInitializer() + try: + raise ValueError('token=abc.def') + except ValueError as exc: + exception = SimpleNamespace( + type=type(exc), + value=exc, + traceback=exc.__traceback__, + ) + record = { + 'time': datetime(2026, 4, 20, 12, 0, 0), + 'level': SimpleNamespace(name='ERROR'), + 'message': 'password=123456', + 'name': 'test_logger', + 'module': 'test_module', + 'function': 'test_function', + 'line': 1, + 'extra': {'authorization': 'Bearer abc.def', 'trace_id': 'trace-1'}, + 'exception': exception, + } + + initializer._patch_record(record) + initializer._filter(record) + payload = initializer._build_json_payload(record) + + assert '123456' not in payload['message'] + assert 'abc.def' not in payload['exception']['value'] + assert 'abc.def' not in payload['exception']['traceback'] + assert 'ValueError' in payload['exception']['traceback'] + assert payload['extra']['authorization'] == '******' + + +def test_json_log_formatter_emits_valid_json_record() -> None: + initializer = LoggerInitializer() + outputs = [] + test_logger = _logger.patch(initializer._patch_record) + test_logger.remove() + test_logger.add( + lambda message: outputs.append(str(message)), + format=initializer._json_log_formatter, + filter=initializer._filter, + ) + + test_logger.info('hello password=123456') + + assert len(outputs) == 1 + payload = json.loads(outputs[0]) + assert payload['message'] == 'hello password=******' + assert payload['level'] == 'INFO' + + +def test_json_log_formatter_does_not_leak_internal_payload_between_handlers() -> None: + initializer = LoggerInitializer() + first_outputs = [] + second_outputs = [] + test_logger = _logger.patch(initializer._patch_record) + test_logger.remove() + test_logger.add( + lambda message: first_outputs.append(str(message)), + format=initializer._json_log_formatter, + filter=initializer._filter, + ) + test_logger.add( + lambda message: second_outputs.append(str(message)), + format=initializer._json_log_formatter, + filter=initializer._filter, + ) + + test_logger.info('hello') + + assert 'json_payload' not in json.loads(first_outputs[0])['extra'] + assert 'json_payload' not in json.loads(second_outputs[0])['extra'] + + +def test_plain_log_formatter_sanitizes_exception_traceback() -> None: + initializer = LoggerInitializer() + outputs = [] + test_logger = _logger.patch(initializer._patch_record) + test_logger.remove() + test_logger.add( + lambda message: outputs.append(str(message)), + format=initializer._plain_log_formatter, + filter=initializer._filter, + backtrace=False, + diagnose=False, + ) + + try: + raise ValueError('password=123456 token=abc.def') + except ValueError: + test_logger.exception('boom password=123456') + + output = outputs[0] + + assert '123456' not in output + assert 'abc.def' not in output + assert 'ValueError' in output + assert '******' in output + + +def test_plain_log_formatter_separates_consecutive_records() -> None: + initializer = LoggerInitializer() + outputs = [] + expected_output_count = 2 + test_logger = _logger.patch(initializer._patch_record) + test_logger.remove() + test_logger.add( + lambda message: outputs.append(str(message)), + format=initializer._plain_log_formatter, + filter=initializer._filter, + ) + + test_logger.info('first') + test_logger.info('second') + + assert len(outputs) == expected_output_count + assert outputs[0].endswith('\n') + assert 'first\n' in ''.join(outputs) + + +def test_get_request_params_returns_structured_payload() -> None: + async def receive() -> dict: + return { + 'type': 'http.request', + 'body': b'{"password":"plain-password","phonenumber":"13812345678"}', + 'more_body': False, + } + + request = Request( + { + 'type': 'http', + 'method': 'POST', + 'path': '/test', + 'headers': [(b'content-type', b'application/json')], + 'path_params': {}, + 'query_string': b'page=1', + }, + receive=receive, + ) + + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + params = asyncio.run(log_decorator._get_request_params(request)) + + assert params['query_params']['page'] == '1' + assert params['json_body']['password'] == 'plain-password' + + +def test_get_request_params_falls_back_to_raw_body_when_json_invalid() -> None: + async def receive() -> dict: + return { + 'type': 'http.request', + 'body': b'{bad json', + 'more_body': False, + } + + request = Request( + { + 'type': 'http', + 'method': 'POST', + 'path': '/test', + 'headers': [(b'content-type', b'application/json')], + 'path_params': {}, + 'query_string': b'', + }, + receive=receive, + ) + + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + params = asyncio.run(log_decorator._get_request_params(request)) + + assert params['raw_body'] == '{bad json' + mock_warning.assert_called_once() + assert '解析失败' in mock_warning.call_args.args[0] + + +def test_get_request_params_decodes_non_utf8_body_without_raising() -> None: + async def receive() -> dict: + return { + 'type': 'http.request', + 'body': b'\xff\xfeabc', + 'more_body': False, + } + + request = Request( + { + 'type': 'http', + 'method': 'POST', + 'path': '/test', + 'headers': [(b'content-type', b'text/plain')], + 'path_params': {}, + 'query_string': b'', + }, + receive=receive, + ) + + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + params = asyncio.run(log_decorator._get_request_params(request)) + + assert params['raw_body'].endswith('abc') + assert '\ufffd' in params['raw_body'] + + +def test_log_handles_missing_user_agent_without_error() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER, log_type='login') + + assert log_decorator._get_oper_type(None) == 0 + + login_log = log_decorator._get_login_log( + None, + oper_ip='127.0.0.1', + oper_location='内网IP', + oper_time=datetime(2026, 4, 20, 12, 0, 0), + origin_kwargs={}, + ) + + assert login_log['ipaddr'] == '127.0.0.1' + assert login_log['loginLocation'] == '内网IP' + assert login_log['browser'] == 'Other' + assert login_log['os'] == 'Other' + + +def test_build_log_text_with_summary_mode() -> None: + log_decorator = Log( + title='测试日志', + business_type=BusinessType.OTHER, + request_log_mode='summary', + ) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'query_params': {'page': '1'}, + 'json_body': {'password': 'plain-password', 'modelName': 'demo-model'}, + } + ), + mode='summary', + include_fields=(), + exclude_fields=(), + payload_kind='request', + ) + log_payload = json.loads(log_text) + + assert log_payload['mode'] == 'summary' + assert log_payload['query_param_keys'] == ['page'] + assert log_payload['json_body_keys'] == ['password', 'modelName'] + + +def test_build_log_text_with_include_mode() -> None: + log_decorator = Log( + title='测试日志', + business_type=BusinessType.OTHER, + response_log_mode='include', + response_include_fields=('data.token', 'data.userName'), + ) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'code': 200, + 'msg': 'success', + 'data': {'token': 'abc.def', 'userName': 'admin', 'extra': 'ignored'}, + } + ), + mode='include', + include_fields=('data.token', 'data.userName'), + exclude_fields=(), + payload_kind='response', + ) + log_payload = json.loads(log_text) + + assert log_payload['mode'] == 'include' + assert log_payload['selected']['data.token'] == '******' + assert log_payload['selected']['data.userName'] == 'admin' + assert 'data.extra' not in log_payload['selected'] + + +def test_include_fields_support_snake_case_matching_camel_case_payload() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'json_body': { + 'modelCode': 'deepseek-chat', + 'supportReasoning': 'Y', + } + } + ), + mode='include', + include_fields=('json_body.model_code', 'json_body.support_reasoning'), + exclude_fields=(), + payload_kind='request', + ) + log_payload = json.loads(log_text) + + assert log_payload['selected']['json_body.model_code'] == 'deepseek-chat' + assert log_payload['selected']['json_body.support_reasoning'] == 'Y' + + +def test_include_fields_support_camel_case_matching_snake_case_payload() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'data': { + 'user_name': 'admin', + 'login_ip': '192.168.1.10', + } + } + ), + mode='include', + include_fields=('data.userName', 'data.loginIp'), + exclude_fields=(), + payload_kind='response', + ) + log_payload = json.loads(log_text) + + assert log_payload['selected']['data.userName'] == 'admin' + assert log_payload['selected']['data.loginIp'] == '192.168.1.10' + + +def test_request_field_helper_builds_canonical_path() -> None: + assert RequestLogFieldRoot.JSON_BODY.field('model_code') == 'json_body.model_code' + assert ResponseLogFieldRoot.DATA.field('userName') == 'data.userName' + + +def test_code_field_masking_only_applies_to_login_like_verification_code() -> None: + login_payload = { + 'uuid': 'captcha-session', + 'code': 'A1B2', + 'msg': 'login', + } + business_payload = { + 'code': '200', + 'msg': 'ok', + } + + sanitized_login_payload = LogSanitizer.sanitize_data(login_payload) + sanitized_business_payload = LogSanitizer.sanitize_data(business_payload) + + assert sanitized_login_payload['code'] == '******' + assert sanitized_business_payload['code'] == '200' + + +def test_build_log_text_with_exclude_mode() -> None: + log_decorator = Log( + title='测试日志', + business_type=BusinessType.OTHER, + request_log_mode='exclude', + request_exclude_fields=('json_body.api_key',), + ) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'json_body': { + 'modelCode': 'deepseek-chat', + 'apiKey': 'sk-test', + 'baseUrl': 'https://api.example.com', + } + } + ), + mode='exclude', + include_fields=(), + exclude_fields=('json_body.api_key',), + payload_kind='request', + ) + log_payload = json.loads(log_text) + + assert 'apiKey' not in log_payload['json_body'] + assert log_payload['json_body']['modelCode'] == 'deepseek-chat' + assert log_payload['json_body']['baseUrl'] == 'https://api.example.com' + + +def test_exclude_fields_support_snake_case_matching_camel_case_payload() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'data': { + 'apiKey': '******', + 'baseUrl': 'https://api.example.com', + } + } + ), + mode='exclude', + include_fields=(), + exclude_fields=('data.api_key',), + payload_kind='response', + ) + log_payload = json.loads(log_text) + + assert 'apiKey' not in log_payload['data'] + assert log_payload['data']['baseUrl'] == 'https://api.example.com' + + +def test_exclude_fields_remove_multiple_list_indexes_without_shift() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'rows': [ + {'id': 1}, + {'id': 2}, + {'id': 3}, + ] + } + ), + mode='exclude', + include_fields=(), + exclude_fields=('rows.0', 'rows.1'), + payload_kind='response', + ) + log_payload = json.loads(log_text) + + assert log_payload['rows'] == [{'id': 3}] + + +def test_collect_field_path_warnings_for_invalid_root() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + warnings = log_decorator._collect_field_path_warnings( + mode='include', + include_fields=('body.modelCode',), + exclude_fields=(), + payload_kind='request', + ) + + assert len(warnings) == 1 + assert 'body.modelCode' in warnings[0] + assert 'json_body' in warnings[0] + + +def test_collect_field_path_warnings_accepts_normalized_root() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + warnings = log_decorator._collect_field_path_warnings( + mode='include', + include_fields=('jsonBody.modelCode',), + exclude_fields=(), + payload_kind='request', + ) + + assert warnings == [] + + +def test_collect_field_path_warnings_for_missing_include_fields() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + warnings = log_decorator._collect_field_path_warnings( + mode='include', + include_fields=(), + exclude_fields=(), + payload_kind='response', + ) + + assert len(warnings) == 1 + assert 'include模式' in warnings[0] + assert 'data' in warnings[0] + + +def test_collect_field_path_warnings_for_missing_exclude_fields() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + warnings = log_decorator._collect_field_path_warnings( + mode='exclude', + include_fields=(), + exclude_fields=(), + payload_kind='request', + ) + + assert len(warnings) == 1 + assert 'exclude模式' in warnings[0] + assert 'json_body' in warnings[0] + + +def test_log_warns_when_include_fields_do_not_match_mode() -> None: + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + Log( + title='测试日志', + business_type=BusinessType.OTHER, + request_log_mode='summary', + request_include_fields=('json_body.modelCode',), + ) + + mock_warning.assert_called_once() + assert '不会生效' in mock_warning.call_args.args[0] + + +def test_request_include_fields_invalid_root_raises_value_error() -> None: + try: + Log( + title='测试日志', + business_type=BusinessType.OTHER, + request_log_mode='include', + request_include_fields=('body.modelCode',), + ) + except ValueError as exc: + assert 'body.modelCode' in str(exc) + else: + raise AssertionError('expected ValueError for invalid request include root') + + +def test_request_exclude_fields_invalid_root_raises_value_error() -> None: + try: + Log( + title='测试日志', + business_type=BusinessType.OTHER, + request_log_mode='exclude', + request_exclude_fields=('body.apiKey',), + ) + except ValueError as exc: + assert 'body.apiKey' in str(exc) + else: + raise AssertionError('expected ValueError for invalid request exclude root') + + +def test_build_log_text_warns_for_missing_include_field() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'json_body': { + 'modelCode': 'deepseek-chat', + } + } + ), + mode='include', + include_fields=('json_body.modleCode',), + exclude_fields=(), + payload_kind='request', + ) + + log_payload = json.loads(log_text) + + assert log_payload['selected'] == {} + mock_warning.assert_called_once() + assert '未命中' in mock_warning.call_args.args[0] + assert 'modleCode' in mock_warning.call_args.args[0] + + +def test_missing_include_field_warning_only_emitted_once() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + payload = LogSanitizer.sanitize_data({'data': {'userName': 'admin'}}) + + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + log_decorator._build_log_text( + payload, + mode='include', + include_fields=('data.userNmae',), + exclude_fields=(), + payload_kind='response', + ) + log_decorator._build_log_text( + payload, + mode='include', + include_fields=('data.userNmae',), + exclude_fields=(), + payload_kind='response', + ) + + mock_warning.assert_called_once() + + +def test_include_fields_keep_explicit_none_value() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'data': { + 'userName': None, + } + } + ), + mode='include', + include_fields=('data.userName',), + exclude_fields=(), + payload_kind='response', + ) + + log_payload = json.loads(log_text) + + assert 'data.userName' in log_payload['selected'] + assert log_payload['selected']['data.userName'] is None + mock_warning.assert_not_called() + + +def test_include_field_warning_reports_ambiguous_normalized_match() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'data': { + 'user_name': 'admin', + 'userName': 'root', + } + } + ), + mode='include', + include_fields=('data.username',), + exclude_fields=(), + payload_kind='response', + ) + + log_payload = json.loads(log_text) + + assert log_payload['selected'] == {} + mock_warning.assert_called_once() + assert '命名冲突' in mock_warning.call_args.args[0] + assert 'user_name' in mock_warning.call_args.args[0] + assert 'userName' in mock_warning.call_args.args[0] + + +def test_build_log_text_warns_for_missing_exclude_field() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + with patch('common.annotation.log_annotation.logger.warning') as mock_warning: + log_text = log_decorator._build_log_text( + LogSanitizer.sanitize_data( + { + 'json_body': { + 'modelCode': 'deepseek-chat', + } + } + ), + mode='exclude', + include_fields=(), + exclude_fields=('json_body.apiKey',), + payload_kind='request', + ) + + log_payload = json.loads(log_text) + + assert log_payload['json_body']['modelCode'] == 'deepseek-chat' + mock_warning.assert_called_once() + assert '排除路径未命中' in mock_warning.call_args.args[0] + + +def test_build_summary_payload_supports_message_field_fallback() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + + summary_payload = log_decorator._build_summary_payload({'code': 200, 'message': '获取成功'}, 'response') + status, error_msg = log_decorator._get_status_and_error_msg({'code': 500, 'message': '获取失败'}) + + assert summary_payload['msg'] == '获取成功' + assert status == 1 + assert error_msg == '获取失败' + + +def test_log_decorator_truncates_oversized_json_result() -> None: + log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER) + json_result = log_decorator._build_log_text( + {'code': 200, 'data': {'content': 'x' * 2100}}, + mode='full', + include_fields=(), + exclude_fields=(), + payload_kind='response', + ) + + limited_json_result = log_decorator._limit_log_text(json_result, log_decorator._json_result_len, '返回参数过长') + + assert limited_json_result == '返回参数过长' diff --git a/shierfeng-fastapi-backend/utils/ai_util.py b/shierfeng-fastapi-backend/utils/ai_util.py new file mode 100644 index 0000000..e9bd4c7 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/ai_util.py @@ -0,0 +1,172 @@ +from importlib import import_module +from typing import TYPE_CHECKING + +from config.database import async_engine +from config.env import DataBaseConfig + +if TYPE_CHECKING: + from agno.db.base import AsyncBaseDb + from agno.models.base import Model + +# 提供商名称 -> (模块路径, 类名) 的映射,延迟导入避免启动时加载所有AI SDK +_PROVIDER_REGISTRY: dict[str, tuple[str, str]] = { + 'AIMLAPI': ('agno.models.aimlapi', 'AIMLAPI'), + 'Anthropic': ('agno.models.anthropic', 'Claude'), + 'Cerebras': ('agno.models.cerebras', 'Cerebras'), + 'CerebrasOpenAI': ('agno.models.cerebras', 'CerebrasOpenAI'), + 'Cohere': ('agno.models.cohere', 'Cohere'), + 'CometAPI': ('agno.models.cometapi', 'CometAPI'), + 'DashScope': ('agno.models.dashscope', 'DashScope'), + 'DeepInfra': ('agno.models.deepinfra', 'DeepInfra'), + 'DeepSeek': ('agno.models.deepseek', 'DeepSeek'), + 'Fireworks': ('agno.models.fireworks', 'Fireworks'), + 'Google': ('agno.models.google', 'Gemini'), + 'Groq': ('agno.models.groq', 'Groq'), + 'HuggingFace': ('agno.models.huggingface', 'HuggingFace'), + 'LangDB': ('agno.models.langdb', 'LangDB'), + 'LiteLLM': ('agno.models.litellm', 'LiteLLM'), + 'LiteLLMOpenAI': ('agno.models.litellm', 'LiteLLMOpenAI'), + 'LlamaCpp': ('agno.models.llama_cpp', 'LlamaCpp'), + 'LMStudio': ('agno.models.lmstudio', 'LMStudio'), + 'Meta': ('agno.models.meta', 'Llama'), + 'Mistral': ('agno.models.mistral', 'MistralChat'), + 'N1N': ('agno.models.n1n', 'N1N'), + 'Nebius': ('agno.models.nebius', 'Nebius'), + 'Nexus': ('agno.models.nexus', 'Nexus'), + 'Nvidia': ('agno.models.nvidia', 'Nvidia'), + 'Ollama': ('agno.models.ollama', 'Ollama'), + 'OpenAI': ('agno.models.openai', 'OpenAIChat'), + 'OpenAIResponses': ('agno.models.openai.responses', 'OpenAIResponses'), + 'OpenRouter': ('agno.models.openrouter', 'OpenRouter'), + 'Perplexity': ('agno.models.perplexity', 'Perplexity'), + 'Portkey': ('agno.models.portkey', 'Portkey'), + 'Requesty': ('agno.models.requesty', 'Requesty'), + 'Sambanova': ('agno.models.sambanova', 'Sambanova'), + 'SiliconFlow': ('agno.models.siliconflow', 'Siliconflow'), + 'Together': ('agno.models.together', 'Together'), + 'Vercel': ('agno.models.vercel', 'V0'), + 'VLLM': ('agno.models.vllm', 'VLLM'), + 'xAI': ('agno.models.xai', 'xAI'), +} + +# 存储引擎名称 -> (模块路径, 类名) 的映射 +_STORAGE_ENGINE_REGISTRY: dict[str, tuple[str, str]] = { + 'mysql': ('agno.db.mysql', 'AsyncMySQLDb'), + 'postgresql': ('agno.db.postgres', 'AsyncPostgresDb'), +} + +# 已加载的提供商类缓存,避免重复import_module +_provider_class_cache: dict[str, 'type[Model]'] = {} +_storage_class_cache: dict[str, 'type[AsyncBaseDb]'] = {} + + +class AiUtil: + """ + AI工具类 + """ + + @classmethod + def _resolve_provider_class(cls, provider: str) -> 'type[Model] | None': + """ + 按需加载并缓存提供商模型类 + + :param provider: 提供商名称 + :return: 模型类,未找到返回None + """ + if provider in _provider_class_cache: + return _provider_class_cache[provider] + entry = _PROVIDER_REGISTRY.get(provider) + if entry is None: + return None + module_path, class_name = entry + provider_cls = getattr(import_module(module_path), class_name) + _provider_class_cache[provider] = provider_cls + return provider_cls + + @classmethod + def _resolve_storage_class(cls, db_type: str) -> 'type[AsyncBaseDb]': + """ + 按需加载并缓存存储引擎类 + + :param db_type: 数据库类型 + :return: 存储引擎类 + """ + if db_type in _storage_class_cache: + return _storage_class_cache[db_type] + entry = _STORAGE_ENGINE_REGISTRY.get(db_type) + if entry is None: + # 默认使用MySQL + entry = _STORAGE_ENGINE_REGISTRY['mysql'] + module_path, class_name = entry + storage_cls = getattr(import_module(module_path), class_name) + _storage_class_cache[db_type] = storage_cls + return storage_cls + + @classmethod + def get_storage_engine(cls) -> 'AsyncBaseDb': + """ + 获取存储引擎实例 + + :return: 存储引擎实例 + """ + storage_engine_class = cls._resolve_storage_class(DataBaseConfig.db_type) + + return storage_engine_class( + db_engine=async_engine, + db_schema=DataBaseConfig.db_database if DataBaseConfig.db_type == 'mysql' else 'public', + session_table='ai_sessions', + memory_table='ai_memories', + metrics_table='ai_metrics', + eval_table='ai_eval_runs', + knowledge_table='ai_knowledge', + culture_table='ai_culture', + traces_table='ai_traces', + spans_table='ai_spans', + versions_table='ai_schema_versions', + create_schema=False, + ) + + @classmethod + def get_model_from_factory( + cls, + provider: str, + model_code: str, + model_name: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + **kwargs, + ) -> 'Model': + """ + 从工厂获取模型实例 + + :param provider: 提供商 + :param model_code: 模型编码 + :param model_name: 模型名称 + :param api_key: API密钥 + :param base_url: 基础URL + :param temperature: 温度 + :param max_tokens: 最大令牌数 + :return: 模型实例 + """ + params = { + 'id': model_code, + 'name': model_name, + 'base_url': base_url, + 'api_key': api_key, + 'temperature': temperature, + 'max_tokens': max_tokens, + **kwargs, + } + params = {k: v for k, v in params.items() if v is not None} + if provider == 'Ollama': + params['host'] = base_url + if provider == 'DashScope' and not base_url: + params['base_url'] = 'https://dashscope.aliyuncs.com/compatible-mode/v1' + model_class = cls._resolve_provider_class(provider) + if model_class is None: + # 未知提供商,回退到OpenAI + model_class = cls._resolve_provider_class('OpenAI') + + return model_class(**params) diff --git a/shierfeng-fastapi-backend/utils/api_annotation_util.py b/shierfeng-fastapi-backend/utils/api_annotation_util.py new file mode 100644 index 0000000..09c1454 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/api_annotation_util.py @@ -0,0 +1,102 @@ +import inspect +from collections.abc import Awaitable, Callable, Sequence +from typing import TypeVar + +from fastapi import Request +from redis import asyncio as aioredis +from typing_extensions import ParamSpec + +from common.enums import HttpMethod +from utils.log_util import logger + +P = ParamSpec('P') +R = TypeVar('R') + + +class ApiAnnotationUtil: + """ + 接口装饰器通用工具类 + """ + + @classmethod + def get_request(cls, func: Callable[P, Awaitable[R]], *args: P.args, **kwargs: P.kwargs) -> Request | None: + """ + 从被装饰函数的入参中提取Request对象 + + :param func: 被装饰的异步接口函数 + :param args: 位置参数 + :param kwargs: 关键字参数 + :return: Request对象,未找到时返回None + """ + signature = inspect.signature(func) + bound_arguments = signature.bind_partial(*args, **kwargs) + for argument in bound_arguments.arguments.values(): + if isinstance(argument, Request): + return argument + + return None + + @classmethod + def get_redis_client(cls, request: Request, skip_message: str) -> aioredis.Redis | None: + """ + 从应用状态中获取Redis连接 + + :param request: 当前请求对象 + :param skip_message: 未初始化Redis连接时的日志信息 + :return: Redis连接对象,未初始化时返回None + """ + redis = getattr(request.app.state, 'redis', None) + if redis is None: + logger.warning(skip_message) + + return redis + + @classmethod + def resolve_request_redis( + cls, + func: Callable[P, Awaitable[R]], + skip_message: str, + *args: P.args, + **kwargs: P.kwargs, + ) -> tuple[Request | None, aioredis.Redis | None]: + """ + 从被装饰函数入参中同时解析Request与Redis连接 + + :param func: 被装饰的异步接口函数 + :param skip_message: 未初始化Redis连接时的日志信息 + :param args: 位置参数 + :param kwargs: 关键字参数 + :return: Request对象与Redis连接对象组成的元组 + """ + request = cls.get_request(func, *args, **kwargs) + if request is None: + return None, None + + return request, cls.get_redis_client(request, skip_message) + + @classmethod + def normalize_http_methods( + cls, + methods: Sequence[HttpMethod] | None, + default_methods: Sequence[HttpMethod] | None = None, + ) -> tuple[str, ...]: + """ + 标准化HTTP请求方法配置 + + :param methods: 显式配置的HTTP请求方法 + :param default_methods: methods为空时使用的默认HTTP请求方法 + :return: 去重且标准化后的HTTP请求方法元组 + """ + target_methods = methods if methods is not None else default_methods + if not target_methods: + return () + + normalized_methods: list[str] = [] + for method in target_methods: + if not isinstance(method, HttpMethod): + raise TypeError('methods参数仅支持HttpMethod枚举') + normalized_method = method.value + if normalized_method not in normalized_methods: + normalized_methods.append(normalized_method) + + return tuple(normalized_methods) diff --git a/shierfeng-fastapi-backend/utils/api_response_header_util.py b/shierfeng-fastapi-backend/utils/api_response_header_util.py new file mode 100644 index 0000000..40824e3 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/api_response_header_util.py @@ -0,0 +1,25 @@ +from collections.abc import Mapping + +from fastapi import Request + + +class ApiResponseHeaderUtil: + """ + 接口响应头通用工具类 + """ + + @classmethod + def merge_headers(cls, request: Request, headers: Mapping[str, str] | None) -> None: + """ + 将响应头暂存到请求上下文中,供出站中间件统一追加 + + :param request: 当前请求对象 + :param headers: 需要追加的响应头 + :return: None + """ + if not headers: + return + + api_response_headers = dict(getattr(request.state, 'api_response_headers', {})) + api_response_headers.update(headers) + request.state.api_response_headers = api_response_headers diff --git a/shierfeng-fastapi-backend/utils/client_ip_util.py b/shierfeng-fastapi-backend/utils/client_ip_util.py new file mode 100644 index 0000000..34b7fdc --- /dev/null +++ b/shierfeng-fastapi-backend/utils/client_ip_util.py @@ -0,0 +1,60 @@ +from fastapi import Request + +from config.env import AppConfig + + +class ClientIPUtil: + """ + 客户端IP提取工具 + """ + + @classmethod + def get_client_ip(cls, request: Request) -> str: + """ + 获取客户端真实IP + + 仅当请求来源命中可信代理列表,且可信代理跳数大于0时,才会解析 + X-Forwarded-For / X-Real-IP 请求头;否则回退到直接连接来源地址。 + + :param request: 当前请求对象 + :return: 客户端IP + """ + remote_addr = request.client.host if request.client else 'unknown' + if AppConfig.app_trusted_proxy_hops <= 0: + return remote_addr + if not cls._should_trust_proxy_headers(remote_addr): + return remote_addr + + forwarded_for = request.headers.get('X-Forwarded-For', '') + if forwarded_for: + forwarded_chain = [item.strip() for item in forwarded_for.split(',') if item.strip()] + if forwarded_chain: + if len(forwarded_chain) > AppConfig.app_trusted_proxy_hops: + return forwarded_chain[-(AppConfig.app_trusted_proxy_hops + 1)] + return forwarded_chain[0] + + real_ip = request.headers.get('X-Real-IP', '').strip() + if real_ip: + return real_ip + + return remote_addr + + @classmethod + def _should_trust_proxy_headers(cls, remote_addr: str) -> bool: + """ + 判断当前连接来源是否属于可信代理 + + :param remote_addr: 与应用直接建立连接的来源IP + :return: 是否信任代理头 + """ + trusted_proxy_ips = cls._get_trusted_proxy_ips() + return '*' in trusted_proxy_ips or remote_addr in trusted_proxy_ips + + @classmethod + def _get_trusted_proxy_ips(cls) -> set[str]: + """ + 获取可信代理IP集合 + + :return: 可信代理IP集合 + """ + return {item.strip() for item in AppConfig.app_trusted_proxy_ips.split(',') if item.strip()} diff --git a/shierfeng-fastapi-backend/utils/common_util.py b/shierfeng-fastapi-backend/utils/common_util.py new file mode 100644 index 0000000..eb6ff35 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/common_util.py @@ -0,0 +1,403 @@ +import io +import os +import re +from collections.abc import Generator, Sequence +from typing import Any, Literal, overload + +import pandas as pd +from openpyxl import Workbook +from openpyxl.styles import Alignment, PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.datavalidation import DataValidation +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.collections import InstrumentedList +from sqlalchemy.sql.expression import TextClause, null + +from config.database import Base +from config.env import CachePathConfig +from utils.log_util import logger + + +def worship() -> None: + logger.info(r""" +//////////////////////////////////////////////////////////////////// +// _ooOoo_ // +// o8888888o // +// 88" . "88 // +// (| ^_^ |) // +// O\ = /O // +// ____/`---'\____ // +// .' \\| |// `. // +// / \\||| : |||// \ // +// / _||||| -:- |||||- \ // +// | | \\\ - /// | | // +// | \_| ''\---/'' | | // +// \ .-\__ `-` ___/-. / // +// ___`. .' /--.--\ `. . ___ // +// ."" '< `.___\_<|>_/___.' >'"". // +// | | : `- \`.;`\ _ /`;.`/ - ` : | | // +// \ \ `-. \_ __\ /__ _/ .-` / / // +// ========`-.____`-.___\_____/___.-`____.-'======== // +// `=---=' // +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // +// 佛祖保佑 永不宕机 永无BUG // +//////////////////////////////////////////////////////////////////// + """) + + +class SqlalchemyUtil: + """ + sqlalchemy工具类 + """ + + @classmethod + def base_to_dict( + cls, obj: Base | dict, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> dict: + """ + 将sqlalchemy模型对象转换为字典 + + :param obj: sqlalchemy模型对象或普通字典 + :param transform_case: 转换得到的结果形式,可选的有'no_case'(不转换)、'snake_to_camel'(下划线转小驼峰)、'camel_to_snake'(小驼峰转下划线),默认为'no_case' + :return: 字典结果 + """ + if isinstance(obj, Base): + base_dict = obj.__dict__.copy() + base_dict.pop('_sa_instance_state', None) + for name, value in base_dict.items(): + if isinstance(value, InstrumentedList): + base_dict[name] = cls.serialize_result(value, 'snake_to_camel') + elif isinstance(obj, dict): + base_dict = obj.copy() + if transform_case == 'snake_to_camel': + return {CamelCaseUtil.snake_to_camel(k): v for k, v in base_dict.items()} + if transform_case == 'camel_to_snake': + return {SnakeCaseUtil.camel_to_snake(k): v for k, v in base_dict.items()} + + return base_dict + + @classmethod + @overload + def serialize_result( + cls, result: Base, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> dict[str, Any]: ... + + @classmethod + @overload + def serialize_result( + cls, result: dict, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> dict[Any, Any]: ... + + @classmethod + @overload + def serialize_result( + cls, result: Row, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> dict[str, Any] | list[dict[Any, Any]]: ... + + @classmethod + @overload + def serialize_result( + cls, result: Sequence[Base], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> list[dict[str, Any]]: ... + + @classmethod + @overload + def serialize_result( + cls, result: Sequence[dict], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> list[dict[Any, Any]]: ... + + @classmethod + @overload + def serialize_result( + cls, result: Sequence[Row], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> list[dict[str, Any] | list[dict[Any, Any]]]: ... + + @classmethod + @overload + def serialize_result( + cls, result: Sequence[Any], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> list[Any]: ... + + @classmethod + @overload + def serialize_result( + cls, result: Any, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> Any: ... + + @classmethod + def serialize_result( + cls, result: Any, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case' + ) -> Any: + """ + 将sqlalchemy查询结果序列化 + + :param result: sqlalchemy查询结果 + :param transform_case: 转换得到的结果形式,可选的有'no_case'(不转换)、'snake_to_camel'(下划线转小驼峰)、'camel_to_snake'(小驼峰转下划线),默认为'no_case' + :return: 序列化结果 + """ + if isinstance(result, (Base, dict)): + return cls.base_to_dict(result, transform_case) + if isinstance(result, list): + return [cls.serialize_result(row, transform_case) for row in result] + if isinstance(result, Row): + if all(isinstance(row, Base) for row in result): + return [cls.base_to_dict(row, transform_case) for row in result] + if any(isinstance(row, Base) for row in result): + return [cls.serialize_result(row, transform_case) for row in result] + result_dict = result._asdict() + if transform_case == 'snake_to_camel': + return {CamelCaseUtil.snake_to_camel(k): v for k, v in result_dict.items()} + if transform_case == 'camel_to_snake': + return {SnakeCaseUtil.camel_to_snake(k): v for k, v in result_dict.items()} + return result_dict + return result + + @classmethod + def get_server_default_null(cls, dialect_name: str, need_explicit_null: bool = True) -> TextClause | None: + """ + 根据数据库方言动态返回值为null的server_default + + :param dialect_name: 数据库方言名称 + :param need_explicit_null: 是否需要显式DEFAULT NULL + :return: 不同数据库方言对应的null_server_default + """ + if need_explicit_null and dialect_name == 'postgresql': + return null() + return None + + +class CamelCaseUtil: + """ + 下划线形式(snake_case)转小驼峰形式(camelCase)工具方法 + """ + + @classmethod + def snake_to_camel(cls, snake_str: str) -> str: + """ + 下划线形式字符串(snake_case)转换为小驼峰形式字符串(camelCase) + + :param snake_str: 下划线形式字符串 + :return: 小驼峰形式字符串 + """ + # 分割字符串 + words = snake_str.split('_') + # 小驼峰命名,第一个词首字母小写,其余词首字母大写 + return words[0] + ''.join(word.capitalize() for word in words[1:]) + + @classmethod + @overload + def transform_result(cls, result: Base) -> dict[str, Any]: ... + + @classmethod + @overload + def transform_result(cls, result: dict) -> dict[Any, Any]: ... + + @classmethod + @overload + def transform_result(cls, result: Row) -> dict[str, Any] | list[dict[Any, Any]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[Base]) -> list[dict[str, Any]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[dict]) -> list[dict[Any, Any]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[Row]) -> list[dict[str, Any] | list[dict[Any, Any]]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[Any]) -> list[Any]: ... + + @classmethod + @overload + def transform_result(cls, result: Any) -> Any: ... + + @classmethod + def transform_result(cls, result: Any) -> Any: + """ + 针对不同类型将下划线形式(snake_case)批量转换为小驼峰形式(camelCase)方法 + + :param result: 输入数据 + :return: 小驼峰形式结果 + """ + return SqlalchemyUtil.serialize_result(result=result, transform_case='snake_to_camel') + + +class SnakeCaseUtil: + """ + 小驼峰形式(camelCase)转下划线形式(snake_case)工具方法 + """ + + @classmethod + def camel_to_snake(cls, camel_str: str) -> str: + """ + 小驼峰形式字符串(camelCase)转换为下划线形式字符串(snake_case) + + :param camel_str: 小驼峰形式字符串 + :return: 下划线形式字符串 + """ + # 在大写字母前添加一个下划线,然后将整个字符串转为小写 + words = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', words).lower() + + @classmethod + @overload + def transform_result(cls, result: Base) -> dict[str, Any]: ... + + @classmethod + @overload + def transform_result(cls, result: dict) -> dict[Any, Any]: ... + + @classmethod + @overload + def transform_result(cls, result: Row) -> dict[str, Any] | list[dict[Any, Any]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[Base]) -> list[dict[str, Any]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[dict]) -> list[dict[Any, Any]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[Row]) -> list[dict[str, Any] | list[dict[Any, Any]]]: ... + + @classmethod + @overload + def transform_result(cls, result: Sequence[Any]) -> list[Any]: ... + + @classmethod + @overload + def transform_result(cls, result: Any) -> Any: ... + + @classmethod + def transform_result(cls, result: Any) -> Any: + """ + 针对不同类型将小驼峰形式(camelCase)批量转换为下划线形式(snake_case)方法 + + :param result: 输入数据 + :return: 下划线形式结果 + """ + return SqlalchemyUtil.serialize_result(result=result, transform_case='camel_to_snake') + + +def bytes2human(n: int, format_str: str = '%(value).1f%(symbol)s') -> str: + """Used by various scripts. See: + http://goo.gl/zeJZl + + >>> bytes2human(10000) + '9.8K' + >>> bytes2human(100001221) + '95.4M' + """ + symbols = ('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB') + prefix = {} + for i, s in enumerate(symbols[1:]): + prefix[s] = 1 << (i + 1) * 10 + for symbol in reversed(symbols[1:]): + if n >= prefix[symbol]: + value = float(n) / prefix[symbol] + return format_str % locals() + return format_str % {'symbol': symbols[0], 'value': n} + + +def bytes2file_response(bytes_info: bytes) -> Generator[bytes]: + yield bytes_info + + +def export_list2excel(list_data: list) -> bytes: + """ + 工具方法:将需要导出的list数据转化为对应excel的二进制数据 + + :param list_data: 数据列表 + :return: 字典信息对应excel的二进制数据 + """ + df = pd.DataFrame(list_data) + binary_data = io.BytesIO() + df.to_excel(binary_data, index=False, engine='openpyxl') + binary_data = binary_data.getvalue() + + return binary_data + + +def get_excel_template(header_list: list, selector_header_list: list, option_list: list[dict]) -> bytes: + """ + 工具方法:将需要导出的list数据转化为对应excel的二进制数据 + + :param header_list: 表头数据列表 + :param selector_header_list: 需要设置为选择器格式的表头数据列表 + :param option_list: 选择器格式的表头预设的选项列表 + :return: 模板excel的二进制数据 + """ + # 创建Excel工作簿 + wb = Workbook() + # 选择默认的活动工作表 + ws = wb.active + + # 设置表头文字 + headers = header_list + + # 设置表头背景样式为灰色,前景色为白色 + header_fill = PatternFill(start_color='ababab', end_color='ababab', fill_type='solid') + + # 将表头写入第一行 + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num) + cell.value = header + cell.fill = header_fill + # 设置列宽度为16 + ws.column_dimensions[chr(64 + col_num)].width = 12 + # 设置水平居中对齐 + cell.alignment = Alignment(horizontal='center') + + # 设置选择器的预设选项 + options = option_list + + # 获取selector_header的字母索引 + for selector_header in selector_header_list: + column_selector_header_index = headers.index(selector_header) + 1 + + # 创建数据有效性规则 + header_option = [] + for option in options: + if option.get(selector_header): + header_option = option.get(selector_header) + dv = DataValidation(type='list', formula1=f'"{",".join(header_option)}"') + # 设置数据有效性规则的起始单元格和结束单元格 + dv.add( + f'{get_column_letter(column_selector_header_index)}2:{get_column_letter(column_selector_header_index)}1048576' + ) + # 添加数据有效性规则到工作表 + ws.add_data_validation(dv) + + # 保存Excel文件为字节类型的数据 + file = io.BytesIO() + wb.save(file) + file.seek(0) + + # 读取字节数据 + excel_data = file.getvalue() + + return excel_data + + +def get_filepath_from_url(url: str) -> str: + """ + 工具方法:根据请求参数获取文件路径 + + :param url: 请求参数中的url参数 + :return: 文件路径 + """ + file_info = url.split('?')[1].split('&') + task_id = file_info[0].split('=')[1] + file_name = file_info[1].split('=')[1] + task_path = file_info[2].split('=')[1] + filepath = os.path.join(CachePathConfig.PATH, task_path, task_id, file_name) + + return filepath diff --git a/shierfeng-fastapi-backend/utils/cron_util.py b/shierfeng-fastapi-backend/utils/cron_util.py new file mode 100644 index 0000000..045f4ea --- /dev/null +++ b/shierfeng-fastapi-backend/utils/cron_util.py @@ -0,0 +1,166 @@ +import re +from datetime import datetime + + +class CronUtil: + """ + Cron表达式工具类 + """ + + ONE_YEAR_LENGTH = 4 + MAX_YEAR = 2099 + CRON_EXPRESSION_LENGTH_MIN = 6 + CRON_EXPRESSION_LENGTH_MAX = 7 + + @classmethod + def __valid_range(cls, search_str: str, start_range: int, end_range: int) -> bool: + match = re.match(r'^(\d+)-(\d+)$', search_str) + if match: + start, end = int(match.group(1)), int(match.group(2)) + return start_range <= start < end <= end_range + return False + + @classmethod + def __valid_sum( + cls, search_str: str, start_range_a: int, start_range_b: int, end_range_a: int, end_range_b: int, sum_range: int + ) -> bool: + match = re.match(r'^(\d+)/(\d+)$', search_str) + if match: + start, end = int(match.group(1)), int(match.group(2)) + return ( + start_range_a <= start <= start_range_b + and end_range_a <= end <= end_range_b + and start + end <= sum_range + ) + return False + + @classmethod + def validate_second_or_minute(cls, second_or_minute: str) -> bool: + """ + 校验秒或分钟值是否正确 + + :param second_or_minute: 秒或分钟值 + :return: 校验结果 + """ + return bool( + second_or_minute == '*' + or ('-' in second_or_minute and cls.__valid_range(second_or_minute, 0, 59)) + or ('/' in second_or_minute and cls.__valid_sum(second_or_minute, 0, 58, 1, 59, 59)) + or re.match(r'^(?:[0-5]?\d|59)(?:,[0-5]?\d|59)*$', second_or_minute) + ) + + @classmethod + def validate_hour(cls, hour: str) -> bool: + """ + 校验小时值是否正确 + + :param hour: 小时值 + :return: 校验结果 + """ + return bool( + hour == '*' + or ('-' in hour and cls.__valid_range(hour, 0, 23)) + or ('/' in hour and cls.__valid_sum(hour, 0, 22, 1, 23, 23)) + or re.match(r'^(?:0|[1-9]|1\d|2[0-3])(?:,(?:0|[1-9]|1\d|2[0-3]))*$', hour) + ) + + @classmethod + def validate_day(cls, day: str) -> bool: + """ + 校验日值是否正确 + + :param day: 日值 + :return: 校验结果 + """ + return bool( + day in ['*', '?', 'L'] + or ('-' in day and cls.__valid_range(day, 1, 31)) + or ('/' in day and cls.__valid_sum(day, 1, 30, 1, 30, 31)) + or ('W' in day and re.match(r'^(?:[1-9]|1\d|2\d|3[01])W$', day)) + or re.match(r'^(?:0|[1-9]|1\d|2[0-9]|3[0-1])(?:,(?:0|[1-9]|1\d|2[0-9]|3[0-1]))*$', day) + ) + + @classmethod + def validate_month(cls, month: str) -> bool: + """ + 校验月值是否正确 + + :param month: 月值 + :return: 校验结果 + """ + return bool( + month == '*' + or ('-' in month and cls.__valid_range(month, 1, 12)) + or ('/' in month and cls.__valid_sum(month, 1, 11, 1, 11, 12)) + or re.match(r'^(?:0|[1-9]|1[0-2])(?:,(?:0|[1-9]|1[0-2]))*$', month) + ) + + @classmethod + def validate_week(cls, week: str) -> bool: + """ + 校验周值是否正确 + + :param week: 周值 + :return: 校验结果 + """ + return bool( + week in ['*', '?'] + or ('-' in week and cls.__valid_range(week, 1, 7)) + or ('#' in week and re.match(r'^[1-7]#[1-4]$', week)) + or ('L' in week and re.match(r'^[1-7]L$', week)) + or re.match(r'^[1-7](?:(,[1-7]))*$', week) + ) + + @classmethod + def validate_year(cls, year: str) -> bool: + """ + 校验年值是否正确 + + :param year: 年值 + :return: 校验结果 + """ + current_year = int(datetime.now().year) + future_years = [current_year + i for i in range(9)] + return bool( + year == '*' + or ('-' in year and cls.__valid_range(year, current_year, 2099)) + or ('/' in year and cls.__valid_sum(year, current_year, 2098, 1, 2099 - current_year, 2099)) + or ('#' in year and re.match(r'^[1-7]#[1-4]$', year)) + or ('L' in year and re.match(r'^[1-7]L$', year)) + or ( + (len(year) == cls.ONE_YEAR_LENGTH or ',' in year) + and all( + int(item) in future_years and current_year <= int(item) <= cls.MAX_YEAR for item in year.split(',') + ) + ) + ) + + @classmethod + def validate_cron_expression(cls, cron_expression: str) -> bool: + """ + 校验Cron表达式是否正确 + + :param cron_expression: Cron表达式 + :return: 校验结果 + """ + values = cron_expression.split() + if len(values) != cls.CRON_EXPRESSION_LENGTH_MIN and len(values) != cls.CRON_EXPRESSION_LENGTH_MAX: + return False + second_validation = cls.validate_second_or_minute(values[0]) + minute_validation = cls.validate_second_or_minute(values[1]) + hour_validation = cls.validate_hour(values[2]) + day_validation = cls.validate_day(values[3]) + month_validation = cls.validate_month(values[4]) + week_validation = cls.validate_week(values[5]) + validation = ( + second_validation + and minute_validation + and hour_validation + and day_validation + and month_validation + and week_validation + ) + if len(values) == cls.CRON_EXPRESSION_LENGTH_MIN: + return validation + year_validation = cls.validate_year(values[6]) + return validation and year_validation diff --git a/shierfeng-fastapi-backend/utils/crypto_util.py b/shierfeng-fastapi-backend/utils/crypto_util.py new file mode 100644 index 0000000..8dc1229 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/crypto_util.py @@ -0,0 +1,58 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet + +from config.env import JwtConfig +from exceptions.exception import ServiceException + + +class CryptoUtil: + """ + 加解密工具类 + """ + + _cipher_suite = None + + @classmethod + def _get_cipher_suite(cls) -> Fernet: + if cls._cipher_suite is None: + # 使用 JWT secret key 派生 Fernet key + # SHA256 hash (32 bytes) -> Base64 encoded (44 bytes) -> Fernet key + key = base64.urlsafe_b64encode(hashlib.sha256(JwtConfig.jwt_secret_key.encode()).digest()) + cls._cipher_suite = Fernet(key) + return cls._cipher_suite + + @classmethod + def encrypt(cls, data: str) -> str: + """ + 加密字符串 + + :param data: 明文 + :return: 密文 + """ + if not data: + return data + try: + cipher_suite = cls._get_cipher_suite() + encrypted_bytes = cipher_suite.encrypt(data.encode('utf-8')) + return encrypted_bytes.decode('utf-8') + except Exception as e: + raise ServiceException('加密失败') from e + + @classmethod + def decrypt(cls, token: str) -> str: + """ + 解密字符串 + + :param token: 密文 + :return: 明文 + """ + if not token: + return token + try: + cipher_suite = cls._get_cipher_suite() + decrypted_bytes = cipher_suite.decrypt(token.encode('utf-8')) + return decrypted_bytes.decode('utf-8') + except Exception as e: + raise ServiceException('解密失败') from e diff --git a/shierfeng-fastapi-backend/utils/dependency_util.py b/shierfeng-fastapi-backend/utils/dependency_util.py new file mode 100644 index 0000000..23ace9d --- /dev/null +++ b/shierfeng-fastapi-backend/utils/dependency_util.py @@ -0,0 +1,49 @@ +from fastapi import Request + +from common.context import RequestContext +from config.env import AppConfig +from exceptions.exception import PermissionException + + +class DependencyUtil: + """ + 依赖项工具类 + """ + + @classmethod + def check_exclude_routes(cls, request: Request, err_msg: str = '当前路由不在认证规则内,不可使用该依赖项') -> None: + """ + 检查路径和方法是否匹配排除路由模式 + + :param request: 请求对象 + :param err_msg: 错误信息 + :return: None + """ + # 获取当前请求路径和方法 + path = request.url.path + method = request.method.upper() + + # 从配置中获取APP_ROOT_PATH + app_root_path = AppConfig.app_root_path + + # 去掉APP_ROOT_PATH前缀 + if app_root_path and path.startswith(app_root_path): + path = path[len(app_root_path) :] + + # 获取编译后的排除路由模式列表 + exclude_patterns = RequestContext.get_current_exclude_patterns() + + # 检查当前路由是否在排除路由列表中 + if path and method and exclude_patterns: + for item in exclude_patterns: + pattern = item['pattern'] + exclude_methods = item['methods'] + ignore_paths = item['ignore_paths'] + + # 检查当前路径是否在忽略列表中 + if path in ignore_paths: + continue + + # 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中 + if pattern.match(path) and (not exclude_methods or method in exclude_methods): + raise PermissionException(data='', message=err_msg) diff --git a/shierfeng-fastapi-backend/utils/excel_util.py b/shierfeng-fastapi-backend/utils/excel_util.py new file mode 100644 index 0000000..07168d1 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/excel_util.py @@ -0,0 +1,104 @@ +import io + +import pandas as pd +from openpyxl import Workbook +from openpyxl.styles import Alignment, PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.datavalidation import DataValidation + + +class ExcelUtil: + """ + Excel操作类 + """ + + @classmethod + def __mapping_list(cls, list_data: list, mapping_dict: dict) -> list[dict]: + """ + 工具方法:将list数据中的字段名映射为对应的中文字段名 + + :param list_data: 数据列表 + :param mapping_dict: 映射字典 + :return: 映射后的数据列表 + """ + mapping_data = [{mapping_dict.get(key): item.get(key) for key in mapping_dict} for item in list_data] + + return mapping_data + + @classmethod + def export_list2excel(cls, list_data: list, mapping_dict: dict) -> bytes: + """ + 工具方法:将需要导出的list数据转化为对应excel的二进制数据 + + :param list_data: 数据列表 + :param mapping_dict: 映射字典 + :return: list数据对应excel的二进制数据 + """ + mapping_data = cls.__mapping_list(list_data, mapping_dict) + df = pd.DataFrame(mapping_data) + binary_data = io.BytesIO() + df.to_excel(binary_data, index=False, engine='openpyxl') + binary_data = binary_data.getvalue() + + return binary_data + + @classmethod + def get_excel_template(cls, header_list: list, selector_header_list: list, option_list: list[dict]) -> bytes: + """ + 工具方法:将需要导出的list数据转化为对应excel的二进制数据 + + :param header_list: 表头数据列表 + :param selector_header_list: 需要设置为选择器格式的表头数据列表 + :param option_list: 选择器格式的表头预设的选项列表 + :return: 模板excel的二进制数据 + """ + # 创建Excel工作簿 + wb = Workbook() + # 选择默认的活动工作表 + ws = wb.active + + # 设置表头文字 + headers = header_list + + # 设置表头背景样式为灰色,前景色为白色 + header_fill = PatternFill(start_color='ababab', end_color='ababab', fill_type='solid') + + # 将表头写入第一行 + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num) + cell.value = header + cell.fill = header_fill + # 设置列宽度为16 + ws.column_dimensions[chr(64 + col_num)].width = 12 + # 设置水平居中对齐 + cell.alignment = Alignment(horizontal='center') + + # 设置选择器的预设选项 + options = option_list + + # 获取selector_header的字母索引 + for selector_header in selector_header_list: + column_selector_header_index = headers.index(selector_header) + 1 + + # 创建数据有效性规则 + header_option = [] + for option in options: + if option.get(selector_header): + header_option = option.get(selector_header) + dv = DataValidation(type='list', formula1=f'"{",".join(header_option)}"') + # 设置数据有效性规则的起始单元格和结束单元格 + dv.add( + f'{get_column_letter(column_selector_header_index)}2:{get_column_letter(column_selector_header_index)}1048576' + ) + # 添加数据有效性规则到工作表 + ws.add_data_validation(dv) + + # 保存Excel文件为字节类型的数据 + file = io.BytesIO() + wb.save(file) + file.seek(0) + + # 读取字节数据 + excel_data = file.getvalue() + + return excel_data diff --git a/shierfeng-fastapi-backend/utils/gen_util.py b/shierfeng-fastapi-backend/utils/gen_util.py new file mode 100644 index 0000000..487137d --- /dev/null +++ b/shierfeng-fastapi-backend/utils/gen_util.py @@ -0,0 +1,226 @@ +import re +from datetime import datetime + +from common.constant import GenConstant +from config.env import GenConfig +from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel +from utils.string_util import StringUtil + + +class GenUtils: + """代码生成器工具类""" + + TEXTAREA_COLUMN_LENGTH = 500 + + @classmethod + def init_table(cls, gen_table: GenTableModel, oper_name: str) -> None: + """ + 初始化表信息 + + param gen_table: 业务表对象 + param oper_name: 操作人 + :return: + """ + gen_table.class_name = cls.convert_class_name(gen_table.table_name) + gen_table.package_name = GenConfig.package_name + gen_table.module_name = cls.get_module_name(GenConfig.package_name) + gen_table.business_name = cls.get_business_name(gen_table.table_name) + gen_table.function_name = cls.replace_text(gen_table.table_comment) + gen_table.function_author = GenConfig.author + gen_table.create_by = oper_name + gen_table.create_time = datetime.now() + gen_table.update_by = oper_name + gen_table.update_time = datetime.now() + + @classmethod + def init_column_field(cls, column: GenTableColumnModel, table: GenTableModel) -> None: + """ + 初始化列属性字段 + + param column: 业务表字段对象 + param table: 业务表对象 + :return: + """ + data_type = cls.get_db_type(column.column_type) + column_name = column.column_name + column.table_id = table.table_id + column.create_by = table.create_by + # 设置Python字段名 + column.python_field = cls.to_camel_case(column_name) + # 设置默认类型 + column.python_type = StringUtil.get_mapping_value_by_key_ignore_case( + GenConstant.DB_TO_PYTHON_TYPE_MAPPING, data_type + ) + column.query_type = GenConstant.QUERY_EQ + + if cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains( + GenConstant.COLUMNTYPE_TEXT, data_type + ): + # 字符串长度超过500设置为文本域 + column_length = cls.get_column_length(column.column_type) + html_type = ( + GenConstant.HTML_TEXTAREA + if column_length >= cls.TEXTAREA_COLUMN_LENGTH + or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type) + else GenConstant.HTML_INPUT + ) + column.html_type = html_type + elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type): + column.html_type = GenConstant.HTML_DATETIME + elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type): + column.html_type = GenConstant.HTML_INPUT + + # 插入字段(默认所有字段都需要插入) + column.is_insert = GenConstant.REQUIRE + + # 编辑字段 + if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_EDIT, column_name) and not column.pk: + column.is_edit = GenConstant.REQUIRE + # 列表字段 + if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_LIST, column_name) and not column.pk: + column.is_list = GenConstant.REQUIRE + # 查询字段 + if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_QUERY, column_name) and not column.pk: + column.is_query = GenConstant.REQUIRE + + # 查询字段类型 + if column_name.lower().endswith('name'): + column.query_type = GenConstant.QUERY_LIKE + # 状态字段设置单选框 + if column_name.lower().endswith('status'): + column.html_type = GenConstant.HTML_RADIO + # 类型&性别字段设置下拉框 + elif column_name.lower().endswith('type') or column_name.lower().endswith('sex'): + column.html_type = GenConstant.HTML_SELECT + # 图片字段设置图片上传控件 + elif column_name.lower().endswith('image'): + column.html_type = GenConstant.HTML_IMAGE_UPLOAD + # 文件字段设置文件上传控件 + elif column_name.lower().endswith('file'): + column.html_type = GenConstant.HTML_FILE_UPLOAD + # 内容字段设置富文本控件 + elif column_name.lower().endswith('content'): + column.html_type = GenConstant.HTML_EDITOR + + column.create_by = table.create_by + column.create_time = datetime.now() + column.update_by = table.update_by + column.update_time = datetime.now() + + @classmethod + def arrays_contains(cls, arr: list[str], target_value: str) -> bool: + """ + 校验数组是否包含指定值 + + param arr: 数组 + param target_value: 需要校验的值 + :return: 校验结果 + """ + return target_value in arr + + @classmethod + def get_module_name(cls, package_name: str) -> str: + """ + 获取模块名 + + param package_name: 包名 + :return: 模块名 + """ + return package_name.rsplit('.', maxsplit=1)[-1] + + @classmethod + def get_business_name(cls, table_name: str) -> str: + """ + 获取业务名 + + param table_name: 业务表名 + :return: 业务名 + """ + return table_name.rsplit('_', maxsplit=1)[-1] + + @classmethod + def convert_class_name(cls, table_name: str) -> str: + """ + 表名转换成Python类名 + + param table_name: 业务表名 + :return: Python类名 + """ + auto_remove_pre = GenConfig.auto_remove_pre + table_prefix = GenConfig.table_prefix + if auto_remove_pre and table_prefix: + search_list = table_prefix.split(',') + table_name = cls.replace_first(table_name, search_list) + return StringUtil.convert_to_camel_case(table_name) + + @classmethod + def replace_first(cls, replacement: str, search_list: list[str]) -> str: + """ + 批量替换前缀 + + param replacement: 需要被替换的字符串 + param search_list: 可替换的字符串列表 + :return: 替换后的字符串 + """ + for search_string in search_list: + if replacement.startswith(search_string): + return replacement.replace(search_string, '', 1) + return replacement + + @classmethod + def replace_text(cls, text: str) -> str: + """ + 关键字替换 + + param text: 需要被替换的字符串 + :return: 替换后的字符串 + """ + return re.sub(r'(?:表|施尔丰)', '', text) + + @classmethod + def get_db_type(cls, column_type: str) -> str: + """ + 获取数据库类型字段 + + param column_type: 字段类型 + :return: 数据库类型 + """ + if '(' in column_type: + return column_type.split('(', maxsplit=1)[0] + return column_type + + @classmethod + def get_column_length(cls, column_type: str) -> int: + """ + 获取字段长度 + + param column_type: 字段类型 + :return: 字段长度 + """ + if '(' in column_type: + length = len(column_type.split('(')[1].split(')', maxsplit=1)[0]) + return length + return 0 + + @classmethod + def split_column_type(cls, column_type: str) -> list[str]: + """ + 拆分列类型 + + param column_type: 字段类型 + :return: 拆分结果 + """ + if '(' in column_type and ')' in column_type: + return column_type.split('(')[1].split(')', maxsplit=1)[0].split(',') + return [] + + @classmethod + def to_camel_case(cls, text: str) -> str: + """ + 将字符串转换为驼峰命名 + + param text: 需要转换的字符串 + :return: 驼峰命名 + """ + parts = text.split('_') + return parts[0] + ''.join(word.capitalize() for word in parts[1:]) diff --git a/shierfeng-fastapi-backend/utils/import_util.py b/shierfeng-fastapi-backend/utils/import_util.py new file mode 100644 index 0000000..c349e9c --- /dev/null +++ b/shierfeng-fastapi-backend/utils/import_util.py @@ -0,0 +1,123 @@ +import importlib +import inspect +import os +import sys +from functools import lru_cache +from pathlib import Path +from typing import Any + +from sqlalchemy import inspect as sa_inspect + +from config.database import Base + + +class ImportUtil: + @classmethod + def find_project_root(cls) -> Path: + """ + 查找项目根目录 + + :return: 项目根目录路径 + """ + current_dir = Path(__file__).resolve().parent + while current_dir != current_dir.parent: + if any(current_dir.joinpath(file).exists() for file in ['setup.py', 'pyproject.toml', 'requirements.txt']): + return current_dir + current_dir = current_dir.parent + return Path(__file__).resolve().parent + + @classmethod + def is_valid_model(cls, obj: Any, base_class: Base) -> bool: + """ + 验证是否为有效的SQLAlchemy模型类 + + :param obj: 待验证的对象 + :param base_class: SQLAlchemy的Base类 + :return: 验证结果 + """ + # 必须继承自Base且不是Base本身 + if not (inspect.isclass(obj) and issubclass(obj, base_class) and obj is not base_class): + return False + + # 必须有表名定义(排除抽象基类) + if not hasattr(obj, '__tablename__') or obj.__tablename__ is None: + return False + + # 必须有至少一个列定义 + try: + return len(sa_inspect(obj).columns) > 0 + except Exception: + return False + + @classmethod + @lru_cache(maxsize=256) + def find_models(cls, base_class: Base) -> list[Base]: + """ + 查找并过滤有效的模型类,避免重复和无效定义 + + :param base_class: SQLAlchemy的Base类,用于验证模型类 + :return: 有效模型类列表 + """ + models = [] + # 按类对象去重 + seen_models = set() + # 按表名去重(防止同表名冲突) + seen_tables = set() + project_root = cls.find_project_root() + + sys.path.append(str(project_root)) + print(f'⏰️ 开始在项目根目录 {project_root} 中查找模型...') + + # 排除目录扩展 + exclude_dirs = { + 'venv', + '.env', + '.git', + '__pycache__', + 'migrations', + 'alembic', + 'tests', + 'test', + 'docs', + 'examples', + 'scripts', + } + + for root, dirs, files in os.walk(project_root): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + for file in files: + if file.endswith('.py') and not file.startswith('__'): + relative_path = Path(root).relative_to(project_root) + module_parts = [*list(relative_path.parts), file[:-3]] + module_name = '.'.join(module_parts) + + try: + module = importlib.import_module(module_name) + + for _name, obj in inspect.getmembers(module, inspect.isclass): + # 验证模型有效性 + if not cls.is_valid_model(obj, base_class): + continue + + # 检查类对象重复 + if obj in seen_models: + continue + + # 检查表名重复 + table_name = obj.__tablename__ + if table_name in seen_tables: + continue + + seen_models.add(obj) + seen_tables.add(table_name) + models.append(obj) + print(f'✅️ 找到有效模型: {obj.__module__}.{obj.__name__} (表: {table_name})') + + except ImportError as e: + if 'cannot import name' not in str(e): + print(f'❗️ 警告: 无法导入模块 {module_name}: {e}') + except Exception as e: + print(f'❌️ 处理模块 {module_name} 时出错: {e}') + + return models diff --git a/shierfeng-fastapi-backend/utils/log_util.py b/shierfeng-fastapi-backend/utils/log_util.py new file mode 100644 index 0000000..299f2d9 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/log_util.py @@ -0,0 +1,675 @@ +import ast +import json +import logging +import os +import re +import sys +import traceback +from collections.abc import Mapping, Sequence +from typing import Any + +from loguru import logger as _logger +from loguru._logger import Logger + +from config.env import AppConfig, LogConfig +from middlewares.trace_middleware import TraceCtx +from utils.server_util import WorkerIdUtil + + +def _split_field_tokens(field_name: str) -> tuple[str, ...]: + """ + 拆分字段名为文本匹配用的 token + + :param field_name: 原始字段名 + :return: token 元组 + """ + separated_name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', field_name) + normalized_name = re.sub(r'[^A-Za-z0-9]+', ' ', separated_name) + return tuple(token.lower() for token in normalized_name.split() if token) + + +def _build_text_key_pattern(field_name: str) -> str: + """ + 构建文本日志中的字段名匹配正则,自动兼容 snake_case / camelCase / kebab-case / dot.case + + :param field_name: 原始字段名 + :return: 正则片段 + """ + tokens = _split_field_tokens(field_name) + if not tokens: + return re.escape(field_name) + return r'[^\S\r\n._-]*[._-]*[^\S\r\n._-]*'.join(re.escape(token) for token in tokens) + + +def _build_text_assignment_patterns(key_pattern: str) -> list[re.Pattern[str]]: + """ + 构建文本日志中的键值对匹配正则 + + :param key_pattern: 字段名正则片段 + :return: 正则列表 + """ + if not key_pattern: + return [] + return [ + re.compile( + rf'(?P(?P[\'"]?)(?P(?:{key_pattern}))(?P=key_quote)\s*[:=]\s*)' + r'(?P[\'"])(?P.*?)(?P=quote)', + re.IGNORECASE, + ), + re.compile( + rf'(?P(?P[\'"]?)(?P(?:{key_pattern}))(?P=key_quote)\s*[:=]\s*)' + r'(?P[\'"]?)(?P[^\'"\s,;]+)(?P=quote)', + re.IGNORECASE, + ), + ] + + +class LogSanitizer: + """ + 日志脱敏工具 + """ + + _MASK = LogConfig.log_mask_placeholder + _PHONE_MIN_LENGTH = 7 + _PHONE_PREFIX_LENGTH = 3 + _PHONE_SUFFIX_LENGTH = 4 + _EMAIL_SHORT_LOCAL_LENGTH = 2 + _IPV4_PARTS = 4 + _IPV4_VISIBLE_PARTS = 3 + _IPV6_MASK_THRESHOLD = 2 + _IPV6_VISIBLE_PARTS = 2 + _SENSITIVE_FIELDS = { + re.sub(r'[^a-z0-9]', '', item.lower()) + for item in (field.strip() for field in LogConfig.log_mask_fields.split(',')) + if item + } + _TEXT_SENSITIVE_FIELDS = tuple(field.strip() for field in LogConfig.log_mask_fields.split(',') if field.strip()) + _TEXT_SENSITIVE_KEY_PATTERN = '|'.join( + sorted({_build_text_key_pattern(field_name) for field_name in _TEXT_SENSITIVE_FIELDS}, key=len, reverse=True) + ) + _PARTIAL_MASK_FIELDS = { + re.sub(r'[^a-z0-9]', '', item.lower()) + for item in (field.strip() for field in LogConfig.log_partial_mask_fields.split(',')) + if item + } + _TEXT_PARTIAL_FIELDS = tuple( + field.strip() for field in LogConfig.log_partial_mask_fields.split(',') if field.strip() + ) + _TEXT_PARTIAL_KEY_PATTERN = '|'.join( + sorted({_build_text_key_pattern(field_name) for field_name in _TEXT_PARTIAL_FIELDS}, key=len, reverse=True) + ) + _CONFIG_SECRET_PATTERNS = [ + re.compile(pattern.strip(), re.IGNORECASE) + for pattern in LogConfig.log_config_secret_patterns.split(',') + if pattern.strip() + ] + _KV_PATTERNS = [ + re.compile( + r'(?Pauthorization\s*[:=]\s*)(?P[\'"]?)(?Pbearer\s+[^\s\'",;]+|[^\s\'",;]+)(?P=quote)', + re.IGNORECASE, + ), + *_build_text_assignment_patterns(_TEXT_SENSITIVE_KEY_PATTERN), + re.compile(r'(?PBearer\s+)(?P[A-Za-z0-9\-._~+/]+=*)', re.IGNORECASE), + ] + _PARTIAL_KV_PATTERNS = _build_text_assignment_patterns(_TEXT_PARTIAL_KEY_PATTERN) + _LOGIN_CODE_PATTERN = re.compile(r'^[A-Za-z0-9]{4,8}$') + + @classmethod + def sanitize_data(cls, data: Any, field_name: str | None = None) -> Any: + """ + 对日志数据进行脱敏 + + :param data: 原始日志数据 + :param field_name: 当前字段名 + :return: 脱敏后的数据 + """ + if not LogConfig.log_mask_enabled: + return data + if data is None: + return None + if hasattr(data, 'model_dump'): + return cls.sanitize_data(data.model_dump(by_alias=True, exclude_none=True), field_name) + if isinstance(data, Mapping): + return cls._sanitize_mapping(data) + if isinstance(data, str): + return cls._sanitize_string(data, field_name) + if isinstance(data, bytes): + return f'' + if isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)): + return [cls.sanitize_data(item, field_name) for item in data] + return data + + @classmethod + def sanitize_text(cls, text: str) -> str: + """ + 对普通文本日志进行脱敏 + + :param text: 原始文本 + :return: 脱敏后的文本 + """ + if not LogConfig.log_mask_enabled: + return text + if not isinstance(text, str): + return text + sanitized_text = cls._sanitize_string(text) + return sanitized_text if isinstance(sanitized_text, str) else json.dumps(sanitized_text, ensure_ascii=False) + + @classmethod + def _sanitize_mapping(cls, data: Mapping[Any, Any]) -> dict[Any, Any]: + """ + 对字典类型数据进行脱敏 + + :param data: 字典数据 + :return: 脱敏后的字典 + """ + sanitized: dict[Any, Any] = {} + normalized_map = {cls._normalize_key(str(key)): value for key, value in data.items()} + + for key, value in data.items(): + key_str = str(key) + normalized_key = cls._normalize_key(key_str) + if cls._should_fully_mask_field(normalized_key, value, normalized_map): + sanitized[key] = cls._MASK + elif normalized_key in cls._PARTIAL_MASK_FIELDS and isinstance(value, str): + sanitized[key] = cls._mask_partial_value(value, normalized_key) + else: + sanitized[key] = cls.sanitize_data(value, key_str) + + config_key = normalized_map.get('configkey') + if isinstance(config_key, str) and cls._is_secret_config_key(config_key): + for key in data: + if cls._normalize_key(str(key)) == 'configvalue': + sanitized[key] = cls._MASK + + return sanitized + + @classmethod + def _sanitize_string(cls, value: str, field_name: str | None = None) -> Any: + """ + 对字符串数据进行脱敏 + + :param value: 字符串值 + :param field_name: 当前字段名 + :return: 脱敏后的字符串或结构化数据 + """ + normalized_field = cls._normalize_key(field_name or '') + if normalized_field in cls._SENSITIVE_FIELDS: + return cls._MASK + if normalized_field in cls._PARTIAL_MASK_FIELDS: + return cls._mask_partial_value(value, normalized_field) + + stripped_value = value.strip() + if stripped_value and stripped_value[0] in '{[': + try: + parsed_value = json.loads(value) + except (TypeError, ValueError, json.JSONDecodeError): + try: + parsed_value = ast.literal_eval(value) + except (SyntaxError, ValueError): + pass + else: + return cls._dump_sanitized_structured_text( + cls.sanitize_data(parsed_value, field_name), + original_text=value, + ) + else: + return cls._dump_sanitized_structured_text( + cls.sanitize_data(parsed_value, field_name), + original_text=value, + ) + + sanitized_text = value + for pattern in cls._KV_PATTERNS: + sanitized_text = pattern.sub(cls._replace_text_secret, sanitized_text) + for pattern in cls._PARTIAL_KV_PATTERNS: + sanitized_text = pattern.sub(cls._replace_text_partial_secret, sanitized_text) + + if '验证码' in sanitized_text: + sanitized_text = re.sub(r'(验证码(?:为|是)?\s*)([A-Za-z0-9]{4,8})', rf'\1{cls._MASK}', sanitized_text) + + return sanitized_text + + @classmethod + def _replace_text_secret(cls, match: re.Match[str]) -> str: + """ + 替换文本中的敏感值 + + :param match: 正则匹配对象 + :return: 脱敏后的文本 + """ + prefix = match.group('prefix') + quote = match.groupdict().get('quote', '') + if quote is None: + quote = '' + return f'{prefix}{quote}{cls._MASK}{quote}' + + @classmethod + def _replace_text_partial_secret(cls, match: re.Match[str]) -> str: + """ + 替换文本中的部分脱敏字段值 + + :param match: 正则匹配对象 + :return: 脱敏后的文本 + """ + prefix = match.group('prefix') + quote = match.groupdict().get('quote', '') + if quote is None: + quote = '' + normalized_key = cls._normalize_key(match.groupdict().get('key', '')) + masked_value = cls._mask_partial_value(match.group('value'), normalized_key) + return f'{prefix}{quote}{masked_value}{quote}' + + @staticmethod + def _dump_sanitized_structured_text(sanitized_value: Any, original_text: str) -> Any: + """ + 将脱敏后的结构化数据恢复为文本,并尽量保持原始换行风格 + + :param sanitized_value: 脱敏后的结构化数据 + :param original_text: 原始文本 + :return: 文本或原始值 + """ + if isinstance(sanitized_value, (dict, list)): + indent = 2 if '\n' in original_text or '\r' in original_text else None + return json.dumps(sanitized_value, ensure_ascii=False, indent=indent) + return sanitized_value + + @classmethod + def _should_fully_mask_field( + cls, normalized_key: str, value: Any, full_mapping: Mapping[str, Any] | None = None + ) -> bool: + """ + 判断字段是否需要全量脱敏 + + :param normalized_key: 标准化后的字段名 + :param value: 字段值 + :param full_mapping: 当前层级的完整字段映射 + :return: 是否需要全量脱敏 + """ + if normalized_key in cls._SENSITIVE_FIELDS: + return True + if normalized_key in {'captchacode', 'smscode'}: + return True + if normalized_key == 'code' and isinstance(value, str): + sibling_keys = set((full_mapping or {}).keys()) + if 'uuid' in sibling_keys and cls._LOGIN_CODE_PATTERN.fullmatch(value): + return True + return False + + @classmethod + def _is_secret_config_key(cls, config_key: str) -> bool: + """ + 判断参数键是否属于敏感配置 + + :param config_key: 参数键 + :return: 是否敏感 + """ + return any(pattern.search(config_key) for pattern in cls._CONFIG_SECRET_PATTERNS) + + @classmethod + def _mask_partial_value(cls, value: str, normalized_field: str) -> str: + """ + 对部分字段进行部分脱敏 + + :param value: 原始值 + :param normalized_field: 标准化后的字段名 + :return: 脱敏后的值 + """ + if normalized_field in {'phonenumber', 'phone', 'mobile'}: + return cls._mask_phone(value) + if normalized_field == 'email': + return cls._mask_email(value) + if normalized_field in {'ip', 'ipaddr', 'operip', 'loginip'}: + return cls._mask_ip(value) + return cls._MASK + + @staticmethod + def _normalize_key(field_name: str) -> str: + """ + 标准化字段名 + + :param field_name: 原始字段名 + :return: 标准化字段名 + """ + return re.sub(r'[^a-z0-9]', '', field_name.lower()) + + @classmethod + def _mask_phone(cls, value: str) -> str: + """ + 手机号脱敏 + + :param value: 原始手机号 + :return: 脱敏后的手机号 + """ + digits = re.sub(r'\D', '', value) + if len(digits) < cls._PHONE_MIN_LENGTH: + return cls._MASK + return f'{digits[: cls._PHONE_PREFIX_LENGTH]}****{digits[-cls._PHONE_SUFFIX_LENGTH :]}' + + @classmethod + def _mask_email(cls, value: str) -> str: + """ + 邮箱脱敏 + + :param value: 原始邮箱 + :return: 脱敏后的邮箱 + """ + if '@' not in value: + return cls._MASK + local_part, domain = value.split('@', 1) + masked_local = ( + f'{local_part[:1]}***' + if len(local_part) <= cls._EMAIL_SHORT_LOCAL_LENGTH + else f'{local_part[:1]}***{local_part[-1:]}' + ) + return f'{masked_local}@{domain}' + + @classmethod + def _mask_ip(cls, value: str) -> str: + """ + IP地址脱敏 + + :param value: 原始IP + :return: 脱敏后的IP + """ + if '.' in value: + parts = value.split('.') + if len(parts) == cls._IPV4_PARTS: + return '.'.join([*parts[: cls._IPV4_VISIBLE_PARTS], '*']) + if ':' in value: + parts = value.split(':') + if len(parts) > cls._IPV6_MASK_THRESHOLD: + return ':'.join([*parts[: cls._IPV6_VISIBLE_PARTS], '*', '*']) + return cls._MASK + + +class InterceptHandler(logging.Handler): + target_logger = _logger + + def emit(self, record: logging.LogRecord) -> None: + """ + 拦截标准 logging 记录并转发到 Loguru + + :param record: 原生 logging 日志记录 + :return: None + """ + try: + level = self.target_logger.level(record.levelname).name + except ValueError: + level = record.levelno + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + self.target_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +class LoggerInitializer: + def __init__(self) -> None: + """ + 初始化日志基础配置与运行时标识 + + :return: None + """ + self.worker_id = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id) + self.instance_id = LogConfig.log_instance_id + self.service_name = LogConfig.log_service_name or AppConfig.app_name + self._log_file_enabled = LogConfig.log_file_enabled + self._log_base_dir = LogConfig.log_file_base_dir + self._ensure_log_directory_exists() + + def _ensure_log_directory_exists(self) -> None: + """ + 确保日志目录存在 + + :return: None + """ + if not self._log_file_enabled: + return + if self._log_base_dir and not os.path.exists(self._log_base_dir): + os.makedirs(self._log_base_dir, exist_ok=True) + + def _filter(self, record: dict) -> bool: + """ + 注入 Trace 上下文并控制启动阶段日志输出 + + :param record: Loguru 日志记录字典 + :return: 是否允许输出日志 + """ + record['extra']['trace_id'] = TraceCtx.get_trace_id() + record['extra']['request_id'] = TraceCtx.get_request_id() + record['extra']['span_id'] = TraceCtx.get_span_id() + record['extra']['path'] = TraceCtx.get_request_path() + record['extra']['method'] = TraceCtx.get_request_method() + record['extra']['worker_id'] = self.worker_id + record['extra']['instance_id'] = self.instance_id + record['extra']['service'] = self.service_name + startup_phase = record['extra'].get('startup_phase') + startup_log_enabled = record['extra'].get('startup_log_enabled') + record['extra'] = LogSanitizer.sanitize_data(record['extra']) + if startup_phase: + return bool(startup_log_enabled) + return True + + @staticmethod + def _get_exception_value_text(exception: Any) -> str | None: + """ + 获取脱敏后的异常值文本 + + :param exception: Loguru 异常对象 + :return: 脱敏后的异常值文本 + """ + if not exception or not exception.value: + return None + return LogSanitizer.sanitize_text(str(exception.value)) + + def _get_exception_traceback_text(self, exception: Any) -> str | None: + """ + 获取脱敏后的异常堆栈文本 + + :param exception: Loguru 异常对象 + :return: 脱敏后的异常堆栈文本 + """ + if not exception or not exception.traceback: + return None + if isinstance(exception.traceback, str): + traceback_text = exception.traceback + elif exception.type and exception.value: + traceback_text = ''.join(traceback.format_exception(exception.type, exception.value, exception.traceback)) + else: + traceback_text = str(exception.traceback) + return LogSanitizer.sanitize_text(traceback_text.rstrip()) + + def _build_plain_exception_suffix(self, record: dict) -> str: + """ + 构建普通文本日志使用的异常后缀 + + :param record: Loguru 日志记录字典 + :return: 异常后缀文本 + """ + exception_text = self._get_exception_traceback_text(record.get('exception')) + return f'\n{exception_text}' if exception_text else '' + + def _build_json_payload(self, record: dict) -> dict: + """ + 构建统一的 JSON 日志结构 + + :param record: Loguru 日志记录字典 + :return: JSON 日志结构 + """ + exception = None + if record['exception']: + exception = { + 'type': record['exception'].type.__name__ if record['exception'].type else None, + 'value': self._get_exception_value_text(record['exception']), + 'traceback': self._get_exception_traceback_text(record['exception']), + } + return { + 'timestamp': record['time'].isoformat(), + 'level': record['level'].name, + 'message': record['message'], + 'logger': record['name'], + 'trace_id': record['extra'].get('trace_id'), + 'request_id': record['extra'].get('request_id'), + 'span_id': record['extra'].get('span_id'), + 'worker_id': record['extra'].get('worker_id'), + 'instance_id': record['extra'].get('instance_id'), + 'service': record['extra'].get('service'), + 'method': record['extra'].get('method'), + 'path': record['extra'].get('path'), + 'module': record['module'], + 'function': record['function'], + 'line': record['line'], + 'exception': exception, + 'extra': {key: value for key, value in record['extra'].items() if key != 'json_payload'}, + } + + def _json_log_formatter(self, record: dict) -> str: + """ + 将 Loguru 日志记录格式化为 JSON 文本 + + :param record: Loguru 日志记录字典 + :return: JSON 格式文本 + """ + record['extra']['json_payload'] = json.dumps(self._build_json_payload(record), ensure_ascii=False, default=str) + return '{extra[json_payload]}\n' + + def _plain_log_formatter(self, record: dict) -> str: + """ + 将 Loguru 日志记录格式化为普通文本,并追加脱敏后的异常堆栈 + + :param record: Loguru 日志记录字典 + :return: 普通文本格式模板 + """ + record['extra']['sanitized_exception'] = self._build_plain_exception_suffix(record) + return ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' + '{extra[trace_id]} | ' + '{extra[span_id]} | ' + '{extra[request_id]} | ' + '{extra[worker_id]} | ' + '{level: <8} | ' + '{name}:{function}:{line} - ' + '{message}{extra[sanitized_exception]}\n' + ) + + def _patch_record(self, record: dict) -> dict: + """ + 在日志落地前统一脱敏 + + :param record: Loguru 日志记录字典 + :return: 脱敏后的日志记录字典 + """ + record['message'] = LogSanitizer.sanitize_text(record['message']) + return record + + def _info_file_filter(self, record: dict) -> bool: + """ + 仅输出 INFO 级别日志到 info 文件 + + :param record: Loguru 日志记录字典 + :return: 是否允许输出日志 + """ + return self._filter(record) and record['level'].name == 'INFO' + + def _error_file_filter(self, record: dict) -> bool: + """ + 输出 WARNING 及以上日志到 error 文件 + + :param record: Loguru 日志记录字典 + :return: 是否允许输出日志 + """ + return self._filter(record) and record['level'].no >= logging.WARNING + + def _configure_logging(self) -> None: + """ + 统一接管标准 logging 与第三方日志输出 + + :return: None + """ + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + for logger_name in ('uvicorn', 'uvicorn.error', 'uvicorn.access', 'fastapi'): + logging.getLogger(logger_name).handlers = [InterceptHandler()] + logging.getLogger(logger_name).propagate = False + for logger_name in ('LiteLLM', 'litellm'): + logging.getLogger(logger_name).setLevel(logging.WARNING) + + def init_log(self) -> Logger: + """ + 初始化 Loguru 输出与标准 logging 配置 + + :return: 已配置的 Loguru Logger 实例 + """ + configured_logger = _logger.patch(self._patch_record) + InterceptHandler.target_logger = configured_logger + configured_logger.remove() + info_log_path = os.path.join(self._log_base_dir, '{time:YYYY}', '{time:MM}', '{time:DD}', 'info.log') + error_log_path = os.path.join(self._log_base_dir, '{time:YYYY}', '{time:MM}', '{time:DD}', 'error.log') + if LogConfig.loguru_stdout: + if LogConfig.loguru_json: + configured_logger.add( + sys.stdout, + level=LogConfig.loguru_level, + enqueue=True, + filter=self._filter, + format=self._json_log_formatter, + ) + else: + configured_logger.add( + sys.stdout, + level=LogConfig.loguru_level, + enqueue=True, + filter=self._filter, + format=self._plain_log_formatter, + ) + if self._log_file_enabled: + if LogConfig.loguru_json: + configured_logger.add( + info_log_path, + level='INFO', + rotation=LogConfig.loguru_rotation, + retention=LogConfig.loguru_retention, + compression=LogConfig.loguru_compression, + enqueue=True, + filter=self._info_file_filter, + serialize=False, + format=self._json_log_formatter, + ) + configured_logger.add( + error_log_path, + level='WARNING', + rotation=LogConfig.loguru_rotation, + retention=LogConfig.loguru_retention, + compression=LogConfig.loguru_compression, + enqueue=True, + filter=self._error_file_filter, + serialize=False, + format=self._json_log_formatter, + ) + else: + configured_logger.add( + info_log_path, + level='INFO', + rotation=LogConfig.loguru_rotation, + retention=LogConfig.loguru_retention, + compression=LogConfig.loguru_compression, + enqueue=True, + filter=self._info_file_filter, + format=self._plain_log_formatter, + ) + configured_logger.add( + error_log_path, + level='WARNING', + rotation=LogConfig.loguru_rotation, + retention=LogConfig.loguru_retention, + compression=LogConfig.loguru_compression, + enqueue=True, + filter=self._error_file_filter, + format=self._plain_log_formatter, + ) + self._configure_logging() + return configured_logger + + +# 初始化日志处理器 +log_initializer = LoggerInitializer() +logger = log_initializer.init_log() diff --git a/shierfeng-fastapi-backend/utils/message_util.py b/shierfeng-fastapi-backend/utils/message_util.py new file mode 100644 index 0000000..b425d3b --- /dev/null +++ b/shierfeng-fastapi-backend/utils/message_util.py @@ -0,0 +1,5 @@ +from utils.log_util import logger + + +def message_service(sms_code: str) -> None: + logger.info(f'短信验证码为{sms_code}') diff --git a/shierfeng-fastapi-backend/utils/page_util.py b/shierfeng-fastapi-backend/utils/page_util.py new file mode 100644 index 0000000..3c50590 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/page_util.py @@ -0,0 +1,105 @@ +import math +from typing import Any + +from sqlalchemy import Row, Select, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from utils.common_util import CamelCaseUtil + + +class PageUtil: + """ + 分页工具类 + """ + + @classmethod + def get_page_obj(cls, data_list: list, page_num: int, page_size: int) -> PageModel: + """ + 输入数据列表data_list和分页信息,返回分页数据列表结果 + + :param data_list: 原始数据列表 + :param page_num: 当前页码 + :param page_size: 当前页面数据量 + :return: 分页数据对象 + """ + # 计算起始索引和结束索引 + start = (page_num - 1) * page_size + end = page_num * page_size + + # 根据计算得到的起始索引和结束索引对数据列表进行切片 + paginated_data = data_list[start:end] + has_next = math.ceil(len(data_list) / page_size) > page_num + + result = PageModel[Any]( + rows=paginated_data, pageNum=page_num, pageSize=page_size, total=len(data_list), hasNext=has_next + ) + + return result + + @classmethod + async def paginate( + cls, db: AsyncSession, query: Select, page_num: int, page_size: int, is_page: bool = False + ) -> PageModel | list[dict[str, Any] | list[dict[Any, Any]]]: + """ + 输入查询语句和分页信息,返回分页数据列表结果 + + :param db: orm对象 + :param query: sqlalchemy查询语句 + :param page_num: 当前页码 + :param page_size: 当前页面数据量 + :param is_page: 是否开启分页 + :return: 分页数据对象 + """ + if is_page: + total = (await db.execute(select(func.count('*')).select_from(query.subquery()))).scalar() + query_result = await db.execute(query.offset((page_num - 1) * page_size).limit(page_size)) + paginated_data: list[Row] = [] + for row in query_result: + if row and len(row) == 1: + paginated_data.append(row[0]) + else: + paginated_data.append(row) + has_next = math.ceil(total / page_size) > page_num + result = PageModel[Any]( + rows=CamelCaseUtil.transform_result(paginated_data), + pageNum=page_num, + pageSize=page_size, + total=total, + hasNext=has_next, + ) + else: + query_result = await db.execute(query) + no_paginated_data: list[Row] = [] + for row in query_result: + if row and len(row) == 1: + no_paginated_data.append(row[0]) + else: + no_paginated_data.append(row) + result = CamelCaseUtil.transform_result(no_paginated_data) + + return result + + +def get_page_obj(data_list: list, page_num: int, page_size: int) -> PageModel: + """ + 输入数据列表data_list和分页信息,返回分页数据列表结果 + + :param data_list: 原始数据列表 + :param page_num: 当前页码 + :param page_size: 当前页面数据量 + :return: 分页数据对象 + """ + # 计算起始索引和结束索引 + start = (page_num - 1) * page_size + end = page_num * page_size + + # 根据计算得到的起始索引和结束索引对数据列表进行切片 + paginated_data = data_list[start:end] + has_next = math.ceil(len(data_list) / page_size) > page_num + + result = PageModel[Any]( + rows=paginated_data, pageNum=page_num, pageSize=page_size, total=len(data_list), hasNext=has_next + ) + + return result diff --git a/shierfeng-fastapi-backend/utils/pwd_util.py b/shierfeng-fastapi-backend/utils/pwd_util.py new file mode 100644 index 0000000..9fa592f --- /dev/null +++ b/shierfeng-fastapi-backend/utils/pwd_util.py @@ -0,0 +1,30 @@ +import bcrypt + + +class PwdUtil: + """ + 密码工具类 + """ + + @classmethod + def verify_password(cls, plain_password: str, hashed_password: str) -> bool: + """ + 工具方法:校验当前输入的密码与数据库存储的密码是否一致 + + :param plain_password: 当前输入的密码 + :param hashed_password: 数据库存储的密码 + :return: 校验结果 + """ + return ( + bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) if hashed_password else None + ) + + @classmethod + def get_password_hash(cls, input_password: str) -> str: + """ + 工具方法:对当前输入的密码进行加密 + + :param input_password: 输入的密码 + :return: 加密成功的密码 + """ + return bcrypt.hashpw(input_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') diff --git a/shierfeng-fastapi-backend/utils/response_util.py b/shierfeng-fastapi-backend/utils/response_util.py new file mode 100644 index 0000000..d281501 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/response_util.py @@ -0,0 +1,315 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from fastapi import status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse, Response, StreamingResponse +from pydantic import BaseModel +from starlette.background import BackgroundTask + +from common.constant import HttpStatusConstant + + +class ResponseUtil: + """ + 响应工具类 + """ + + @classmethod + def success( + cls, + msg: str = '操作成功', + data: Any | None = None, + rows: Any | None = None, + dict_content: dict | None = None, + model_content: BaseModel | None = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 成功响应方法 + + :param msg: 可选,自定义成功响应信息 + :param data: 可选,成功响应结果中属性为data的值 + :param rows: 可选,成功响应结果中属性为rows的值 + :param dict_content: 可选,dict类型,成功响应结果中自定义属性的值 + :param model_content: 可选,BaseModel类型,成功响应结果中自定义属性的值 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 成功响应结果 + """ + result = {'code': HttpStatusConstant.SUCCESS, 'msg': msg} + + if data is not None: + result['data'] = data + if rows is not None: + result['rows'] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + result.update({'success': True, 'time': datetime.now()}) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder(result), + headers=headers, + media_type=media_type, + background=background, + ) + + @classmethod + def failure( + cls, + msg: str = '操作失败', + data: Any | None = None, + rows: Any | None = None, + dict_content: dict | None = None, + model_content: BaseModel | None = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 失败响应方法 + + :param msg: 可选,自定义失败响应信息 + :param data: 可选,失败响应结果中属性为data的值 + :param rows: 可选,失败响应结果中属性为rows的值 + :param dict_content: 可选,dict类型,失败响应结果中自定义属性的值 + :param model_content: 可选,BaseModel类型,失败响应结果中自定义属性的值 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 失败响应结果 + """ + result = {'code': HttpStatusConstant.WARN, 'msg': msg} + + if data is not None: + result['data'] = data + if rows is not None: + result['rows'] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + result.update({'success': False, 'time': datetime.now()}) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder(result), + headers=headers, + media_type=media_type, + background=background, + ) + + @classmethod + def unauthorized( + cls, + msg: str = '登录信息已过期,访问系统资源失败', + data: Any | None = None, + rows: Any | None = None, + dict_content: dict | None = None, + model_content: BaseModel | None = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 未认证响应方法 + + :param msg: 可选,自定义未认证响应信息 + :param data: 可选,未认证响应结果中属性为data的值 + :param rows: 可选,未认证响应结果中属性为rows的值 + :param dict_content: 可选,dict类型,未认证响应结果中自定义属性的值 + :param model_content: 可选,BaseModel类型,未认证响应结果中自定义属性的值 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 未认证响应结果 + """ + result = {'code': HttpStatusConstant.UNAUTHORIZED, 'msg': msg} + + if data is not None: + result['data'] = data + if rows is not None: + result['rows'] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + result.update({'success': False, 'time': datetime.now()}) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder(result), + headers=headers, + media_type=media_type, + background=background, + ) + + @classmethod + def forbidden( + cls, + msg: str = '该用户无此接口权限', + data: Any | None = None, + rows: Any | None = None, + dict_content: dict | None = None, + model_content: BaseModel | None = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 未授权响应方法 + + :param msg: 可选,自定义未授权响应信息 + :param data: 可选,未授权响应结果中属性为data的值 + :param rows: 可选,未授权响应结果中属性为rows的值 + :param dict_content: 可选,dict类型,未授权响应结果中自定义属性的值 + :param model_content: 可选,BaseModel类型,未授权响应结果中自定义属性的值 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 未授权响应结果 + """ + result = {'code': HttpStatusConstant.FORBIDDEN, 'msg': msg} + + if data is not None: + result['data'] = data + if rows is not None: + result['rows'] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + result.update({'success': False, 'time': datetime.now()}) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder(result), + headers=headers, + media_type=media_type, + background=background, + ) + + @classmethod + def error( + cls, + msg: str = '接口异常', + data: Any | None = None, + rows: Any | None = None, + dict_content: dict | None = None, + model_content: BaseModel | None = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 错误响应方法 + + :param msg: 可选,自定义错误响应信息 + :param data: 可选,错误响应结果中属性为data的值 + :param rows: 可选,错误响应结果中属性为rows的值 + :param dict_content: 可选,dict类型,错误响应结果中自定义属性的值 + :param model_content: 可选,BaseModel类型,错误响应结果中自定义属性的值 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 错误响应结果 + """ + result = {'code': HttpStatusConstant.ERROR, 'msg': msg} + + if data is not None: + result['data'] = data + if rows is not None: + result['rows'] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + result.update({'success': False, 'time': datetime.now()}) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder(result), + headers=headers, + media_type=media_type, + background=background, + ) + + @classmethod + def too_many_requests( + cls, + msg: str = '请求过于频繁,请稍后再试', + data: Any | None = None, + rows: Any | None = None, + dict_content: dict | None = None, + model_content: BaseModel | None = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 接口限流响应方法 + + :param msg: 可选,自定义限流响应信息 + :param data: 可选,限流响应结果中属性为data的值 + :param rows: 可选,限流响应结果中属性为rows的值 + :param dict_content: 可选,dict类型,限流响应结果中自定义属性的值 + :param model_content: 可选,BaseModel类型,限流响应结果中自定义属性的值 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 限流响应结果 + """ + result = {'code': HttpStatusConstant.TOO_MANY_REQUESTS, 'msg': msg} + + if data is not None: + result['data'] = data + if rows is not None: + result['rows'] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + result.update({'success': False, 'time': datetime.now()}) + + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content=jsonable_encoder(result), + headers=headers, + media_type=media_type, + background=background, + ) + + @classmethod + def streaming( + cls, + *, + data: Any = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> Response: + """ + 流式响应方法 + + :param data: 流式传输的内容 + :param headers: 可选,响应头信息 + :param media_type: 可选,响应结果媒体类型 + :param background: 可选,响应返回后执行的后台任务 + :return: 流式响应结果 + """ + return StreamingResponse( + status_code=status.HTTP_200_OK, content=data, headers=headers, media_type=media_type, background=background + ) diff --git a/shierfeng-fastapi-backend/utils/server_util.py b/shierfeng-fastapi-backend/utils/server_util.py new file mode 100644 index 0000000..98ae174 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/server_util.py @@ -0,0 +1,496 @@ +import asyncio +import ipaddress +import os +import socket +import uuid +from collections.abc import Callable + +import psutil +from fastapi import FastAPI, Request, applications +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html +from fastapi.openapi.utils import get_openapi +from fastapi.responses import HTMLResponse, JSONResponse +from redis import asyncio as aioredis + +from config.env import AppConfig + + +class APIDocsUtil: + """ + API文档工具类 + """ + + # API文档URLs + _OPENAPI_URL = '/openapi.json' + _PROXY_OPENAPI_URL = '/proxy-openapi.json' + _DOCS_URL = '/docs' + _PROXY_DOCS_URL = '/proxy-docs' + _REDOC_URL = '/redoc' + _PROXY_REDOC_URL = '/proxy-redoc' + _OAUTH2_REDIRECT_URL = '/docs/oauth2-redirect' + _PROXY_OAUTH2_REDIRECT_URL = '/proxy-docs/oauth2-redirect' + + # 文档静态资源URLs + DEFAULT_REDOC_JS_URL = 'https://registry.npmmirror.com/redoc/2/files/bundles/redoc.standalone.js' + DEFAULT_REDOC_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png' + DEFAULT_SWAGGER_JS_URL = 'https://registry.npmmirror.com/swagger-ui-dist/5/files/swagger-ui-bundle.js' + DEFAULT_SWAGGER_CSS_URL = 'https://registry.npmmirror.com/swagger-ui-dist/5/files/swagger-ui.css' + DEFAULT_SWAGGER_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png' + + @classmethod + def proxy_openapi_url(cls) -> str: + """ + 代理OpenAPI文档URL + """ + return cls._PROXY_OPENAPI_URL if not AppConfig.app_disable_swagger and not AppConfig.app_disable_redoc else None + + @classmethod + def docs_url(cls) -> str: + """ + 文档URL + """ + return cls._DOCS_URL + + @classmethod + def proxy_docs_url(cls) -> str: + """ + 代理文档URL + """ + return cls._PROXY_DOCS_URL if not AppConfig.app_disable_swagger else None + + @classmethod + def redoc_url(cls) -> str: + """ + ReDoc文档URL + """ + return cls._REDOC_URL + + @classmethod + def proxy_redoc_url(cls) -> str: + """ + 代理ReDoc文档URL + """ + return cls._PROXY_REDOC_URL if not AppConfig.app_disable_redoc else None + + @classmethod + def proxy_oauth2_redirect_url(cls) -> str: + """ + 代理OAuth2重定向URL + """ + return cls._PROXY_OAUTH2_REDIRECT_URL if not AppConfig.app_disable_swagger else None + + @classmethod + def setup_docs_static_resources( + cls, + redoc_js_url: str = DEFAULT_REDOC_JS_URL, + redoc_favicon_url: str = DEFAULT_REDOC_FAVICON_URL, + swagger_js_url: str = DEFAULT_SWAGGER_JS_URL, + swagger_css_url: str = DEFAULT_SWAGGER_CSS_URL, + swagger_favicon_url: str = DEFAULT_SWAGGER_FAVICON_URL, + ) -> None: + """ + 配置文档静态资源 + + :param redoc_js_url: 用于加载ReDoc JavaScript的URL + :param redoc_favicon_url: ReDoc要使用的favicon的URL + :param swagger_js_url: 用于加载Swagger UI JavaScript的URL + :param swagger_css_url: 用于加载Swagger UI CSS的URL + :param swagger_favicon_url: Swagger UI要使用的favicon的URL + :return: + """ + + def redoc_monkey_patch(*args, **kwargs) -> HTMLResponse: + return get_redoc_html( + *args, + **kwargs, + redoc_js_url=redoc_js_url, + redoc_favicon_url=redoc_favicon_url, + ) + + def swagger_ui_monkey_patch(*args, **kwargs) -> HTMLResponse: + return get_swagger_ui_html( + *args, + **kwargs, + swagger_js_url=swagger_js_url, + swagger_css_url=swagger_css_url, + swagger_favicon_url=swagger_favicon_url, + ) + + applications.get_redoc_html = redoc_monkey_patch + applications.get_swagger_ui_html = swagger_ui_monkey_patch + + @classmethod + def custom_api_docs_router( + cls, + app: FastAPI, + redoc_js_url: str = DEFAULT_REDOC_JS_URL, + redoc_favicon_url: str = DEFAULT_REDOC_FAVICON_URL, + swagger_js_url: str = DEFAULT_SWAGGER_JS_URL, + swagger_css_url: str = DEFAULT_SWAGGER_CSS_URL, + swagger_favicon_url: str = DEFAULT_SWAGGER_FAVICON_URL, + ) -> None: + """ + 自定义API文档路由 + + :param app: FastAPI对象 + :param redoc_js_url: 用于加载ReDoc JavaScript的URL + :param redoc_favicon_url: ReDoc要使用的favicon的URL + :param swagger_js_url: 用于加载Swagger UI JavaScript的URL + :param swagger_css_url: 用于加载Swagger UI CSS的URL + :param swagger_favicon_url: Swagger UI要使用的favicon的URL + :return: + """ + + async def custom_openapi(request: Request) -> JSONResponse: + return await cls._custom_openapi(app) + + async def custom_redoc(request: Request) -> HTMLResponse: + return await cls._custom_redoc(app, redoc_js_url, redoc_favicon_url) + + async def custom_swagger(request: Request) -> HTMLResponse: + return await cls._custom_swagger(app, swagger_js_url, swagger_css_url, swagger_favicon_url) + + async def custom_swagger_ui_redirect(request: Request) -> HTMLResponse: + return await cls._custom_swagger_ui_redirect(app, swagger_favicon_url) + + # 注册路由 + app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False) + cls._register_docs_routes(app, custom_swagger, custom_swagger_ui_redirect, custom_redoc) + + @classmethod + async def _custom_openapi(cls, app: FastAPI) -> JSONResponse: + """ + 自定义 OpenAPI 路由处理函数 + + :param app: FastAPI对象 + :return: openapi的json响应 + """ + openapi_schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + summary=app.summary, + description=app.description, + terms_of_service=app.terms_of_service, + contact=app.contact, + license_info=app.license_info, + routes=app.routes, + webhooks=app.webhooks.routes, + tags=app.openapi_tags, + separate_input_output_schemas=app.separate_input_output_schemas, + external_docs=app.openapi_external_docs, + ) + return JSONResponse(openapi_schema) + + @classmethod + async def _custom_redoc( + cls, + app: FastAPI, + redoc_js_url: str, + redoc_favicon_url: str, + ) -> HTMLResponse: + """ + 自定义 ReDoc 路由处理函数 + + :param app: FastAPI对象 + :param redoc_js_url: 用于加载ReDoc JavaScript的URL + :param redoc_favicon_url: ReDoc要使用的favicon的URL + :return: ReDoc HTML响应 + """ + if not AppConfig.app_disable_redoc: + return get_redoc_html( + openapi_url=cls._OPENAPI_URL, + title=f'{app.title} - ReDoc', + redoc_js_url=redoc_js_url, + redoc_favicon_url=redoc_favicon_url, + ) + return cls._get_disabled_html_content( + f'{app.title} - ReDoc', + 'ReDoc', + redoc_favicon_url, + ) + + @classmethod + async def _custom_swagger( + cls, + app: FastAPI, + swagger_js_url: str, + swagger_css_url: str, + swagger_favicon_url: str, + ) -> HTMLResponse: + """ + 自定义 Swagger UI 路由处理函数 + + :param app: FastAPI对象 + :param swagger_js_url: 用于加载Swagger UI JavaScript的URL + :param swagger_css_url: 用于加载Swagger UI CSS的URL + :param swagger_favicon_url: Swagger UI要使用的favicon的URL + :return: Swagger UI HTML响应 + """ + if not AppConfig.app_disable_swagger: + return get_swagger_ui_html( + openapi_url=cls._OPENAPI_URL, + title=f'{app.title} - Swagger UI', + swagger_js_url=swagger_js_url, + swagger_css_url=swagger_css_url, + swagger_favicon_url=swagger_favicon_url, + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + init_oauth=app.swagger_ui_init_oauth, + swagger_ui_parameters=app.swagger_ui_parameters, + ) + return cls._get_disabled_html_content( + f'{app.title} - Swagger UI', + 'Swagger UI', + swagger_favicon_url, + ) + + @classmethod + async def _custom_swagger_ui_redirect( + cls, + app: FastAPI, + swagger_favicon_url: str, + ) -> HTMLResponse: + """ + 自定义 Swagger UI OAuth2 重定向路由处理函数 + + :param app: FastAPI对象 + :param swagger_favicon_url: Swagger UI要使用的favicon的URL + :return: Swagger UI OAuth2重定向HTML响应 + """ + if not AppConfig.app_disable_swagger: + return get_swagger_ui_oauth2_redirect_html() + return cls._get_disabled_html_content( + f'{app.title} - Swagger UI OAuth2 Redirect', + 'Swagger UI OAuth2 Redirect', + swagger_favicon_url, + ) + + @staticmethod + def _get_disabled_html_content(title: str, name: str, favicon_url: str) -> HTMLResponse: + """ + 生成禁用文档的HTML内容 + + :param title: 页面标题 + :param name: 文档名称 + :param favicon_url: 图标地址 + :return: 禁用文档HTML响应 + """ + html = f""" + + + + {title} + + + + + + + + + +

{name} has been disabled. Please enable it first.

+ + + """ + return HTMLResponse(html) + + @classmethod + def _register_docs_routes( + cls, app: FastAPI, swagger_handler: Callable, redirect_handler: Callable, redoc_handler: Callable + ) -> None: + """ + 注册文档路由 + + :param app: FastAPI对象 + :param swagger_handler: Swagger UI 路由处理函数 + :param redirect_handler: Swagger UI OAuth2 重定向路由处理函数 + :param redoc_handler: ReDoc 路由处理函数 + :return: + """ + swagger_urls: list[str] = ( + [cls._DOCS_URL] if not AppConfig.app_disable_swagger else [cls._DOCS_URL, cls._PROXY_DOCS_URL] + ) + swagger_redirect_urls: list[str] = ( + [cls._OAUTH2_REDIRECT_URL] + if not AppConfig.app_disable_swagger + else [cls._OAUTH2_REDIRECT_URL, cls._PROXY_OAUTH2_REDIRECT_URL] + ) + redoc_urls: list[str] = ( + [cls._REDOC_URL] if not AppConfig.app_disable_redoc else [cls._REDOC_URL, cls._PROXY_REDOC_URL] + ) + + for url in swagger_urls: + app.add_route(url, swagger_handler, include_in_schema=False) + for url in swagger_redirect_urls: + app.add_route(url, redirect_handler, include_in_schema=False) + for url in redoc_urls: + app.add_route(url, redoc_handler, include_in_schema=False) + + +class StartupUtil: + """ + 启动门禁工具类 + """ + + @classmethod + async def acquire_startup_log_gate( + cls, redis: aioredis.Redis, lock_key: str, worker_id: str, lock_expire_seconds: int + ) -> bool: + """ + 获取启动日志门禁 + + :param redis: Redis连接对象 + :param lock_key: 分布式锁key + :param worker_id: 当前worker标识 + :param lock_expire_seconds: 锁过期时间 + :return: 是否获得启动日志输出权 + """ + acquired = await redis.set(lock_key, worker_id, nx=True, ex=lock_expire_seconds) + if acquired: + return True + current_holder = await redis.get(lock_key) + return current_holder == worker_id + + @classmethod + def start_lock_renewal( + cls, + redis: aioredis.Redis, + lock_key: str, + worker_id: str, + lock_expire_seconds: int, + interval_seconds: int, + on_lock_lost: Callable[[], None] | None = None, + ) -> asyncio.Task: + """ + 启动分布式锁续期任务 + + :param redis: Redis连接对象 + :param lock_key: 分布式锁key + :param worker_id: 当前worker标识 + :param lock_expire_seconds: 锁过期时间 + :param interval_seconds: 续期间隔时间 + :param on_lock_lost: 失去锁时的回调 + :return: 异步任务对象 + """ + + async def _loop() -> None: + while True: + try: + current_holder = await redis.get(lock_key) + if current_holder == worker_id: + await redis.expire(lock_key, lock_expire_seconds) + await asyncio.sleep(interval_seconds) + continue + if on_lock_lost: + on_lock_lost() + break + except Exception: + await asyncio.sleep(interval_seconds) + + return asyncio.create_task(_loop()) + + +class WorkerIdUtil: + """ + Worker标识生成工具类 + """ + + _worker_id: str | None = None + + @classmethod + def get_worker_id(cls, configured_worker_id: str | None) -> str: + """ + 获取当前worker标识 + + :param configured_worker_id: 配置的worker标识 + :return: 当前worker标识 + """ + if cls._worker_id: + return cls._worker_id + worker_id = configured_worker_id + if not worker_id or worker_id.lower() == 'auto': + worker_id = f'{os.getpid()}-{uuid.uuid4().hex[:6]}' + cls._worker_id = worker_id + return worker_id + + +class IPUtil: + """ + IP工具类 + """ + + _PREFERRED_DNS_HOSTS: tuple[str, str] = ('223.5.5.5', '8.8.8.8') + _DNS_CONNECT_TIMEOUT = 1 + + @classmethod + def get_local_ip(cls) -> str: + """ + 获取本机Local IP + """ + try: + for snics in psutil.net_if_addrs().values(): + for snic in snics: + if snic.family == socket.AF_INET and snic.address.startswith('127.'): + return snic.address + except Exception: + pass + + return '127.0.0.1' + + @classmethod + def get_network_ips(cls) -> list[str]: + """ + 获取本机Network IP列表 + """ + network_ips = [] + try: + # 获取网卡状态 + stats = psutil.net_if_stats() + for name, snics in psutil.net_if_addrs().items(): + # 过滤掉状态为DOWN的网卡 + if name in stats and not stats[name].isup: + continue + + for snic in snics: + if snic.family == socket.AF_INET: + try: + ip_obj = ipaddress.ip_address(snic.address) + if ip_obj.is_loopback or ip_obj.is_link_local: + continue + network_ips.append(snic.address) + except ValueError: + continue + except Exception: + pass + + # 优先显示首选出站IP + preferred_ip = None + for dns_host in cls._PREFERRED_DNS_HOSTS: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.settimeout(cls._DNS_CONNECT_TIMEOUT) + s.connect((dns_host, 80)) + preferred_ip = s.getsockname()[0] + break + except Exception: + continue + + if preferred_ip: + if preferred_ip in network_ips: + network_ips.remove(preferred_ip) + network_ips.insert(0, preferred_ip) + + if not network_ips: + network_ips = ['127.0.0.1'] + + return network_ips diff --git a/shierfeng-fastapi-backend/utils/string_util.py b/shierfeng-fastapi-backend/utils/string_util.py new file mode 100644 index 0000000..55f3d8f --- /dev/null +++ b/shierfeng-fastapi-backend/utils/string_util.py @@ -0,0 +1,165 @@ +from common.constant import CommonConstant + + +class StringUtil: + """ + 字符串工具类 + """ + + @classmethod + def is_blank(cls, string: str | None) -> bool: + """ + 校验字符串是否为''或全空格 + + :param string: 需要校验的字符串 + :return: 校验结果 + """ + if string is None: + return False + str_len = len(string) + if str_len == 0: + return True + return all(string[i] == ' ' for i in range(str_len)) + + @classmethod + def is_empty(cls, string: str) -> bool: + """ + 校验字符串是否为''或None + + :param string: 需要校验的字符串 + :return: 校验结果 + """ + return string is None or len(string) == 0 + + @classmethod + def is_not_empty(cls, string: str) -> bool: + """ + 校验字符串是否不是''和None + + :param string: 需要校验的字符串 + :return: 校验结果 + """ + return not cls.is_empty(string) + + @classmethod + def is_http(cls, link: str) -> bool: + """ + 判断是否为http(s)://开头 + + :param link: 链接 + :return: 是否为http(s)://开头 + """ + return link.startswith((CommonConstant.HTTP, CommonConstant.HTTPS)) + + @classmethod + def contains_ignore_case(cls, search_str: str, compare_str: str) -> bool: + """ + 查找指定字符串是否包含指定字符串同时忽略大小写 + + :param search_str: 查找的字符串 + :param compare_str: 比对的字符串 + :return: 查找结果 + """ + if compare_str and search_str: + return compare_str.lower() in search_str.lower() + return False + + @classmethod + def contains_any_ignore_case(cls, search_str: str, compare_str_list: list[str]) -> bool: + """ + 查找指定字符串是否包含指定字符串列表中的任意一个字符串同时忽略大小写 + + :param search_str: 查找的字符串 + :param compare_str_list: 比对的字符串列表 + :return: 查找结果 + """ + if search_str and compare_str_list: + return any(cls.contains_ignore_case(search_str, compare_str) for compare_str in compare_str_list) + return False + + @classmethod + def equals_ignore_case(cls, search_str: str, compare_str: str) -> bool: + """ + 比较两个字符串是否相等同时忽略大小写 + + :param search_str: 查找的字符串 + :param compare_str: 比对的字符串 + :return: 比较结果 + """ + if search_str and compare_str: + return search_str.lower() == compare_str.lower() + return False + + @classmethod + def equals_any_ignore_case(cls, search_str: str, compare_str_list: list[str]) -> bool: + """ + 比较指定字符串是否与指定字符串列表中的任意一个字符串相等同时忽略大小写 + + :param search_str: 查找的字符串 + :param compare_str_list: 比对的字符串列表 + :return: 比较结果 + """ + if search_str and compare_str_list: + return any(cls.equals_ignore_case(search_str, compare_str) for compare_str in compare_str_list) + return False + + @classmethod + def startswith_case(cls, search_str: str, compare_str: str) -> bool: + """ + 查找指定字符串是否以指定字符串开头 + + :param search_str: 查找的字符串 + :param compare_str: 比对的字符串 + :return: 查找结果 + """ + if compare_str and search_str: + return search_str.startswith(compare_str) + return False + + @classmethod + def startswith_any_case(cls, search_str: str, compare_str_list: list[str]) -> bool: + """ + 查找指定字符串是否以指定字符串列表中的任意一个字符串开头 + + :param search_str: 查找的字符串 + :param compare_str_list: 比对的字符串列表 + :return: 查找结果 + """ + if search_str and compare_str_list: + return any(cls.startswith_case(search_str, compare_str) for compare_str in compare_str_list) + return False + + @classmethod + def convert_to_camel_case(cls, name: str) -> str: + """ + 将下划线大写方式命名的字符串转换为驼峰式。如果转换前的下划线大写方式命名的字符串为空,则返回空字符串 + + :param name: 转换前的下划线大写方式命名的字符串 + :return: 转换后的驼峰式命名的字符串 + """ + if not name: + return '' + if '_' not in name: + return name[0].upper() + name[1:] + parts = name.split('_') + result = [] + for part in parts: + if not part: + continue + result.append(part[0].upper() + part[1:].lower()) + return ''.join(result) + + @classmethod + def get_mapping_value_by_key_ignore_case(cls, mapping: dict[str, str], key: str) -> str: + """ + 根据忽略大小写的键获取字典中的对应的值 + + param mapping: 字典 + param key: 字典的键 + :return: 字典键对应的值 + """ + for k, v in mapping.items(): + if key.lower() == k.lower(): + return v + + return '' diff --git a/shierfeng-fastapi-backend/utils/template_util.py b/shierfeng-fastapi-backend/utils/template_util.py new file mode 100644 index 0000000..89cc412 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/template_util.py @@ -0,0 +1,473 @@ +import json +import os +from datetime import datetime +from typing import Any + +from jinja2 import Environment, FileSystemLoader + +from common.constant import GenConstant +from config.env import DataBaseConfig +from exceptions.exception import ServiceWarning +from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel +from utils.common_util import CamelCaseUtil, SnakeCaseUtil +from utils.string_util import StringUtil + + +class TemplateInitializer: + """ + 模板引擎初始化类 + """ + + @classmethod + def init_jinja2(cls) -> Environment: + """ + 初始化 Jinja2 模板引擎 + + :return: Jinja2 环境对象 + """ + try: + template_dir = os.path.join(os.getcwd(), 'module_generator', 'templates') + env = Environment( + loader=FileSystemLoader(template_dir), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) + env.filters.update( + { + 'camel_to_snake': SnakeCaseUtil.camel_to_snake, + 'snake_to_camel': CamelCaseUtil.snake_to_camel, + 'get_sqlalchemy_type': TemplateUtils.get_sqlalchemy_type, + } + ) + return env + except Exception as e: + raise RuntimeError(f'初始化Jinja2模板引擎失败: {e}') from e + + +class TemplateUtils: + """ + 模板工具类 + """ + + # 项目路径 + FRONTEND_PROJECT_PATH = 'frontend' + BACKEND_PROJECT_PATH = 'backend' + DEFAULT_PARENT_MENU_ID = '3' + + @classmethod + def prepare_context(cls, gen_table: GenTableModel) -> dict[str, Any]: + """ + 准备模板变量 + + :param gen_table: 生成表的配置信息 + :return: 模板上下文字典 + """ + if not gen_table.options: + raise ServiceWarning(message='请先完善生成配置信息') + class_name = gen_table.class_name + module_name = gen_table.module_name + business_name = gen_table.business_name + package_name = gen_table.package_name + tpl_category = gen_table.tpl_category + function_name = gen_table.function_name + + context = { + 'tplCategory': tpl_category, + 'tableName': gen_table.table_name, + 'tableComment': gen_table.table_comment, + 'functionName': function_name if StringUtil.is_not_empty(function_name) else '【请填写功能名称】', + 'ClassName': class_name, + 'className': class_name.lower(), + 'moduleName': module_name, + 'BusinessName': business_name.capitalize(), + 'businessName': business_name, + 'basePackage': cls.get_package_prefix(package_name), + 'packageName': package_name, + 'author': gen_table.function_author, + 'datetime': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'pkColumn': gen_table.pk_column, + 'doImportList': cls.get_do_import_list(gen_table), + 'voImportList': cls.get_vo_import_list(gen_table), + 'permissionPrefix': cls.get_permission_prefix(module_name, business_name), + 'columns': gen_table.columns, + 'table': gen_table, + 'dicts': cls.get_dicts(gen_table), + 'dbType': DataBaseConfig.db_type, + 'column_not_add_show': GenConstant.COLUMNNAME_NOT_ADD_SHOW, + 'column_not_edit_show': GenConstant.COLUMNNAME_NOT_EDIT_SHOW, + } + + # 设置菜单、树形结构、子表的上下文 + cls.set_menu_context(context, gen_table) + if tpl_category == GenConstant.TPL_TREE: + cls.set_tree_context(context, gen_table) + if tpl_category == GenConstant.TPL_SUB: + cls.set_sub_context(context, gen_table) + + return context + + @classmethod + def set_menu_context(cls, context: dict, gen_table: GenTableModel) -> None: + """ + 设置菜单上下文 + + :param context: 模板上下文字典 + :param gen_table: 生成表的配置信息 + :return: 新的模板上下文字典 + """ + options = gen_table.options + params_obj = json.loads(options) + context['parentMenuId'] = cls.get_parent_menu_id(params_obj) + + @classmethod + def set_tree_context(cls, context: dict, gen_table: GenTableModel) -> None: + """ + 设置树形结构上下文 + + :param context: 模板上下文字典 + :param gen_table: 生成表的配置信息 + :return: 新的模板上下文字典 + """ + options = gen_table.options + params_obj = json.loads(options) + context['treeCode'] = cls.get_tree_code(params_obj) + context['treeParentCode'] = cls.get_tree_parent_code(params_obj) + context['treeName'] = cls.get_tree_name(params_obj) + context['expandColumn'] = cls.get_expand_column(gen_table) + + @classmethod + def set_sub_context(cls, context: dict, gen_table: GenTableModel) -> None: + """ + 设置子表上下文 + + :param context: 模板上下文字典 + :param gen_table: 生成表的配置信息 + :return: 新的模板上下文字典 + """ + sub_table = gen_table.sub_table + sub_table_name = gen_table.sub_table_name + sub_table_fk_name = gen_table.sub_table_fk_name + sub_class_name = sub_table.class_name + sub_table_fk_class_name = StringUtil.convert_to_camel_case(sub_table_fk_name) + context['subTable'] = sub_table + context['subTableName'] = sub_table_name + context['subTableFkName'] = sub_table_fk_name + context['subTableFkClassName'] = sub_table_fk_class_name + context['subTableFkclassName'] = sub_table_fk_class_name.lower() + context['subClassName'] = sub_class_name + context['subclassName'] = sub_class_name.lower() + + @classmethod + def get_template_list(cls, tpl_category: str, tpl_web_type: str) -> list[str]: + """ + 获取模板列表 + + :param tpl_category: 生成模板类型 + :param tpl_web_type: 前端类型 + :return: 模板列表 + """ + use_web_type = 'vue' + if tpl_web_type == 'element-plus': + use_web_type = 'vue/v3' + templates = [ + 'python/controller.py.jinja2', + 'python/dao.py.jinja2', + 'python/do.py.jinja2', + 'python/service.py.jinja2', + 'python/vo.py.jinja2', + 'sql/sql.jinja2', + 'js/api.js.jinja2', + ] + if tpl_category == GenConstant.TPL_CRUD: + templates.append(f'{use_web_type}/index.vue.jinja2') + elif tpl_category == GenConstant.TPL_TREE: + templates.append(f'{use_web_type}/index-tree.vue.jinja2') + elif tpl_category == GenConstant.TPL_SUB: + templates.append(f'{use_web_type}/index.vue.jinja2') + # templates.append('python/sub-domain.python.jinja2') + return templates + + @classmethod + def get_file_name(cls, template: list[str], gen_table: GenTableModel) -> str: + """ + 根据模板生成文件名 + + :param template: 模板列表 + :param gen_table: 生成表的配置信息 + :return: 模板生成文件名 + """ + package_name = gen_table.package_name + module_name = gen_table.module_name + business_name = gen_table.business_name + + vue_path = cls.FRONTEND_PROJECT_PATH + python_path = f'{cls.BACKEND_PROJECT_PATH}/{package_name.replace(".", "/")}' + + if 'controller.py.jinja2' in template: + return f'{python_path}/controller/{business_name}_controller.py' + if 'dao.py.jinja2' in template: + return f'{python_path}/dao/{business_name}_dao.py' + if 'do.py.jinja2' in template: + return f'{python_path}/entity/do/{business_name}_do.py' + if 'service.py.jinja2' in template: + return f'{python_path}/service/{business_name}_service.py' + if 'vo.py.jinja2' in template: + return f'{python_path}/entity/vo/{business_name}_vo.py' + if 'sql.jinja2' in template: + return f'{cls.BACKEND_PROJECT_PATH}/sql/{business_name}_menu.sql' + if 'api.js.jinja2' in template: + return f'{vue_path}/api/{module_name}/{business_name}.js' + if 'index.vue.jinja2' in template or 'index-tree.vue.jinja2' in template: + return f'{vue_path}/views/{module_name}/{business_name}/index.vue' + return '' + + @classmethod + def get_package_prefix(cls, package_name: str) -> str: + """ + 获取包前缀 + + :param package_name: 包名 + :return: 包前缀 + """ + return package_name[: package_name.rfind('.')] + + @classmethod + def get_vo_import_list(cls, gen_table: GenTableModel) -> list[str]: + """ + 获取vo模板导入包列表 + + :param gen_table: 生成表的配置信息 + :return: 导入包列表 + """ + columns = gen_table.columns or [] + import_list = set() + for column in columns: + if column.python_type in GenConstant.TYPE_DATE: + import_list.add(f'from datetime import {column.python_type}') + elif column.python_type == GenConstant.TYPE_DECIMAL: + import_list.add('from decimal import Decimal') + if gen_table.sub: + sub_columns = gen_table.sub_table.columns or [] + for sub_column in sub_columns: + if sub_column.python_type in GenConstant.TYPE_DATE: + import_list.add(f'from datetime import {sub_column.python_type}') + elif sub_column.python_type == GenConstant.TYPE_DECIMAL: + import_list.add('from decimal import Decimal') + return cls.merge_same_imports(list(import_list), 'from datetime import') + + @classmethod + def get_do_import_list(cls, gen_table: GenTableModel) -> list[str]: + """ + 获取do模板导入包列表 + + :param gen_table: 生成表的配置信息 + :return: 导入包列表 + """ + columns = gen_table.columns or [] + import_list = set() + import_list.add('from sqlalchemy import Column') + for column in columns: + data_type = cls.get_db_type(column.column_type) + if data_type in GenConstant.COLUMNTYPE_GEOMETRY: + import_list.add('from geoalchemy2 import Geometry') + import_list.add( + f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, data_type)}' + ) + if gen_table.sub: + import_list.add('from sqlalchemy import ForeignKey') + sub_columns = gen_table.sub_table.columns or [] + for sub_column in sub_columns: + data_type = cls.get_db_type(sub_column.column_type) + import_list.add( + f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, data_type)}' + ) + return cls.merge_same_imports(list(import_list), 'from sqlalchemy import') + + @classmethod + def get_db_type(cls, column_type: str) -> str: + """ + 获取数据库类型字段 + + param column_type: 字段类型 + :return: 数据库类型 + """ + if '(' in column_type: + return column_type.split('(', maxsplit=1)[0] + return column_type + + @classmethod + def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]: + """ + 合并相同的导入语句 + + :param imports: 导入语句列表 + :param import_start: 导入语句的起始字符串 + :return: 合并后的导入语句列表 + """ + merged_imports = [] + _imports = [] + for import_stmt in imports: + if import_stmt.startswith(import_start): + imported_items = import_stmt.split('import')[1].strip() + _imports.extend(imported_items.split(', ')) + else: + merged_imports.append(import_stmt) + + if _imports: + merged_datetime_import = f'{import_start} {", ".join(_imports)}' + merged_imports.append(merged_datetime_import) + + return merged_imports + + @classmethod + def get_dicts(cls, gen_table: GenTableModel) -> str: + """ + 获取字典列表 + + :param gen_table: 生成表的配置信息 + :return: 字典列表 + """ + columns = gen_table.columns or [] + dicts = set() + cls.add_dicts(dicts, columns) + if gen_table.sub_table is not None: + cls.add_dicts(dicts, gen_table.sub_table.columns) + return ', '.join(dicts) + + @classmethod + def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnModel]) -> None: + """ + 添加字典列表 + + :param dicts: 字典列表 + :param columns: 字段列表 + :return: 新的字典列表 + """ + for column in columns: + if ( + not column.super_column + and StringUtil.is_not_empty(column.dict_type) + and StringUtil.equals_any_ignore_case( + column.html_type, [GenConstant.HTML_SELECT, GenConstant.HTML_RADIO, GenConstant.HTML_CHECKBOX] + ) + ): + dicts.add(f"'{column.dict_type}'") + + @classmethod + def get_permission_prefix(cls, module_name: str, business_name: str) -> str: + """ + 获取权限前缀 + + :param module_name: 模块名 + :param business_name: 业务名 + :return: 权限前缀 + """ + return f'{module_name}:{business_name}' + + @classmethod + def get_parent_menu_id(cls, params_obj: dict) -> str: + """ + 获取上级菜单ID + + :param params_obj: 菜单参数字典 + :return: 上级菜单ID + """ + if params_obj and params_obj.get(GenConstant.PARENT_MENU_ID): + return params_obj.get(GenConstant.PARENT_MENU_ID) + return cls.DEFAULT_PARENT_MENU_ID + + @classmethod + def get_tree_code(cls, params_obj: dict) -> str: + """ + 获取树编码 + + :param params_obj: 菜单参数字典 + :return: 树编码 + """ + if GenConstant.TREE_CODE in params_obj: + return cls.to_camel_case(params_obj.get(GenConstant.TREE_CODE)) + return '' + + @classmethod + def get_tree_parent_code(cls, params_obj: dict) -> str: + """ + 获取树父编码 + + :param params_obj: 菜单参数字典 + :return: 树父编码 + """ + if GenConstant.TREE_PARENT_CODE in params_obj: + return cls.to_camel_case(params_obj.get(GenConstant.TREE_PARENT_CODE)) + return '' + + @classmethod + def get_tree_name(cls, params_obj: dict) -> str: + """ + 获取树名称 + + :param params_obj: 菜单参数字典 + :return: 树名称 + """ + if GenConstant.TREE_NAME in params_obj: + return cls.to_camel_case(params_obj.get(GenConstant.TREE_NAME)) + return '' + + @classmethod + def get_expand_column(cls, gen_table: GenTableModel) -> int: + """ + 获取展开列 + + :param gen_table: 生成表的配置信息 + :return: 展开列 + """ + options = gen_table.options + params_obj = json.loads(options) + tree_name = params_obj.get(GenConstant.TREE_NAME) + num = 0 + for column in gen_table.columns or []: + if column.list: + num += 1 + if column.column_name == tree_name: + break + return num + + @classmethod + def to_camel_case(cls, text: str) -> str: + """ + 将字符串转换为驼峰命名 + + :param text: 待转换的字符串 + :return: 转换后的驼峰命名字符串 + """ + parts = text.split('_') + return parts[0] + ''.join(word.capitalize() for word in parts[1:]) + + @classmethod + def get_sqlalchemy_type(cls, column_type: str) -> str: + """ + 获取SQLAlchemy类型 + + :param column_type: 列类型 + :return: SQLAlchemy类型 + """ + if '(' in column_type: + column_type_list = column_type.split('(') + if column_type_list[0] in GenConstant.COLUMNTYPE_STR: + sqlalchemy_type = ( + StringUtil.get_mapping_value_by_key_ignore_case( + GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type_list[0] + ) + + '(' + + column_type_list[1] + ) + else: + sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case( + GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type_list[0] + ) + else: + sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case( + GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type + ) + + return sqlalchemy_type diff --git a/shierfeng-fastapi-backend/utils/time_format_util.py b/shierfeng-fastapi-backend/utils/time_format_util.py new file mode 100644 index 0000000..e2bf150 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/time_format_util.py @@ -0,0 +1,142 @@ +from copy import deepcopy +from datetime import date, datetime +from typing import Any + +from dateutil.parser import parse + + +def object_format_datetime(obj: Any) -> Any: + """ + :param obj: 输入一个对象 + :return:对目标对象所有datetime类型的属性格式化 + """ + for attr in dir(obj): + value = getattr(obj, attr) + if isinstance(value, datetime): + setattr(obj, attr, value.strftime('%Y-%m-%d %H:%M:%S')) + return obj + + +def list_format_datetime(lst: list[Any]) -> list[Any]: + """ + :param lst: 输入一个嵌套对象的列表 + :return: 对目标列表中所有对象的datetime类型的属性格式化 + """ + for obj in lst: + for attr in dir(obj): + value = getattr(obj, attr) + if isinstance(value, datetime): + setattr(obj, attr, value.strftime('%Y-%m-%d %H:%M:%S')) + return lst + + +def format_datetime_dict_list(dicts: list[dict]) -> list[dict]: + """ + 递归遍历嵌套字典,并将 datetime 值转换为字符串格式 + + :param dicts: 输入一个嵌套字典的列表 + :return: 对目标列表中所有字典的datetime类型的属性格式化 + """ + result = [] + + for item in dicts: + new_item = {} + for k, v in item.items(): + if isinstance(v, dict): + # 递归遍历子字典 + new_item[k] = format_datetime_dict_list([v])[0] + elif isinstance(v, datetime): + # 如果值是 datetime 类型,则格式化为字符串 + new_item[k] = v.strftime('%Y-%m-%d %H:%M:%S') + else: + # 否则保留原始值 + new_item[k] = v + result.append(new_item) + + return result + + +class TimeFormatUtil: + """ + 时间格式化工具类 + """ + + @classmethod + def format_time(cls, time_info: str | datetime, fmt: str = '%Y-%m-%d %H:%M:%S') -> str: + """ + 格式化时间字符串或datetime对象为指定格式 + + :param time_info: 时间字符串或datetime对象 + :param fmt: 格式化格式,默认为'%Y-%m-%d %H:%M:%S' + :return: 格式化后的时间字符串 + """ + if isinstance(time_info, datetime): + format_date = time_info.strftime(fmt) + else: + try: + date = parse(time_info) + format_date = date.strftime(fmt) + except Exception: + format_date = time_info + + return format_date + + @classmethod + def parse_date(cls, time_str: str) -> date | str: + """ + 解析时间字符串提取日期部分 + + :param time_str: 时间字符串 + :return: 日期部分 + """ + try: + dt = parse(time_str) + return dt.date() + except Exception: + return time_str + + @classmethod + def format_time_dict(cls, time_dict: dict, fmt: str = '%Y-%m-%d %H:%M:%S') -> dict: + """ + 格式化时间字典 + + :param time_dict: 时间字典 + :param fmt: 格式化格式,默认为'%Y-%m-%d %H:%M:%S' + :return: 格式化后的时间字典 + """ + copy_time_dict = deepcopy(time_dict) + for k, v in copy_time_dict.items(): + if isinstance(v, (str, datetime)): + copy_time_dict[k] = cls.format_time(v, fmt) + elif isinstance(v, dict): + copy_time_dict[k] = cls.format_time_dict(v, fmt) + elif isinstance(v, list): + copy_time_dict[k] = cls.format_time_list(v, fmt) + else: + copy_time_dict[k] = v + + return copy_time_dict + + @classmethod + def format_time_list(cls, time_list: list, fmt: str = '%Y-%m-%d %H:%M:%S') -> list: + """ + 格式化时间列表 + + :param time_list: 时间列表 + :param fmt: 格式化格式,默认为'%Y-%m-%d %H:%M:%S' + :return: 格式化后的时间列表 + """ + format_time_list = [] + for item in time_list: + if isinstance(item, (str, datetime)): + format_item = cls.format_time(item, fmt) + elif isinstance(item, dict): + format_item = cls.format_time_dict(item, fmt) + elif isinstance(item, list): + format_item = cls.format_time_list(item, fmt) + else: + format_item = item + + format_time_list.append(format_item) + + return format_time_list diff --git a/shierfeng-fastapi-backend/utils/transport_crypto_util.py b/shierfeng-fastapi-backend/utils/transport_crypto_util.py new file mode 100644 index 0000000..7b53197 --- /dev/null +++ b/shierfeng-fastapi-backend/utils/transport_crypto_util.py @@ -0,0 +1,1284 @@ +import base64 +import json +import os +import time +from collections import Counter, defaultdict, deque +from dataclasses import dataclass +from datetime import datetime +from threading import Lock +from typing import Any + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from fastapi import FastAPI, Request +from redis import asyncio as aioredis + +from config.env import AppConfig, TransportCryptoConfig +from utils.log_util import logger + + +# 通用编码辅助 +def _urlsafe_b64encode(data: bytes) -> str: + """ + 将字节串编码为URL安全的Base64字符串 + + :param data: 原始字节串 + :return: URL安全的Base64字符串 + """ + return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=') + + +def _urlsafe_b64decode(data: str) -> bytes: + """ + 将URL安全的Base64字符串解码为字节串 + + :param data: URL安全的Base64字符串 + :return: 解码后的字节串 + """ + padding_length = (-len(data)) % 4 + return base64.urlsafe_b64decode(f'{data}{"=" * padding_length}'.encode()) + + +@dataclass(frozen=True) +class TransportKeyPair: + """ + 传输层密钥对载体 + + kid: 密钥版本标识 + private_key_pem: PEM格式私钥 + public_key_pem: PEM格式公钥 + """ + + kid: str + private_key_pem: str + public_key_pem: str + + +# 传输层数据载体 +@dataclass(frozen=True) +class DecryptedTransportEnvelope: + """ + 请求信封解密结果 + + kid: 请求使用的密钥版本标识 + nonce: 请求随机数 + timestamp: 请求时间戳 + aes_key: 当前请求协商出的AES会话密钥 + aad: 通过校验后的AAD上下文 + plaintext: 解密得到的原始请求载荷 + """ + + kid: str + nonce: str + timestamp: int + aes_key: bytes + aad: dict[str, str] + plaintext: bytes + + +# 传输层密钥管理 +class TransportKeyProvider: + """ + 传输层密钥提供者 + """ + + _lock = Lock() + _key_pairs: dict[str, TransportKeyPair] | None = None + _MIN_RSA_KEY_SIZE = 2048 + _RSA_KEY_SIZE_STEP = 256 + + @classmethod + def validate_runtime_configuration(cls) -> None: + """ + 校验传输层加解密运行配置,确保启用时显式配置密钥对 + + :return: None + """ + if not TransportCryptoConfig.transport_crypto_enabled or TransportCryptoConfig.transport_crypto_mode == 'off': + return + + configured_private_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_private_key) + configured_public_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_public_key) + rsa_key_size = TransportCryptoConfig.transport_crypto_rsa_key_size + + if rsa_key_size < cls._MIN_RSA_KEY_SIZE or rsa_key_size % cls._RSA_KEY_SIZE_STEP != 0: + raise ValueError('TRANSPORT_CRYPTO_RSA_KEY_SIZE必须大于等于2048,且为256的整数倍') + + if not configured_private_key or not configured_public_key: + raise ValueError( + '启用传输层加解密时,必须显式配置TRANSPORT_CRYPTO_PUBLIC_KEY和TRANSPORT_CRYPTO_PRIVATE_KEY' + ) + + private_key = serialization.load_pem_private_key(configured_private_key.encode('utf-8'), password=None) + derived_public_key = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) + if cls._normalize_pem(derived_public_key) != configured_public_key: + raise ValueError('TRANSPORT_CRYPTO_PUBLIC_KEY与TRANSPORT_CRYPTO_PRIVATE_KEY不匹配') + + if TransportCryptoConfig.transport_crypto_legacy_key_pairs: + cls._build_legacy_key_pairs() + + @classmethod + def get_current_key_pair(cls) -> TransportKeyPair: + """ + 获取当前启用的密钥对 + + :return: 当前启用的密钥对 + """ + if cls._key_pairs is None: + with cls._lock: + if cls._key_pairs is None: + cls._key_pairs = cls._build_key_pairs() + + return cls._key_pairs[TransportCryptoConfig.transport_crypto_kid] + + @classmethod + def get_current_kid(cls) -> str: + """ + 获取当前启用的密钥标识 + + :return: 当前启用的密钥标识 + """ + return cls.get_current_key_pair().kid + + @classmethod + def get_public_key_pem(cls, kid: str | None = None) -> str: + """ + 获取公钥PEM + + :param kid: 密钥版本标识,未传入时默认使用当前版本 + :return: PEM格式公钥字符串 + """ + return cls.get_key_pair(kid).public_key_pem + + @classmethod + def get_private_key_pem(cls, kid: str | None = None) -> str: + """ + 获取私钥PEM + + :param kid: 密钥版本标识,未传入时默认使用当前版本 + :return: PEM格式私钥字符串 + """ + return cls.get_key_pair(kid).private_key_pem + + @classmethod + def get_key_pair(cls, kid: str | None = None) -> TransportKeyPair: + """ + 根据kid获取密钥对,未传入时返回当前密钥对 + + :param kid: 密钥版本标识,未传入时默认使用当前版本 + :return: 匹配到的密钥对 + """ + target_kid = kid or cls.get_current_kid() + if cls._key_pairs is None: + with cls._lock: + if cls._key_pairs is None: + cls._key_pairs = cls._build_key_pairs() + key_pair = cls._key_pairs.get(target_kid) + if key_pair is None: + raise ValueError('密钥版本不存在') + return key_pair + + @classmethod + def get_supported_kids(cls) -> tuple[str, ...]: + """ + 获取当前支持解密的全部密钥版本 + + :return: 当前支持解密的密钥版本元组 + """ + if cls._key_pairs is None: + cls.get_current_key_pair() + return tuple(cls._key_pairs.keys()) + + @classmethod + def _build_key_pairs(cls) -> dict[str, TransportKeyPair]: + """ + 构建当前进程可用的全部密钥对映射 + + :return: 以kid为键的密钥对映射 + """ + configured_private_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_private_key) + configured_public_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_public_key) + kid = TransportCryptoConfig.transport_crypto_kid + + if not configured_private_key or not configured_public_key: + raise ValueError( + '启用传输层加解密时,必须显式配置TRANSPORT_CRYPTO_PUBLIC_KEY和TRANSPORT_CRYPTO_PRIVATE_KEY' + ) + + key_pairs = { + kid: TransportKeyPair(kid=kid, private_key_pem=configured_private_key, public_key_pem=configured_public_key) + } + key_pairs.update(cls._build_legacy_key_pairs()) + return key_pairs + + @classmethod + def _build_legacy_key_pairs(cls) -> dict[str, TransportKeyPair]: + """ + 构建历史密钥对映射,用于密钥轮换窗口内的兼容解密 + + :return: 以kid为键的历史密钥对映射 + """ + legacy_key_pairs: dict[str, TransportKeyPair] = {} + configured_legacy_key_pairs = TransportCryptoConfig.transport_crypto_legacy_key_pairs + if not configured_legacy_key_pairs: + return legacy_key_pairs + + try: + parsed_key_pairs = json.loads(configured_legacy_key_pairs) + except json.JSONDecodeError as exc: + raise ValueError('传输层历史密钥配置不是合法JSON') from exc + + if not isinstance(parsed_key_pairs, list): + raise ValueError('传输层历史密钥配置必须是JSON数组') + + for item in parsed_key_pairs: + if not isinstance(item, dict): + raise ValueError('传输层历史密钥项必须是JSON对象') + item_kid = item.get('kid') + private_key_pem = cls._normalize_pem(item.get('privateKey') or item.get('private_key') or '') + public_key_pem = cls._normalize_pem(item.get('publicKey') or item.get('public_key') or '') + if not item_kid or not private_key_pem: + raise ValueError('传输层历史密钥项必须包含kid和privateKey') + if not public_key_pem: + private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None) + public_key_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) + legacy_key_pairs[str(item_kid)] = TransportKeyPair( + kid=str(item_kid), + private_key_pem=private_key_pem, + public_key_pem=public_key_pem, + ) + + return legacy_key_pairs + + @staticmethod + def _normalize_pem(pem_value: str) -> str: + """ + 兼容环境变量中的换行转义 + + :param pem_value: 原始PEM字符串 + :return: 标准化后的PEM字符串 + """ + return pem_value.replace('\\n', '\n').strip() if pem_value else '' + + +# 传输层安全校验 +class TransportSecurityUtil: + """ + 传输层安全校验工具 + """ + + @classmethod + def validate_timestamp(cls, timestamp: int) -> None: + """ + 校验请求时间窗 + + :param timestamp: 请求信封中的时间戳 + :return: None + """ + now_timestamp = int(time.time()) + if abs(now_timestamp - timestamp) > TransportCryptoConfig.transport_crypto_clock_skew_seconds: + logger.warning( + '传输层加密请求时间窗校验失败,request_ts={}, now_ts={}, allowed_skew={}', + timestamp, + now_timestamp, + TransportCryptoConfig.transport_crypto_clock_skew_seconds, + ) + raise ValueError('加密请求已过期,请刷新页面后重试') + + @classmethod + async def validate_replay(cls, request: Request, kid: str, nonce: str) -> None: + """ + 使用Redis进行防重放校验 + + :param request: 当前请求对象 + :param kid: 当前密钥版本标识 + :param nonce: 当前请求随机数 + :return: None + """ + redis = getattr(request.app.state, 'redis', None) + if redis is None: + if cls._should_fail_closed_when_replay_check_unavailable(request): + logger.error('Redis未初始化,当前请求要求严格防重放校验,已拒绝请求') + raise ValueError('服务端防重放校验不可用,请稍后重试') + logger.warning('Redis未初始化,已跳过传输层防重放校验') + return + + replay_key = f'transport:replay:{kid}:{nonce}' + try: + is_success = await redis.set( + replay_key, '1', ex=TransportCryptoConfig.transport_crypto_replay_ttl_seconds, nx=True + ) + except Exception as exc: + if cls._should_fail_closed_when_replay_check_unavailable(request): + logger.error('Redis防重放校验执行失败,当前请求要求严格校验,error={}', exc) + raise ValueError('服务端防重放校验不可用,请稍后重试') from exc + logger.warning('Redis防重放校验执行失败,已跳过当前请求的防重放校验,error={}', exc) + return + if not is_success: + logger.warning('传输层加密请求检测到重放,kid={}, nonce={}', kid, nonce) + raise ValueError('检测到重复请求,请勿重放加密报文') + + @classmethod + def _should_fail_closed_when_replay_check_unavailable(cls, request: Request) -> bool: + """ + 判断当前请求在防重放能力不可用时是否需要直接拒绝 + + :param request: 当前请求对象 + :return: 是否需要失败关闭 + """ + if TransportCryptoConfig.transport_crypto_mode == 'required': + return True + current_path = cls._normalize_path(str(request.scope.get('path', ''))) + return cls._is_required_path(current_path) + + @staticmethod + def _normalize_path(path: str) -> str: + """ + 标准化请求路径,剥离应用根路径前缀 + + :param path: 原始请求路径 + :return: 标准化后的业务路径 + """ + app_root_path = AppConfig.app_root_path + if app_root_path and path.startswith(app_root_path): + normalized_path = path[len(app_root_path) :] + return normalized_path or '/' + return path or '/' + + @staticmethod + def _is_required_path(path: str) -> bool: + """ + 判断当前路径是否命中强制加密路径配置 + + :param path: 当前请求路径 + :return: 是否命中强制加密路径 + """ + required_paths = [ + required_path.strip() + for required_path in TransportCryptoConfig.transport_crypto_required_paths.split(',') + if required_path.strip() + ] + if not required_paths: + return False + return any(path == required_path or path.startswith(f'{required_path}/') for required_path in required_paths) + + +# 传输层加解密核心能力 +class TransportCryptoUtil: + """ + 传输层加解密工具 + """ + + _ENVELOPE_VERSION = '1' + _RESPONSE_ENVELOPE_ALGORITHM = 'AES_256_GCM' + _REQUIRED_ENVELOPE_FIELDS = ('kid', 'ts', 'nonce', 'ek', 'iv', 'ct', 'aad') + + @classmethod + def get_response_envelope_algorithm(cls) -> str: + """ + 获取响应信封算法标识 + + :return: 响应信封算法标识 + """ + return cls._RESPONSE_ENVELOPE_ALGORITHM + + @classmethod + def decrypt_envelope( + cls, + envelope: dict[str, Any], + expected_method: str, + expected_path: str, + ) -> DecryptedTransportEnvelope: + """ + 解密请求信封 + + :param envelope: 请求加密信封 + :param expected_method: 当前请求预期HTTP方法 + :param expected_path: 当前请求预期路径 + :return: 解密后的请求信封对象 + """ + cls._validate_envelope(envelope) + kid = str(envelope['kid']) + aad = cls._extract_and_validate_aad(envelope, expected_method, expected_path) + aes_key = cls.decrypt_request_key(envelope) + iv = _urlsafe_b64decode(str(envelope['iv'])) + ciphertext = _urlsafe_b64decode(str(envelope['ct'])) + plaintext = AESGCM(aes_key).decrypt(iv, ciphertext, cls._build_aad_bytes(aad)) + + return DecryptedTransportEnvelope( + kid=kid, + nonce=str(envelope['nonce']), + timestamp=int(envelope['ts']), + aes_key=aes_key, + aad=aad, + plaintext=plaintext, + ) + + @classmethod + def decrypt_request_key(cls, envelope: dict[str, Any]) -> bytes: + """ + 仅解出请求中的AES会话密钥,用于异常场景构造加密错误响应 + + :param envelope: 请求加密信封 + :return: 请求协商出的AES会话密钥 + """ + kid = str(envelope['kid']) + private_key_pem = TransportKeyProvider.get_private_key_pem(kid) + private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None) + encrypted_key = _urlsafe_b64decode(str(envelope['ek'])) + return private_key.decrypt( + encrypted_key, + padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None), + ) + + @classmethod + def encrypt_response_body( + cls, + aes_key: bytes, + payload: bytes, + kid: str, + method: str, + path: str, + ) -> bytes: + """ + 使用请求协商出的AES密钥加密响应体 + + :param aes_key: 请求协商出的AES会话密钥 + :param payload: 需要加密的响应体字节串 + :param kid: 当前使用的密钥版本标识 + :param method: 当前HTTP请求方法 + :param path: 当前HTTP请求路径 + :return: 加密后的响应体字节串 + """ + iv = os.urandom(12) + aad = {'method': method.upper(), 'path': path, 'direction': 'response'} + ciphertext = AESGCM(aes_key).encrypt(iv, payload, cls._build_aad_bytes(aad)) + encrypted_payload = { + 'v': cls._ENVELOPE_VERSION, + 'kid': kid, + 'alg': cls._RESPONSE_ENVELOPE_ALGORITHM, + 'aad': aad, + 'iv': _urlsafe_b64encode(iv), + 'ct': _urlsafe_b64encode(ciphertext), + } + return json.dumps(encrypted_payload, ensure_ascii=False).encode('utf-8') + + @classmethod + def decode_query_envelope(cls, encrypted_query: str) -> dict[str, Any]: + """ + 解码查询参数中的加密信封 + + :param encrypted_query: 查询参数中的加密信封字符串 + :return: 解码后的信封字典 + """ + decoded_query = _urlsafe_b64decode(encrypted_query).decode('utf-8') + return json.loads(decoded_query) + + @classmethod + def build_public_key_payload(cls) -> dict[str, Any]: + """ + 构建公钥下发载荷 + + :return: 公钥下发载荷字典 + """ + return { + 'kid': TransportKeyProvider.get_current_kid(), + 'envelopeVersion': cls._ENVELOPE_VERSION, + 'alg': TransportCryptoConfig.transport_crypto_algorithm, + 'publicKey': TransportKeyProvider.get_public_key_pem(), + 'supportedKids': TransportKeyProvider.get_supported_kids(), + 'expireAt': int(time.time()) + TransportCryptoConfig.transport_crypto_public_key_ttl_seconds, + } + + @classmethod + def build_frontend_config_payload(cls) -> dict[str, Any]: + """ + 构建前端传输层加解密运行配置载荷 + + :return: 前端传输层加解密运行配置载荷字典 + """ + transport_crypto_active = ( + TransportCryptoConfig.transport_crypto_enabled and TransportCryptoConfig.transport_crypto_mode != 'off' + ) + return { + 'transportCryptoEnabled': TransportCryptoConfig.transport_crypto_enabled, + 'transportCryptoMode': TransportCryptoConfig.transport_crypto_mode, + 'transportCryptoActive': transport_crypto_active, + 'envelopeVersion': cls._ENVELOPE_VERSION, + 'publicKeyUrl': '/transport/crypto/public-key', + 'requestEnvelopeAlgorithm': TransportCryptoConfig.transport_crypto_algorithm, + 'responseEnvelopeAlgorithm': cls.get_response_envelope_algorithm(), + 'enabledPaths': cls._split_paths(TransportCryptoConfig.transport_crypto_enabled_paths), + 'requiredPaths': cls._split_paths(TransportCryptoConfig.transport_crypto_required_paths), + 'excludePaths': cls._split_paths(TransportCryptoConfig.transport_crypto_exclude_paths), + 'maxEncryptedGetUrlLength': TransportCryptoConfig.transport_crypto_max_get_url_length, + 'configExpireAt': int(time.time()) + TransportCryptoConfig.transport_crypto_frontend_config_ttl_seconds, + } + + @classmethod + def _validate_envelope(cls, envelope: dict[str, Any]) -> None: + """ + 校验请求加密信封的结构、协议版本与算法是否有效 + + :param envelope: 请求加密信封 + :return: None + """ + if not isinstance(envelope, dict): + raise ValueError('加密请求信封格式不合法') + + missing_fields = [field_name for field_name in cls._REQUIRED_ENVELOPE_FIELDS if not envelope.get(field_name)] + if missing_fields: + raise ValueError(f'加密请求缺少必要字段: {",".join(missing_fields)}') + + if str(envelope.get('v', '')) != cls._ENVELOPE_VERSION: + raise ValueError('加密请求协议版本不受支持') + + if str(envelope.get('alg', '')) != TransportCryptoConfig.transport_crypto_algorithm: + raise ValueError('加密请求算法不受支持') + + @classmethod + def _extract_and_validate_aad( + cls, + envelope: dict[str, Any], + expected_method: str, + expected_path: str, + ) -> dict[str, str]: + """ + 提取并校验请求AAD,确保密文与当前接口绑定 + + :param envelope: 请求加密信封 + :param expected_method: 当前请求预期HTTP方法 + :param expected_path: 当前请求预期路径 + :return: 归一化后的AAD字典 + """ + aad = envelope.get('aad') + if not isinstance(aad, dict): + raise ValueError('加密请求缺少合法的aad') + + method = str(aad.get('method', '')).upper() + path = str(aad.get('path', '')) + if method != expected_method.upper() or path != expected_path: + raise ValueError('加密请求的method/path与当前接口不匹配') + + return {'method': method, 'path': path} + + @staticmethod + def _build_aad_bytes(aad: dict[str, str]) -> bytes: + """ + 将AAD字典序列化为AES-GCM additionalData所需字节串 + + :param aad: AAD字典 + :return: 序列化后的AAD字节串 + """ + return json.dumps(aad, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + + @staticmethod + def _split_paths(path_value: str) -> list[str]: + """ + 将逗号分隔的路径配置拆分为列表 + + :param path_value: 原始路径配置 + :return: 路径列表 + """ + return [path.strip() for path in path_value.split(',') if path.strip()] + + +# 传输层监控读写与聚合 +class TransportCryptoMonitorUtil: + """ + 传输层加解密监控工具 + """ + + _REDIS_KEY_PREFIX = 'transport:monitor' + _META_STARTED_AT_KEY = f'{_REDIS_KEY_PREFIX}:started_at' + _COUNTERS_KEY = f'{_REDIS_KEY_PREFIX}:counters' + _FAILURE_REASONS_KEY = f'{_REDIS_KEY_PREFIX}:failure_reasons' + _KIDS_KEY = f'{_REDIS_KEY_PREFIX}:kids' + _RECENT_FAILURES_KEY = f'{_REDIS_KEY_PREFIX}:recent_failures' + _RECENT_FAILURE_LIMIT = 20 + _REDIS_WARNING_INTERVAL_SECONDS = 60 + _lock = Lock() + _started_at = datetime.now() + _counters: Counter[str] = Counter() + _failure_reasons: Counter[str] = Counter() + _kid_counters: defaultdict[str, Counter[str]] = defaultdict(Counter) + _recent_failures: deque[dict[str, Any]] = deque(maxlen=_RECENT_FAILURE_LIMIT) + _last_redis_warning_at = 0.0 + + # 对外暴露的监控记录与查询入口 + @classmethod + async def record_plain_request(cls, app: FastAPI | None = None) -> None: + """ + 记录明文请求 + + :param app: FastAPI应用对象 + :return: None + """ + if await cls._write_redis_counters( + app, + counter_updates={ + 'requests_total': 1, + 'plain_requests_total': 1, + }, + ): + return + cls._record_plain_request_local() + + @classmethod + async def record_encrypted_request(cls, app: FastAPI | None = None, kid: str | None = None) -> None: + """ + 记录加密请求 + + :param app: FastAPI应用对象 + :param kid: 当前请求使用的密钥版本 + :return: None + """ + if await cls._write_redis_counters( + app, + counter_updates={ + 'requests_total': 1, + 'encrypted_requests_total': 1, + }, + kid=kid, + kid_counter_updates={'encrypted_requests_total': 1}, + ): + return + cls._record_encrypted_request_local(kid) + + @classmethod + async def record_required_rejected(cls, app: FastAPI | None = None, method: str = '', path: str = '') -> None: + """ + 记录强制加密接口被明文访问的拒绝事件 + + :param app: FastAPI应用对象 + :param method: 请求方法 + :param path: 请求路径 + :return: None + """ + if await cls._write_redis_failure( + app, + method=method, + path=path, + reason='required_missing', + include_decrypt_failure=False, + ): + return + cls._record_failure_local(method, path, 'required_missing', include_decrypt_failure=False) + + @classmethod + async def record_decrypt_success(cls, app: FastAPI | None = None, kid: str | None = None) -> None: + """ + 记录请求解密成功事件 + + :param app: FastAPI应用对象 + :param kid: 当前请求使用的密钥版本 + :return: None + """ + if await cls._write_redis_counters( + app, + counter_updates={'decrypt_success_total': 1}, + kid=kid, + kid_counter_updates={'decrypt_success_total': 1}, + ): + return + cls._record_decrypt_success_local(kid) + + @classmethod + async def record_decrypt_failure( + cls, + app: FastAPI | None = None, + method: str = '', + path: str = '', + reason: str = '', + kid: str | None = None, + ) -> None: + """ + 记录请求解密失败事件 + + :param app: FastAPI应用对象 + :param method: 请求方法 + :param path: 请求路径 + :param reason: 失败原因分类 + :param kid: 当前请求使用的密钥版本 + :return: None + """ + if await cls._write_redis_failure(app, method=method, path=path, reason=reason, kid=kid): + return + cls._record_failure_local(method, path, reason, kid=kid) + + @classmethod + async def record_plain_response(cls, app: FastAPI | None = None) -> None: + """ + 记录明文响应 + + :param app: FastAPI应用对象 + :return: None + """ + if await cls._write_redis_counters(app, counter_updates={'plain_responses_total': 1}): + return + cls._record_plain_response_local() + + @classmethod + async def record_encrypted_response( + cls, + app: FastAPI | None = None, + kid: str | None = None, + is_error: bool = False, + ) -> None: + """ + 记录加密响应 + + :param app: FastAPI应用对象 + :param kid: 当前响应使用的密钥版本 + :param is_error: 是否为错误响应 + :return: None + """ + counter_updates = {'encrypted_responses_total': 1} + if is_error: + counter_updates['encrypted_error_responses_total'] = 1 + if await cls._write_redis_counters( + app, + counter_updates=counter_updates, + kid=kid, + kid_counter_updates={'encrypted_responses_total': 1}, + ): + return + cls._record_encrypted_response_local(kid, is_error) + + @classmethod + async def get_snapshot(cls, app: FastAPI | None = None) -> dict[str, Any]: + """ + 获取传输层加解密监控快照 + + :param app: FastAPI应用对象 + :return: 监控快照字典 + """ + redis_snapshot = await cls._get_redis_snapshot(app) + local_snapshot = cls._get_local_snapshot_parts() + snapshot_parts = cls._merge_snapshot_parts(redis_snapshot, local_snapshot) + return cls._build_snapshot(snapshot_parts) + + # Redis 聚合写入与读取 + @classmethod + async def _write_redis_counters( + cls, + app: FastAPI | None, + counter_updates: dict[str, int], + kid: str | None = None, + kid_counter_updates: dict[str, int] | None = None, + ) -> bool: + """ + 将监控计数写入Redis + + :param app: FastAPI应用对象 + :param counter_updates: 全局计数增量 + :param kid: 当前密钥版本 + :param kid_counter_updates: 按密钥版本统计的增量 + :return: 是否写入成功 + """ + redis = cls._get_redis_client(app) + if redis is None: + return False + try: + async with redis.pipeline(transaction=False) as pipe: + pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True) + for counter_name, delta in counter_updates.items(): + pipe.hincrby(cls._COUNTERS_KEY, counter_name, delta) + if kid and kid_counter_updates: + pipe.sadd(cls._KIDS_KEY, kid) + kid_counter_key = cls._build_kid_counter_key(kid) + for counter_name, delta in kid_counter_updates.items(): + pipe.hincrby(kid_counter_key, counter_name, delta) + await pipe.execute() + return True + except Exception as exc: + cls._log_redis_warning('write_counters', exc) + return False + + @classmethod + async def _write_redis_failure( + cls, + app: FastAPI | None, + method: str, + path: str, + reason: str, + kid: str | None = None, + include_decrypt_failure: bool = True, + ) -> bool: + """ + 将失败事件写入Redis + + :param app: FastAPI应用对象 + :param method: 请求方法 + :param path: 请求路径 + :param reason: 失败原因分类 + :param kid: 当前请求使用的密钥版本 + :param include_decrypt_failure: 是否计入解密失败次数 + :return: 是否写入成功 + """ + redis = cls._get_redis_client(app) + if redis is None: + return False + try: + recent_failure = json.dumps( + { + 'time': datetime.now().isoformat(), + 'method': method, + 'path': path, + 'reason': reason, + 'kid': kid, + }, + ensure_ascii=False, + ) + async with redis.pipeline(transaction=False) as pipe: + pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True) + if include_decrypt_failure: + pipe.hincrby(cls._COUNTERS_KEY, 'decrypt_failure_total', 1) + if reason == 'required_missing': + pipe.hincrby(cls._COUNTERS_KEY, 'required_rejected_total', 1) + pipe.hincrby(cls._FAILURE_REASONS_KEY, reason, 1) + pipe.lpush(cls._RECENT_FAILURES_KEY, recent_failure) + pipe.ltrim(cls._RECENT_FAILURES_KEY, 0, cls._RECENT_FAILURE_LIMIT - 1) + if kid: + pipe.sadd(cls._KIDS_KEY, kid) + pipe.hincrby(cls._build_kid_counter_key(kid), 'decrypt_failure_total', 1) + await pipe.execute() + return True + except Exception as exc: + cls._log_redis_warning('write_failure', exc) + return False + + @classmethod + async def _get_redis_snapshot(cls, app: FastAPI | None) -> dict[str, Any]: + """ + 从Redis中读取监控快照 + + :param app: FastAPI应用对象 + :return: Redis监控快照字典 + """ + redis = cls._get_redis_client(app) + if redis is None: + return { + 'monitor_scope': 'process-local-fallback', + 'started_at': cls._started_at, + 'counters': {}, + 'failure_reasons': {}, + 'kid_stats': [], + 'recent_failures': [], + } + try: + async with redis.pipeline(transaction=False) as pipe: + pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True) + pipe.get(cls._META_STARTED_AT_KEY) + pipe.hgetall(cls._COUNTERS_KEY) + pipe.hgetall(cls._FAILURE_REASONS_KEY) + pipe.lrange(cls._RECENT_FAILURES_KEY, 0, cls._RECENT_FAILURE_LIMIT - 1) + pipe.smembers(cls._KIDS_KEY) + _, started_at_raw, counters_raw, failure_reasons_raw, recent_failures_raw, kids = await pipe.execute() + kid_stats = await cls._get_redis_kid_stats(redis, sorted(kids)) + return { + 'monitor_scope': 'redis-aggregated', + 'started_at': cls._parse_datetime(started_at_raw) or cls._started_at, + 'counters': cls._to_int_mapping(counters_raw), + 'failure_reasons': cls._to_int_mapping(failure_reasons_raw), + 'kid_stats': kid_stats, + 'recent_failures': cls._parse_recent_failures(recent_failures_raw), + } + except Exception as exc: + cls._log_redis_warning('read_snapshot', exc) + return { + 'monitor_scope': 'process-local-fallback', + 'started_at': cls._started_at, + 'counters': {}, + 'failure_reasons': {}, + 'kid_stats': [], + 'recent_failures': [], + } + + @classmethod + async def _get_redis_kid_stats(cls, redis: aioredis.Redis, kids: list[str]) -> list[dict[str, Any]]: + """ + 获取Redis中的按密钥版本聚合统计 + + :param redis: Redis客户端 + :param kids: 密钥版本列表 + :return: 按密钥版本统计列表 + """ + if not kids: + return [] + async with redis.pipeline(transaction=False) as pipe: + for kid in kids: + pipe.hgetall(cls._build_kid_counter_key(kid)) + kid_counter_rows = await pipe.execute() + return [ + { + 'kid': kid, + 'encryptedRequests': cls._to_int_mapping(kid_counter).get('encrypted_requests_total', 0), + 'decryptSuccess': cls._to_int_mapping(kid_counter).get('decrypt_success_total', 0), + 'decryptFailure': cls._to_int_mapping(kid_counter).get('decrypt_failure_total', 0), + 'encryptedResponses': cls._to_int_mapping(kid_counter).get('encrypted_responses_total', 0), + } + for kid, kid_counter in zip(kids, kid_counter_rows, strict=False) + ] + + # 进程内回退统计 + @classmethod + def _record_plain_request_local(cls) -> None: + """ + 在本地内存中记录明文请求 + + :return: None + """ + with cls._lock: + cls._counters['requests_total'] += 1 + cls._counters['plain_requests_total'] += 1 + + @classmethod + def _record_encrypted_request_local(cls, kid: str | None = None) -> None: + """ + 在本地内存中记录加密请求 + + :param kid: 当前请求使用的密钥版本 + :return: None + """ + with cls._lock: + cls._counters['requests_total'] += 1 + cls._counters['encrypted_requests_total'] += 1 + cls._increase_kid_counter_local(kid, 'encrypted_requests_total') + + @classmethod + def _record_decrypt_success_local(cls, kid: str | None = None) -> None: + """ + 在本地内存中记录解密成功事件 + + :param kid: 当前请求使用的密钥版本 + :return: None + """ + with cls._lock: + cls._counters['decrypt_success_total'] += 1 + cls._increase_kid_counter_local(kid, 'decrypt_success_total') + + @classmethod + def _record_plain_response_local(cls) -> None: + """ + 在本地内存中记录明文响应 + + :return: None + """ + with cls._lock: + cls._counters['plain_responses_total'] += 1 + + @classmethod + def _record_encrypted_response_local(cls, kid: str | None = None, is_error: bool = False) -> None: + """ + 在本地内存中记录加密响应 + + :param kid: 当前响应使用的密钥版本 + :param is_error: 是否为错误响应 + :return: None + """ + with cls._lock: + cls._counters['encrypted_responses_total'] += 1 + if is_error: + cls._counters['encrypted_error_responses_total'] += 1 + cls._increase_kid_counter_local(kid, 'encrypted_responses_total') + + @classmethod + def _record_failure_local( + cls, + method: str, + path: str, + reason: str, + kid: str | None = None, + include_decrypt_failure: bool = True, + ) -> None: + """ + 在本地内存中记录失败事件 + + :param method: 请求方法 + :param path: 请求路径 + :param reason: 失败原因分类 + :param kid: 当前请求使用的密钥版本 + :param include_decrypt_failure: 是否计入解密失败次数 + :return: None + """ + with cls._lock: + if include_decrypt_failure: + cls._counters['decrypt_failure_total'] += 1 + if reason == 'required_missing': + cls._counters['required_rejected_total'] += 1 + cls._failure_reasons[reason] += 1 + cls._increase_kid_counter_local(kid, 'decrypt_failure_total') + cls._recent_failures.appendleft( + { + 'time': datetime.now(), + 'method': method, + 'path': path, + 'reason': reason, + 'kid': kid, + } + ) + + @classmethod + def _get_local_snapshot_parts(cls) -> dict[str, Any]: + """ + 获取本地内存中的监控快照片段 + + :return: 本地监控快照片段 + """ + with cls._lock: + return { + 'monitor_scope': 'process-local-fallback', + 'started_at': cls._started_at, + 'counters': dict(cls._counters), + 'failure_reasons': dict(cls._failure_reasons), + 'kid_stats': [ + { + 'kid': kid, + 'encryptedRequests': kid_counter.get('encrypted_requests_total', 0), + 'decryptSuccess': kid_counter.get('decrypt_success_total', 0), + 'decryptFailure': kid_counter.get('decrypt_failure_total', 0), + 'encryptedResponses': kid_counter.get('encrypted_responses_total', 0), + } + for kid, kid_counter in sorted(cls._kid_counters.items(), key=lambda item: item[0]) + ], + 'recent_failures': list(cls._recent_failures), + } + + @classmethod + def _merge_snapshot_parts(cls, redis_snapshot: dict[str, Any], local_snapshot: dict[str, Any]) -> dict[str, Any]: + """ + 合并Redis统计与本地回退统计 + + :param redis_snapshot: Redis监控快照片段 + :param local_snapshot: 本地监控快照片段 + :return: 合并后的监控快照片段 + """ + merged_counters = Counter(redis_snapshot['counters']) + merged_counters.update(local_snapshot['counters']) + + merged_failure_reasons = Counter(redis_snapshot['failure_reasons']) + merged_failure_reasons.update(local_snapshot['failure_reasons']) + + merged_kid_stats: dict[str, dict[str, Any]] = {} + for kid_stat in redis_snapshot['kid_stats'] + local_snapshot['kid_stats']: + kid = kid_stat.get('kid') + if not kid: + continue + merged_kid_stat = merged_kid_stats.setdefault( + kid, + { + 'kid': kid, + 'encryptedRequests': 0, + 'decryptSuccess': 0, + 'decryptFailure': 0, + 'encryptedResponses': 0, + }, + ) + merged_kid_stat['encryptedRequests'] += int(kid_stat.get('encryptedRequests', 0) or 0) + merged_kid_stat['decryptSuccess'] += int(kid_stat.get('decryptSuccess', 0) or 0) + merged_kid_stat['decryptFailure'] += int(kid_stat.get('decryptFailure', 0) or 0) + merged_kid_stat['encryptedResponses'] += int(kid_stat.get('encryptedResponses', 0) or 0) + + combined_failures = redis_snapshot['recent_failures'] + local_snapshot['recent_failures'] + combined_failures.sort( + key=lambda item: cls._coerce_datetime_for_sort(item.get('time')), + reverse=True, + ) + + monitor_scope = redis_snapshot['monitor_scope'] + if monitor_scope == 'redis-aggregated' and cls._has_local_fallback_data(local_snapshot): + monitor_scope = 'redis-aggregated+local-fallback' + + return { + 'monitor_scope': monitor_scope, + 'started_at': min(redis_snapshot['started_at'], local_snapshot['started_at']), + 'counters': dict(merged_counters), + 'failure_reasons': dict(merged_failure_reasons), + 'kid_stats': sorted(merged_kid_stats.values(), key=lambda item: item['kid']), + 'recent_failures': combined_failures[: cls._RECENT_FAILURE_LIMIT], + } + + # 快照构建与通用辅助 + @classmethod + def _build_snapshot(cls, snapshot_parts: dict[str, Any]) -> dict[str, Any]: + """ + 基于监控片段构建最终快照 + + :param snapshot_parts: 监控快照片段 + :return: 最终监控快照 + """ + try: + current_kid = TransportKeyProvider.get_current_kid() + supported_kids = TransportKeyProvider.get_supported_kids() + except Exception: + current_kid = '' + supported_kids = [] + + counters = snapshot_parts['counters'] + return { + 'monitorScope': snapshot_parts['monitor_scope'], + 'startedAt': snapshot_parts['started_at'], + 'appEnv': AppConfig.app_env, + 'transportCryptoEnabled': TransportCryptoConfig.transport_crypto_enabled, + 'transportCryptoMode': TransportCryptoConfig.transport_crypto_mode, + 'currentKid': current_kid, + 'supportedKids': supported_kids, + 'enabledPaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_enabled_paths), + 'requiredPaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_required_paths), + 'excludePaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_exclude_paths), + 'requestsTotal': counters.get('requests_total', 0), + 'plainRequestsTotal': counters.get('plain_requests_total', 0), + 'encryptedRequestsTotal': counters.get('encrypted_requests_total', 0), + 'requiredRejectedTotal': counters.get('required_rejected_total', 0), + 'decryptSuccessTotal': counters.get('decrypt_success_total', 0), + 'decryptFailureTotal': counters.get('decrypt_failure_total', 0), + 'plainResponsesTotal': counters.get('plain_responses_total', 0), + 'encryptedResponsesTotal': counters.get('encrypted_responses_total', 0), + 'encryptedErrorResponsesTotal': counters.get('encrypted_error_responses_total', 0), + 'failureReasons': snapshot_parts['failure_reasons'], + 'kidStats': snapshot_parts['kid_stats'], + 'recentFailures': snapshot_parts['recent_failures'], + } + + @classmethod + def _get_redis_client(cls, app: FastAPI | None) -> aioredis.Redis | None: + """ + 获取当前应用中的Redis客户端 + + :param app: FastAPI应用对象 + :return: Redis客户端,不存在时返回None + """ + if app is None: + return None + return getattr(app.state, 'redis', None) + + @classmethod + def _increase_kid_counter_local(cls, kid: str | None, counter_name: str) -> None: + """ + 在本地内存中按密钥版本累加统计值 + + :param kid: 当前密钥版本 + :param counter_name: 统计项名称 + :return: None + """ + if not kid: + return + cls._kid_counters[kid][counter_name] += 1 + + @classmethod + def _log_redis_warning(cls, action: str, exc: Exception) -> None: + """ + 记录Redis监控降级日志,并限制日志频率 + + :param action: 当前执行动作 + :param exc: 异常对象 + :return: None + """ + now = time.monotonic() + with cls._lock: + if now - cls._last_redis_warning_at < cls._REDIS_WARNING_INTERVAL_SECONDS: + return + cls._last_redis_warning_at = now + logger.warning('传输层加解密监控Redis操作失败,已回退为进程内统计,action={}, error={}', action, exc) + + @classmethod + def _has_local_fallback_data(cls, local_snapshot: dict[str, Any]) -> bool: + """ + 判断本地回退统计中是否存在有效数据 + + :param local_snapshot: 本地监控快照片段 + :return: 是否存在有效数据 + """ + if local_snapshot['counters']: + return True + if local_snapshot['failure_reasons']: + return True + if local_snapshot['kid_stats']: + return True + return bool(local_snapshot['recent_failures']) + + @classmethod + def _build_kid_counter_key(cls, kid: str) -> str: + """ + 构建按密钥版本统计的Redis键名 + + :param kid: 密钥版本 + :return: Redis键名 + """ + return f'{cls._REDIS_KEY_PREFIX}:kid:{kid}:counters' + + @classmethod + def _parse_recent_failures(cls, recent_failures: list[str]) -> list[dict[str, Any]]: + """ + 解析Redis中的最近失败记录 + + :param recent_failures: Redis中存储的失败记录列表 + :return: 失败记录对象列表 + """ + parsed_failures: list[dict[str, Any]] = [] + for recent_failure in recent_failures: + try: + recent_failure_item = json.loads(recent_failure) + except json.JSONDecodeError: + continue + if not isinstance(recent_failure_item, dict): + continue + recent_failure_item['time'] = cls._parse_datetime(recent_failure_item.get('time')) + parsed_failures.append(recent_failure_item) + return parsed_failures + + @staticmethod + def _to_int_mapping(mapping: dict[str, Any]) -> dict[str, int]: + """ + 将Redis返回的字符串字典转换为整数字典 + + :param mapping: Redis原始字典 + :return: 转换后的整数字典 + """ + return {str(key): int(value) for key, value in mapping.items()} + + @staticmethod + def _parse_datetime(value: Any) -> datetime | None: + """ + 将字符串时间解析为datetime对象 + + :param value: 原始时间值 + :return: datetime对象,解析失败时返回None + """ + if isinstance(value, datetime): + return value + if not value or not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + @classmethod + def _coerce_datetime_for_sort(cls, value: Any) -> datetime: + """ + 将任意时间值转换为可排序的datetime对象 + + :param value: 原始时间值 + :return: datetime对象 + """ + parsed_datetime = cls._parse_datetime(value) + if parsed_datetime: + return parsed_datetime + return datetime.min diff --git a/shierfeng-fastapi-backend/utils/upload_util.py b/shierfeng-fastapi-backend/utils/upload_util.py new file mode 100644 index 0000000..b751dde --- /dev/null +++ b/shierfeng-fastapi-backend/utils/upload_util.py @@ -0,0 +1,108 @@ +import os +import random +from collections.abc import AsyncGenerator +from datetime import datetime + +import aiofiles +from fastapi import UploadFile + +from config.env import UploadConfig + + +class UploadUtil: + """ + 上传工具类 + """ + + @classmethod + def generate_random_number(cls) -> str: + """ + 生成3位数字构成的字符串 + + :return: 3位数字构成的字符串 + """ + random_number = random.randint(1, 999) + + return f'{random_number:03}' + + @classmethod + def check_file_exists(cls, filepath: str) -> bool: + """ + 检查文件是否存在 + + :param filepath: 文件路径 + :return: 校验结果 + """ + return os.path.exists(filepath) + + @classmethod + def check_file_extension(cls, file: UploadFile) -> bool: + """ + 检查文件后缀是否合法 + + :param file: 文件对象 + :return: 校验结果 + """ + file_extension = file.filename.rsplit('.', 1)[-1] + + return file_extension in UploadConfig.DEFAULT_ALLOWED_EXTENSION + + @classmethod + def check_file_timestamp(cls, filename: str) -> bool: + """ + 校验文件时间戳是否合法 + + :param filename: 文件名称 + :return: 校验结果 + """ + timestamp = ( + filename.rsplit('.', 1)[0].rsplit('_', maxsplit=1)[-1].split(UploadConfig.UPLOAD_MACHINE, maxsplit=1)[0] + ) + try: + datetime.strptime(timestamp, '%Y%m%d%H%M%S') + return True + except ValueError: + return False + + @classmethod + def check_file_machine(cls, filename: str) -> bool: + """ + 校验文件机器码是否合法 + + :param filename: 文件名称 + :return: 校验结果 + """ + return filename.rsplit('.', 1)[0][-4] == UploadConfig.UPLOAD_MACHINE + + @classmethod + def check_file_random_code(cls, filename: str) -> bool: + """ + 校验文件随机码是否合法 + + :param filename: 文件名称 + :return: 校验结果 + """ + valid_code_list = [f'{i:03}' for i in range(1, 999)] + + return filename.rsplit('.', 1)[0][-3:] in valid_code_list + + @classmethod + async def generate_file(cls, filepath: str) -> AsyncGenerator[bytes, None]: + """ + 根据文件生成二进制数据 + + :param filepath: 文件路径 + :yield: 二进制数据 + """ + async with aiofiles.open(filepath, 'rb') as response_file: + async for chunk in response_file: + yield chunk + + @classmethod + def delete_file(cls, filepath: str) -> None: + """ + 根据文件路径删除对应文件 + + :param filepath: 文件路径 + """ + os.remove(filepath) diff --git a/shierfeng-fastapi-frontend/.env.development b/shierfeng-fastapi-frontend/.env.development new file mode 100644 index 0000000..a086d04 --- /dev/null +++ b/shierfeng-fastapi-frontend/.env.development @@ -0,0 +1,8 @@ +# 页面标题 +VITE_APP_TITLE = vfadmin管理系统 + +# 开发环境配置 +VITE_APP_ENV = 'development' + +# vfadmin管理系统/开发环境 +VITE_APP_BASE_API = '/dev-api' diff --git a/shierfeng-fastapi-frontend/.env.docker b/shierfeng-fastapi-frontend/.env.docker new file mode 100644 index 0000000..8487fb4 --- /dev/null +++ b/shierfeng-fastapi-frontend/.env.docker @@ -0,0 +1,11 @@ +# 页面标题 +VITE_APP_TITLE = vfadmin管理系统 + +# 生产环境配置 +VITE_APP_ENV = 'docker' + +# vfadmin管理系统/生产环境 +VITE_APP_BASE_API = '/docker-api' + +# 是否在打包时开启压缩,支持 gzip 和 brotli +VITE_BUILD_COMPRESS = gzip \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/.env.production b/shierfeng-fastapi-frontend/.env.production new file mode 100644 index 0000000..ac80031 --- /dev/null +++ b/shierfeng-fastapi-frontend/.env.production @@ -0,0 +1,11 @@ +# 页面标题 +VITE_APP_TITLE = vfadmin管理系统 + +# 生产环境配置 +VITE_APP_ENV = 'production' + +# vfadmin管理系统/生产环境 +VITE_APP_BASE_API = '/prod-api' + +# 是否在打包时开启压缩,支持 gzip 和 brotli +VITE_BUILD_COMPRESS = gzip \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/.env.staging b/shierfeng-fastapi-frontend/.env.staging new file mode 100644 index 0000000..c3fcf1a --- /dev/null +++ b/shierfeng-fastapi-frontend/.env.staging @@ -0,0 +1,11 @@ +# 页面标题 +VITE_APP_TITLE = vfadmin管理系统 + +# 生产环境配置 +VITE_APP_ENV = 'staging' + +# vfadmin管理系统/生产环境 +VITE_APP_BASE_API = '/stage-api' + +# 是否在打包时开启压缩,支持 gzip 和 brotli +VITE_BUILD_COMPRESS = gzip \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/.gitignore b/shierfeng-fastapi-frontend/.gitignore new file mode 100644 index 0000000..78a752d --- /dev/null +++ b/shierfeng-fastapi-frontend/.gitignore @@ -0,0 +1,23 @@ +.DS_Store +node_modules/ +dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +**/*.log + +tests/**/coverage/ +tests/e2e/reports +selenium-debug.log + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.local + +package-lock.json +yarn.lock diff --git a/shierfeng-fastapi-frontend/Dockerfile b/shierfeng-fastapi-frontend/Dockerfile new file mode 100644 index 0000000..4235ee5 --- /dev/null +++ b/shierfeng-fastapi-frontend/Dockerfile @@ -0,0 +1,28 @@ +# 构建阶段 +FROM node:18-slim AS builder +WORKDIR /app + +# 复制源代码 +COPY . . + +# 设置npm镜像源 +RUN npm config set registry https://registry.npmmirror.com + +# 安装依赖 +RUN npm install + +# 执行docker构建命令 +RUN npm run build:docker + +# 运行阶段 +FROM nginx:latest +WORKDIR /usr/share/nginx/html + +# 复制构建产物 +COPY --from=builder /app/dist . + +# 暴露端口 +EXPOSE 80 + +# 启动nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/LICENSE b/shierfeng-fastapi-frontend/LICENSE new file mode 100644 index 0000000..8564f29 --- /dev/null +++ b/shierfeng-fastapi-frontend/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2018 RuoYi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/bin/build.bat b/shierfeng-fastapi-frontend/bin/build.bat new file mode 100644 index 0000000..ecbb454 --- /dev/null +++ b/shierfeng-fastapi-frontend/bin/build.bat @@ -0,0 +1,12 @@ +@echo off +echo. +echo [Ϣ] Weḅdistļ +echo. + +%~d0 +cd %~dp0 + +cd .. +yarn build:prod + +pause \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/bin/nginx.dockermy.conf b/shierfeng-fastapi-frontend/bin/nginx.dockermy.conf new file mode 100644 index 0000000..adae1dc --- /dev/null +++ b/shierfeng-fastapi-frontend/bin/nginx.dockermy.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # API代理配置(MySQL版本) + location /docker-api/ { + proxy_pass http://shierfeng-backend-my:9099/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/bin/nginx.dockerpg.conf b/shierfeng-fastapi-frontend/bin/nginx.dockerpg.conf new file mode 100644 index 0000000..6b4c14b --- /dev/null +++ b/shierfeng-fastapi-frontend/bin/nginx.dockerpg.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # API代理配置(PostgreSQL版本) + location /docker-api/ { + proxy_pass http://shierfeng-backend-pg:9099/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/bin/package.bat b/shierfeng-fastapi-frontend/bin/package.bat new file mode 100644 index 0000000..f5b24e0 --- /dev/null +++ b/shierfeng-fastapi-frontend/bin/package.bat @@ -0,0 +1,12 @@ +@echo off +echo. +echo [Ϣ] װWeḅnode_modulesļ +echo. + +%~d0 +cd %~dp0 + +cd .. +yarn --registry=https://registry.npmmirror.com + +pause \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/bin/run-web.bat b/shierfeng-fastapi-frontend/bin/run-web.bat new file mode 100644 index 0000000..d2fe397 --- /dev/null +++ b/shierfeng-fastapi-frontend/bin/run-web.bat @@ -0,0 +1,12 @@ +@echo off +echo. +echo [Ϣ] ʹ Vite Web ̡ +echo. + +%~d0 +cd %~dp0 + +cd .. +yarn dev + +pause \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/html/ie.html b/shierfeng-fastapi-frontend/html/ie.html new file mode 100644 index 0000000..052ffcd --- /dev/null +++ b/shierfeng-fastapi-frontend/html/ie.html @@ -0,0 +1,46 @@ + + + + + + 请升级您的浏览器 + + + + + + +

请升级您的浏览器,以便我们更好的为您提供服务!

+

您正在使用 Internet Explorer 的早期版本(IE11以下版本或使用该内核的浏览器)。这意味着在升级浏览器前,您将无法访问此网站。

+
+

请注意:微软公司对Windows XP 及 Internet Explorer 早期版本的支持已经结束

+

自 2016 年 1 月 12 日起,Microsoft 不再为 IE 11 以下版本提供相应支持和更新。没有关键的浏览器安全更新,您的电脑可能易受有害病毒、间谍软件和其他恶意软件的攻击,它们可以窃取或损害您的业务数据和信息。请参阅 微软对 Internet Explorer 早期版本的支持将于 2016 年 1 月 12 日结束的说明

+
+

您可以选择更先进的浏览器

+

推荐使用以下浏览器的最新版本。如果您的电脑已有以下浏览器的最新版本则直接使用该浏览器访问即可。

+ +
+ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/index.html b/shierfeng-fastapi-frontend/index.html new file mode 100644 index 0000000..17457d3 --- /dev/null +++ b/shierfeng-fastapi-frontend/index.html @@ -0,0 +1,215 @@ + + + + + + + + + + %VITE_APP_TITLE% + + + + + +
+
+
+
+
+
正在加载系统资源,请耐心等待
+
+
+ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/package.json b/shierfeng-fastapi-frontend/package.json new file mode 100644 index 0000000..c2e7c5d --- /dev/null +++ b/shierfeng-fastapi-frontend/package.json @@ -0,0 +1,65 @@ +{ + "name": "vfadmin", + "version": "1.9.0", + "description": "vfadmin管理系统", + "author": "insistence", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build:prod": "vite build", + "build:docker": "vite build --mode docker", + "build:stage": "vite build --mode staging", + "preview": "vite preview" + }, + "repository": { + "type": "git", + "url": "https://git.yujiangrubber.cn/niuniu/ShiErFeng.git" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@antv/g2plot": "^2.4.31", + "@antv/infographic": "^0.2.13", + "@element-plus/icons-vue": "2.3.2", + "@vueup/vue-quill": "1.2.0", + "@vueuse/core": "14.1.0", + "ant-design-vue": "^4.1.1", + "axios": "1.13.5", + "clipboard": "2.0.11", + "echarts": "5.6.0", + "element-plus": "2.13.1", + "file-saver": "2.0.5", + "fuse.js": "7.1.0", + "js-beautify": "1.15.4", + "js-cookie": "3.0.5", + "jsencrypt": "3.3.2", + "katex": ">=0.16.27", + "markstream-vue": ">=0.0.7-beta.6", + "mermaid": ">=11.12.2", + "nprogress": "0.2.0", + "pinia": "3.0.4", + "shiki": "^3.21.0", + "splitpanes": "4.0.4", + "stream-markdown": ">=0.0.14", + "stream-monaco": ">=0.0.17", + "uuid": "13.0.0", + "vue": "3.5.26", + "vue-cropper": "1.1.1", + "vue-router": "4.6.4", + "vuedraggable": "4.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "5.2.4", + "less": "^4.2.0", + "sass-embedded": "1.97.2", + "unplugin-auto-import": "0.18.6", + "unplugin-vue-setup-extend-plus": "1.0.1", + "vite": "6.4.1", + "vite-plugin-compression": "0.5.1", + "vite-plugin-monaco-editor-esm": "2.0.2", + "vite-plugin-svg-icons": "2.0.1" + }, + "overrides": { + "quill": "2.0.2" + } +} diff --git a/shierfeng-fastapi-frontend/public/favicon.ico b/shierfeng-fastapi-frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e26376026420542212ed58d90d0ed34f554fa4ae GIT binary patch literal 5663 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 + + + + diff --git a/shierfeng-fastapi-frontend/src/api/ai/chat.js b/shierfeng-fastapi-frontend/src/api/ai/chat.js new file mode 100644 index 0000000..ac47b39 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/ai/chat.js @@ -0,0 +1,53 @@ +import request from "@/utils/request"; + +// 获取会话列表 +export function listChatSession() { + return request({ + url: "/ai/chat/session/list", + method: "get", + }); +} + +// 删除会话 +export function delChatSession(sessionId) { + return request({ + url: "/ai/chat/session/" + sessionId, + method: "delete", + }); +} + +// 获取会话详情 +export function getChatSession(sessionId) { + return request({ + url: "/ai/chat/session/" + sessionId, + method: "get", + }); +} + +// 获取用户对话配置 +export function getUserChatConfig() { + return request({ + url: "/ai/chat/config", + method: "get", + }); +} + +// 保存用户对话配置 +export function saveUserChatConfig(data) { + return request({ + url: "/ai/chat/config", + method: "put", + data: data, + }); +} + +// 取消对话 +export function cancelChatRun(runId) { + return request({ + url: "/ai/chat/cancel", + method: "post", + data: { + runId: runId, + }, + }); +} diff --git a/shierfeng-fastapi-frontend/src/api/ai/model.js b/shierfeng-fastapi-frontend/src/api/ai/model.js new file mode 100644 index 0000000..1d98442 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/ai/model.js @@ -0,0 +1,52 @@ +import request from "@/utils/request"; + +// 查询AI模型列表 +export function listModel(query) { + return request({ + url: "/ai/model/list", + method: "get", + params: query, + }); +} + +// 查询所有AI模型列表 +export function listModelAll() { + return request({ + url: "/ai/model/all", + method: "get", + }); +} + +// 查询AI模型详细 +export function getModel(modelId) { + return request({ + url: "/ai/model/" + modelId, + method: "get", + }); +} + +// 新增AI模型 +export function addModel(data) { + return request({ + url: "/ai/model", + method: "post", + data: data, + }); +} + +// 修改AI模型 +export function updateModel(data) { + return request({ + url: "/ai/model", + method: "put", + data: data, + }); +} + +// 删除AI模型 +export function delModel(modelId) { + return request({ + url: "/ai/model/" + modelId, + method: "delete", + }); +} diff --git a/shierfeng-fastapi-frontend/src/api/login.js b/shierfeng-fastapi-frontend/src/api/login.js new file mode 100644 index 0000000..83d7d61 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/login.js @@ -0,0 +1,61 @@ +import request from '@/utils/request' + +// 登录方法 +export function login(username, password, code, uuid) { + const data = { + username, + password, + code, + uuid + } + return request({ + url: '/login', + headers: { + isToken: false, + repeatSubmit: false, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: 'post', + data: data + }) +} + +// 注册方法 +export function register(data) { + return request({ + url: '/register', + headers: { + isToken: false + }, + method: 'post', + data: data + }) +} + +// 获取用户详细信息 +export function getInfo() { + return request({ + url: '/getInfo', + method: 'get' + }) +} + +// 退出方法 +export function logout() { + return request({ + url: '/logout', + method: 'post' + }) +} + +// 获取验证码 +export function getCodeImg() { + return request({ + url: '/captchaImage', + headers: { + isToken: false + }, + method: 'get', + timeout: 20000 + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/menu.js b/shierfeng-fastapi-frontend/src/api/menu.js new file mode 100644 index 0000000..faef101 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/menu.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +// 获取路由 +export const getRouters = () => { + return request({ + url: '/getRouters', + method: 'get' + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/monitor/cache.js b/shierfeng-fastapi-frontend/src/api/monitor/cache.js new file mode 100644 index 0000000..72c5f6a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/cache.js @@ -0,0 +1,57 @@ +import request from '@/utils/request' + +// 查询缓存详细 +export function getCache() { + return request({ + url: '/monitor/cache', + method: 'get' + }) +} + +// 查询缓存名称列表 +export function listCacheName() { + return request({ + url: '/monitor/cache/getNames', + method: 'get' + }) +} + +// 查询缓存键名列表 +export function listCacheKey(cacheName) { + return request({ + url: '/monitor/cache/getKeys/' + cacheName, + method: 'get' + }) +} + +// 查询缓存内容 +export function getCacheValue(cacheName, cacheKey) { + return request({ + url: '/monitor/cache/getValue/' + cacheName + '/' + cacheKey, + method: 'get' + }) +} + +// 清理指定名称缓存 +export function clearCacheName(cacheName) { + return request({ + url: '/monitor/cache/clearCacheName/' + cacheName, + method: 'delete' + }) +} + +// 清理指定键名缓存 +export function clearCacheKey(cacheKey) { + return request({ + url: '/monitor/cache/clearCacheKey/' + cacheKey, + method: 'delete' + }) +} + +// 清理全部缓存 +export function clearCacheAll() { + return request({ + url: '/monitor/cache/clearCacheAll', + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/monitor/job.js b/shierfeng-fastapi-frontend/src/api/monitor/job.js new file mode 100644 index 0000000..3815569 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/job.js @@ -0,0 +1,71 @@ +import request from '@/utils/request' + +// 查询定时任务调度列表 +export function listJob(query) { + return request({ + url: '/monitor/job/list', + method: 'get', + params: query + }) +} + +// 查询定时任务调度详细 +export function getJob(jobId) { + return request({ + url: '/monitor/job/' + jobId, + method: 'get' + }) +} + +// 新增定时任务调度 +export function addJob(data) { + return request({ + url: '/monitor/job', + method: 'post', + data: data + }) +} + +// 修改定时任务调度 +export function updateJob(data) { + return request({ + url: '/monitor/job', + method: 'put', + data: data + }) +} + +// 删除定时任务调度 +export function delJob(jobId) { + return request({ + url: '/monitor/job/' + jobId, + method: 'delete' + }) +} + +// 任务状态修改 +export function changeJobStatus(jobId, status) { + const data = { + jobId, + status + } + return request({ + url: '/monitor/job/changeStatus', + method: 'put', + data: data + }) +} + + +// 定时任务立即执行一次 +export function runJob(jobId, jobGroup) { + const data = { + jobId, + jobGroup + } + return request({ + url: '/monitor/job/run', + method: 'put', + data: data + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/monitor/jobLog.js b/shierfeng-fastapi-frontend/src/api/monitor/jobLog.js new file mode 100644 index 0000000..6e0be61 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/jobLog.js @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 查询调度日志列表 +export function listJobLog(query) { + return request({ + url: '/monitor/jobLog/list', + method: 'get', + params: query + }) +} + +// 删除调度日志 +export function delJobLog(jobLogId) { + return request({ + url: '/monitor/jobLog/' + jobLogId, + method: 'delete' + }) +} + +// 清空调度日志 +export function cleanJobLog() { + return request({ + url: '/monitor/jobLog/clean', + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/monitor/logininfor.js b/shierfeng-fastapi-frontend/src/api/monitor/logininfor.js new file mode 100644 index 0000000..4d112b7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/logininfor.js @@ -0,0 +1,34 @@ +import request from '@/utils/request' + +// 查询登录日志列表 +export function list(query) { + return request({ + url: '/monitor/logininfor/list', + method: 'get', + params: query + }) +} + +// 删除登录日志 +export function delLogininfor(infoId) { + return request({ + url: '/monitor/logininfor/' + infoId, + method: 'delete' + }) +} + +// 解锁用户登录状态 +export function unlockLogininfor(userName) { + return request({ + url: '/monitor/logininfor/unlock/' + userName, + method: 'get' + }) +} + +// 清空登录日志 +export function cleanLogininfor() { + return request({ + url: '/monitor/logininfor/clean', + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/monitor/online.js b/shierfeng-fastapi-frontend/src/api/monitor/online.js new file mode 100644 index 0000000..bd22137 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/online.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 查询在线用户列表 +export function list(query) { + return request({ + url: '/monitor/online/list', + method: 'get', + params: query + }) +} + +// 强退用户 +export function forceLogout(tokenId) { + return request({ + url: '/monitor/online/' + tokenId, + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/monitor/operlog.js b/shierfeng-fastapi-frontend/src/api/monitor/operlog.js new file mode 100644 index 0000000..a04bca8 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/operlog.js @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 查询操作日志列表 +export function list(query) { + return request({ + url: '/monitor/operlog/list', + method: 'get', + params: query + }) +} + +// 删除操作日志 +export function delOperlog(operId) { + return request({ + url: '/monitor/operlog/' + operId, + method: 'delete' + }) +} + +// 清空操作日志 +export function cleanOperlog() { + return request({ + url: '/monitor/operlog/clean', + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/monitor/server.js b/shierfeng-fastapi-frontend/src/api/monitor/server.js new file mode 100644 index 0000000..e1f9ca2 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/server.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +// 获取服务信息 +export function getServer() { + return request({ + url: '/monitor/server', + method: 'get' + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/monitor/transportCrypto.js b/shierfeng-fastapi-frontend/src/api/monitor/transportCrypto.js new file mode 100644 index 0000000..6636bc0 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/monitor/transportCrypto.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +// 获取传输加密监控信息 +export function getTransportCryptoMonitor() { + return request({ + url: '/transport/crypto/monitor', + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/system/config.js b/shierfeng-fastapi-frontend/src/api/system/config.js new file mode 100644 index 0000000..a404d82 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/config.js @@ -0,0 +1,60 @@ +import request from '@/utils/request' + +// 查询参数列表 +export function listConfig(query) { + return request({ + url: '/system/config/list', + method: 'get', + params: query + }) +} + +// 查询参数详细 +export function getConfig(configId) { + return request({ + url: '/system/config/' + configId, + method: 'get' + }) +} + +// 根据参数键名查询参数值 +export function getConfigKey(configKey) { + return request({ + url: '/system/config/configKey/' + configKey, + method: 'get' + }) +} + +// 新增参数配置 +export function addConfig(data) { + return request({ + url: '/system/config', + method: 'post', + data: data + }) +} + +// 修改参数配置 +export function updateConfig(data) { + return request({ + url: '/system/config', + method: 'put', + data: data + }) +} + +// 删除参数配置 +export function delConfig(configId) { + return request({ + url: '/system/config/' + configId, + method: 'delete' + }) +} + +// 刷新参数缓存 +export function refreshCache() { + return request({ + url: '/system/config/refreshCache', + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/system/dept.js b/shierfeng-fastapi-frontend/src/api/system/dept.js new file mode 100644 index 0000000..fc943cd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/dept.js @@ -0,0 +1,52 @@ +import request from '@/utils/request' + +// 查询部门列表 +export function listDept(query) { + return request({ + url: '/system/dept/list', + method: 'get', + params: query + }) +} + +// 查询部门列表(排除节点) +export function listDeptExcludeChild(deptId) { + return request({ + url: '/system/dept/list/exclude/' + deptId, + method: 'get' + }) +} + +// 查询部门详细 +export function getDept(deptId) { + return request({ + url: '/system/dept/' + deptId, + method: 'get' + }) +} + +// 新增部门 +export function addDept(data) { + return request({ + url: '/system/dept', + method: 'post', + data: data + }) +} + +// 修改部门 +export function updateDept(data) { + return request({ + url: '/system/dept', + method: 'put', + data: data + }) +} + +// 删除部门 +export function delDept(deptId) { + return request({ + url: '/system/dept/' + deptId, + method: 'delete' + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/system/dict/data.js b/shierfeng-fastapi-frontend/src/api/system/dict/data.js new file mode 100644 index 0000000..6c9eb79 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/dict/data.js @@ -0,0 +1,52 @@ +import request from '@/utils/request' + +// 查询字典数据列表 +export function listData(query) { + return request({ + url: '/system/dict/data/list', + method: 'get', + params: query + }) +} + +// 查询字典数据详细 +export function getData(dictCode) { + return request({ + url: '/system/dict/data/' + dictCode, + method: 'get' + }) +} + +// 根据字典类型查询字典数据信息 +export function getDicts(dictType) { + return request({ + url: '/system/dict/data/type/' + dictType, + method: 'get' + }) +} + +// 新增字典数据 +export function addData(data) { + return request({ + url: '/system/dict/data', + method: 'post', + data: data + }) +} + +// 修改字典数据 +export function updateData(data) { + return request({ + url: '/system/dict/data', + method: 'put', + data: data + }) +} + +// 删除字典数据 +export function delData(dictCode) { + return request({ + url: '/system/dict/data/' + dictCode, + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/system/dict/type.js b/shierfeng-fastapi-frontend/src/api/system/dict/type.js new file mode 100644 index 0000000..a0254ba --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/dict/type.js @@ -0,0 +1,60 @@ +import request from '@/utils/request' + +// 查询字典类型列表 +export function listType(query) { + return request({ + url: '/system/dict/type/list', + method: 'get', + params: query + }) +} + +// 查询字典类型详细 +export function getType(dictId) { + return request({ + url: '/system/dict/type/' + dictId, + method: 'get' + }) +} + +// 新增字典类型 +export function addType(data) { + return request({ + url: '/system/dict/type', + method: 'post', + data: data + }) +} + +// 修改字典类型 +export function updateType(data) { + return request({ + url: '/system/dict/type', + method: 'put', + data: data + }) +} + +// 删除字典类型 +export function delType(dictId) { + return request({ + url: '/system/dict/type/' + dictId, + method: 'delete' + }) +} + +// 刷新字典缓存 +export function refreshCache() { + return request({ + url: '/system/dict/type/refreshCache', + method: 'delete' + }) +} + +// 获取字典选择框列表 +export function optionselect() { + return request({ + url: '/system/dict/type/optionselect', + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/system/menu.js b/shierfeng-fastapi-frontend/src/api/system/menu.js new file mode 100644 index 0000000..f6415c6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/menu.js @@ -0,0 +1,60 @@ +import request from '@/utils/request' + +// 查询菜单列表 +export function listMenu(query) { + return request({ + url: '/system/menu/list', + method: 'get', + params: query + }) +} + +// 查询菜单详细 +export function getMenu(menuId) { + return request({ + url: '/system/menu/' + menuId, + method: 'get' + }) +} + +// 查询菜单下拉树结构 +export function treeselect() { + return request({ + url: '/system/menu/treeselect', + method: 'get' + }) +} + +// 根据角色ID查询菜单下拉树结构 +export function roleMenuTreeselect(roleId) { + return request({ + url: '/system/menu/roleMenuTreeselect/' + roleId, + method: 'get' + }) +} + +// 新增菜单 +export function addMenu(data) { + return request({ + url: '/system/menu', + method: 'post', + data: data + }) +} + +// 修改菜单 +export function updateMenu(data) { + return request({ + url: '/system/menu', + method: 'put', + data: data + }) +} + +// 删除菜单 +export function delMenu(menuId) { + return request({ + url: '/system/menu/' + menuId, + method: 'delete' + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/system/notice.js b/shierfeng-fastapi-frontend/src/api/system/notice.js new file mode 100644 index 0000000..c274ea5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/notice.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 查询公告列表 +export function listNotice(query) { + return request({ + url: '/system/notice/list', + method: 'get', + params: query + }) +} + +// 查询公告详细 +export function getNotice(noticeId) { + return request({ + url: '/system/notice/' + noticeId, + method: 'get' + }) +} + +// 新增公告 +export function addNotice(data) { + return request({ + url: '/system/notice', + method: 'post', + data: data + }) +} + +// 修改公告 +export function updateNotice(data) { + return request({ + url: '/system/notice', + method: 'put', + data: data + }) +} + +// 删除公告 +export function delNotice(noticeId) { + return request({ + url: '/system/notice/' + noticeId, + method: 'delete' + }) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/api/system/post.js b/shierfeng-fastapi-frontend/src/api/system/post.js new file mode 100644 index 0000000..1a8e9ca --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/post.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 查询岗位列表 +export function listPost(query) { + return request({ + url: '/system/post/list', + method: 'get', + params: query + }) +} + +// 查询岗位详细 +export function getPost(postId) { + return request({ + url: '/system/post/' + postId, + method: 'get' + }) +} + +// 新增岗位 +export function addPost(data) { + return request({ + url: '/system/post', + method: 'post', + data: data + }) +} + +// 修改岗位 +export function updatePost(data) { + return request({ + url: '/system/post', + method: 'put', + data: data + }) +} + +// 删除岗位 +export function delPost(postId) { + return request({ + url: '/system/post/' + postId, + method: 'delete' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/system/role.js b/shierfeng-fastapi-frontend/src/api/system/role.js new file mode 100644 index 0000000..f13e6f4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/role.js @@ -0,0 +1,119 @@ +import request from '@/utils/request' + +// 查询角色列表 +export function listRole(query) { + return request({ + url: '/system/role/list', + method: 'get', + params: query + }) +} + +// 查询角色详细 +export function getRole(roleId) { + return request({ + url: '/system/role/' + roleId, + method: 'get' + }) +} + +// 新增角色 +export function addRole(data) { + return request({ + url: '/system/role', + method: 'post', + data: data + }) +} + +// 修改角色 +export function updateRole(data) { + return request({ + url: '/system/role', + method: 'put', + data: data + }) +} + +// 角色数据权限 +export function dataScope(data) { + return request({ + url: '/system/role/dataScope', + method: 'put', + data: data + }) +} + +// 角色状态修改 +export function changeRoleStatus(roleId, status) { + const data = { + roleId, + status + } + return request({ + url: '/system/role/changeStatus', + method: 'put', + data: data + }) +} + +// 删除角色 +export function delRole(roleId) { + return request({ + url: '/system/role/' + roleId, + method: 'delete' + }) +} + +// 查询角色已授权用户列表 +export function allocatedUserList(query) { + return request({ + url: '/system/role/authUser/allocatedList', + method: 'get', + params: query + }) +} + +// 查询角色未授权用户列表 +export function unallocatedUserList(query) { + return request({ + url: '/system/role/authUser/unallocatedList', + method: 'get', + params: query + }) +} + +// 取消用户授权角色 +export function authUserCancel(data) { + return request({ + url: '/system/role/authUser/cancel', + method: 'put', + data: data + }) +} + +// 批量取消用户授权角色 +export function authUserCancelAll(data) { + return request({ + url: '/system/role/authUser/cancelAll', + method: 'put', + params: data + }) +} + +// 授权用户选择 +export function authUserSelectAll(data) { + return request({ + url: '/system/role/authUser/selectAll', + method: 'put', + params: data + }) +} + +// 根据角色ID查询部门树结构 +export function deptTreeSelect(roleId) { + return request({ + url: '/system/role/deptTree/' + roleId, + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/system/user.js b/shierfeng-fastapi-frontend/src/api/system/user.js new file mode 100644 index 0000000..e63875d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/system/user.js @@ -0,0 +1,136 @@ +import request from '@/utils/request' +import { parseStrEmpty } from "@/utils/shierfeng"; + +// 查询用户列表 +export function listUser(query) { + return request({ + url: '/system/user/list', + method: 'get', + params: query + }) +} + +// 查询用户详细 +export function getUser(userId) { + return request({ + url: '/system/user/' + parseStrEmpty(userId), + method: 'get' + }) +} + +// 新增用户 +export function addUser(data) { + return request({ + url: '/system/user', + method: 'post', + data: data + }) +} + +// 修改用户 +export function updateUser(data) { + return request({ + url: '/system/user', + method: 'put', + data: data + }) +} + +// 删除用户 +export function delUser(userId) { + return request({ + url: '/system/user/' + userId, + method: 'delete' + }) +} + +// 用户密码重置 +export function resetUserPwd(userId, password) { + const data = { + userId, + password + } + return request({ + url: '/system/user/resetPwd', + method: 'put', + data: data + }) +} + +// 用户状态修改 +export function changeUserStatus(userId, status) { + const data = { + userId, + status + } + return request({ + url: '/system/user/changeStatus', + method: 'put', + data: data + }) +} + +// 查询用户个人信息 +export function getUserProfile() { + return request({ + url: '/system/user/profile', + method: 'get' + }) +} + +// 修改用户个人信息 +export function updateUserProfile(data) { + return request({ + url: '/system/user/profile', + method: 'put', + data: data + }) +} + +// 用户密码重置 +export function updateUserPwd(oldPassword, newPassword) { + const data = { + oldPassword, + newPassword + } + return request({ + url: '/system/user/profile/updatePwd', + method: 'put', + data: data + }) +} + +// 用户头像上传 +export function uploadAvatar(data) { + return request({ + url: '/system/user/profile/avatar', + method: 'post', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: data + }) +} + +// 查询授权角色 +export function getAuthRole(userId) { + return request({ + url: '/system/user/authRole/' + userId, + method: 'get' + }) +} + +// 保存授权角色 +export function updateAuthRole(data) { + return request({ + url: '/system/user/authRole', + method: 'put', + params: data + }) +} + +// 查询部门下拉树结构 +export function deptTreeSelect() { + return request({ + url: '/system/user/deptTree', + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/tool/gen.js b/shierfeng-fastapi-frontend/src/api/tool/gen.js new file mode 100644 index 0000000..2075677 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/api/tool/gen.js @@ -0,0 +1,85 @@ +import request from '@/utils/request' + +// 查询生成表数据 +export function listTable(query) { + return request({ + url: '/tool/gen/list', + method: 'get', + params: query + }) +} +// 查询db数据库列表 +export function listDbTable(query) { + return request({ + url: '/tool/gen/db/list', + method: 'get', + params: query + }) +} + +// 查询表详细信息 +export function getGenTable(tableId) { + return request({ + url: '/tool/gen/' + tableId, + method: 'get' + }) +} + +// 修改代码生成信息 +export function updateGenTable(data) { + return request({ + url: '/tool/gen', + method: 'put', + data: data + }) +} + +// 导入表 +export function importTable(data) { + return request({ + url: '/tool/gen/importTable', + method: 'post', + params: data + }) +} + +// 创建表 +export function createTable(data) { + return request({ + url: '/tool/gen/createTable', + method: 'post', + params: data + }) +} + +// 预览生成代码 +export function previewTable(tableId) { + return request({ + url: '/tool/gen/preview/' + tableId, + method: 'get' + }) +} + +// 删除表数据 +export function delTable(tableId) { + return request({ + url: '/tool/gen/' + tableId, + method: 'delete' + }) +} + +// 生成代码(自定义路径) +export function genCode(tableName) { + return request({ + url: '/tool/gen/genCode/' + tableName, + method: 'get' + }) +} + +// 同步数据库 +export function synchDb(tableName) { + return request({ + url: '/tool/gen/synchDb/' + tableName, + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/assets/401_images/401.gif b/shierfeng-fastapi-frontend/src/assets/401_images/401.gif new file mode 100644 index 0000000000000000000000000000000000000000..cd6e0d9433421b3f29d0ec0c40f755e354728000 GIT binary patch literal 164227 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⋘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 GY5fOuZZib{ literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-frontend/src/assets/404_images/404.png b/shierfeng-fastapi-frontend/src/assets/404_images/404.png new file mode 100644 index 0000000000000000000000000000000000000000..3d8e2305cc973ad2121403aee4bf08728f76c461 GIT binary patch literal 98071 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-frontend/src/assets/404_images/404_cloud.png b/shierfeng-fastapi-frontend/src/assets/404_images/404_cloud.png new file mode 100644 index 0000000000000000000000000000000000000000..c6281d09013e0a2c5f8e699a0a6038d9480291e5 GIT binary patch literal 4766 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/404.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/404.svg new file mode 100644 index 0000000..6df5019 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-chat.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-chat.svg new file mode 100644 index 0000000..1d712a9 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-manage.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-manage.svg new file mode 100644 index 0000000..e6a6921 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-manage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-model.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-model.svg new file mode 100644 index 0000000..5d3b2b9 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/ai-model.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/bug.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/bug.svg new file mode 100644 index 0000000..05a150d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/build.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/build.svg new file mode 100644 index 0000000..97c4688 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/build.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/button.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/button.svg new file mode 100644 index 0000000..904fddc --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/cascader.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/cascader.svg new file mode 100644 index 0000000..e256024 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/cascader.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/chart.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/chart.svg new file mode 100644 index 0000000..27728fb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/checkbox.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/checkbox.svg new file mode 100644 index 0000000..013fd3a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/checkbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/clipboard.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/clipboard.svg new file mode 100644 index 0000000..90923ff --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/code.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/code.svg new file mode 100644 index 0000000..5f9c5ab --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/color.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/color.svg new file mode 100644 index 0000000..44a81aa --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/component.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/component.svg new file mode 100644 index 0000000..29c3458 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/component.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/dashboard.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/dashboard.svg new file mode 100644 index 0000000..5317d37 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/date-range.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/date-range.svg new file mode 100644 index 0000000..fda571e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/date-range.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/date.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/date.svg new file mode 100644 index 0000000..52dc73e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/date.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/deepthink.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/deepthink.svg new file mode 100644 index 0000000..638e13e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/deepthink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/dict.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/dict.svg new file mode 100644 index 0000000..4849377 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/dict.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/documentation.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/documentation.svg new file mode 100644 index 0000000..7043122 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/documentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/download.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/download.svg new file mode 100644 index 0000000..c896951 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/drag.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/drag.svg new file mode 100644 index 0000000..4185d3c --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/druid.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/druid.svg new file mode 100644 index 0000000..a2b4b4e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/druid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/edit.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/edit.svg new file mode 100644 index 0000000..d26101f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/education.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/education.svg new file mode 100644 index 0000000..7bfb01d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/education.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/email.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/email.svg new file mode 100644 index 0000000..74d25e2 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/enter.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/enter.svg new file mode 100644 index 0000000..f7cabf2 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/enter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/example.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/example.svg new file mode 100644 index 0000000..46f42b5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/example.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/excel.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/excel.svg new file mode 100644 index 0000000..74d97b8 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/excel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/exit-fullscreen.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/exit-fullscreen.svg new file mode 100644 index 0000000..485c128 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/exit-fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/eye-open.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/eye-open.svg new file mode 100644 index 0000000..88dcc98 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/eye-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/eye.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/eye.svg new file mode 100644 index 0000000..16ed2d8 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/form.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/form.svg new file mode 100644 index 0000000..dcbaa18 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/fullscreen.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/fullscreen.svg new file mode 100644 index 0000000..0e86b6f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/github.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/github.svg new file mode 100644 index 0000000..db0a0d4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/guide.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/guide.svg new file mode 100644 index 0000000..b271001 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/icon.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/icon.svg new file mode 100644 index 0000000..82be8ee --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/input.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/input.svg new file mode 100644 index 0000000..ab91381 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/input.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/international.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/international.svg new file mode 100644 index 0000000..e9b56ee --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/international.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/job.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/job.svg new file mode 100644 index 0000000..2a93a25 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/job.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/language.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/language.svg new file mode 100644 index 0000000..0082b57 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/link.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/link.svg new file mode 100644 index 0000000..48197ba --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/list.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/list.svg new file mode 100644 index 0000000..20259ed --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/lock.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/lock.svg new file mode 100644 index 0000000..74fee54 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/log.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/log.svg new file mode 100644 index 0000000..d879d33 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/log.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/logininfor.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/logininfor.svg new file mode 100644 index 0000000..267f844 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/logininfor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/message.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/message.svg new file mode 100644 index 0000000..14ca817 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/money.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/money.svg new file mode 100644 index 0000000..c1580de --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/money.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/monitor.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/monitor.svg new file mode 100644 index 0000000..bc308cb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/monitor.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/moon.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/moon.svg new file mode 100755 index 0000000..ec72d77 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/more-up.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/more-up.svg new file mode 100644 index 0000000..d30ac11 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/more-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/nested.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/nested.svg new file mode 100644 index 0000000..06713a8 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/nested.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/number.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/number.svg new file mode 100644 index 0000000..ad5ce9a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/number.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/online.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/online.svg new file mode 100644 index 0000000..330a202 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/online.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/password.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/password.svg new file mode 100644 index 0000000..6c64def --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/pdf.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/pdf.svg new file mode 100644 index 0000000..957aa0c --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/people.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/people.svg new file mode 100644 index 0000000..2bd54ae --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/people.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/peoples.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/peoples.svg new file mode 100644 index 0000000..aab852e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/peoples.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/phone.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/phone.svg new file mode 100644 index 0000000..ab8e8c4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/post.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/post.svg new file mode 100644 index 0000000..2922c61 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/post.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/qq.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/qq.svg new file mode 100644 index 0000000..ee13d4e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/qq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/question.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/question.svg new file mode 100644 index 0000000..cf75bd4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/radio.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/radio.svg new file mode 100644 index 0000000..0cde345 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/radio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/rate.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/rate.svg new file mode 100644 index 0000000..aa3b14d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/rate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/redis-list.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/redis-list.svg new file mode 100644 index 0000000..98a15b2 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/redis-list.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/redis.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/redis.svg new file mode 100644 index 0000000..2f1d62d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/redis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/row.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/row.svg new file mode 100644 index 0000000..0780992 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/row.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/search.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/search.svg new file mode 100644 index 0000000..84233dd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/select.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/select.svg new file mode 100644 index 0000000..d628382 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/server.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/server.svg new file mode 100644 index 0000000..eb287e3 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/shopping.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/shopping.svg new file mode 100644 index 0000000..87513e7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/shopping.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/size.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/size.svg new file mode 100644 index 0000000..1a409f5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/size.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/skill.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/skill.svg new file mode 100644 index 0000000..a3b7312 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/skill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/slider.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/slider.svg new file mode 100644 index 0000000..fbe4f39 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/slider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/star.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/star.svg new file mode 100644 index 0000000..6cf86e6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/sunny.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/sunny.svg new file mode 100755 index 0000000..cc628bf --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/sunny.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/swagger.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/swagger.svg new file mode 100644 index 0000000..05d4e7b --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/swagger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/switch.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/switch.svg new file mode 100644 index 0000000..0ba61e3 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/switch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/system.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/system.svg new file mode 100644 index 0000000..5992593 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/system.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/tab.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/tab.svg new file mode 100644 index 0000000..b4b48e4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/table.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/table.svg new file mode 100644 index 0000000..0e3dc9d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/textarea.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/textarea.svg new file mode 100644 index 0000000..2709f29 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/textarea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/theme.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/theme.svg new file mode 100644 index 0000000..5982a2f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/theme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/time-range.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/time-range.svg new file mode 100644 index 0000000..13c1202 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/time-range.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/time.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/time.svg new file mode 100644 index 0000000..b376e32 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/time.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/tool.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/tool.svg new file mode 100644 index 0000000..48e0e35 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/tool.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/tree-table.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/tree-table.svg new file mode 100644 index 0000000..8aafdb8 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/tree-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/tree.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/tree.svg new file mode 100644 index 0000000..dd4b7dd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/upload.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/upload.svg new file mode 100644 index 0000000..bae49c0 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/user.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/user.svg new file mode 100644 index 0000000..0ba0716 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/validCode.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/validCode.svg new file mode 100644 index 0000000..cfb1021 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/validCode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/wechat.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/wechat.svg new file mode 100644 index 0000000..c586e55 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/icons/svg/zip.svg b/shierfeng-fastapi-frontend/src/assets/icons/svg/zip.svg new file mode 100644 index 0000000..f806fc4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/icons/svg/zip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/images/dark.svg b/shierfeng-fastapi-frontend/src/assets/images/dark.svg new file mode 100644 index 0000000..f646bd7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/images/dark.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/images/light.svg b/shierfeng-fastapi-frontend/src/assets/images/light.svg new file mode 100644 index 0000000..ab7cc08 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/images/light.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/images/login-background.jpg b/shierfeng-fastapi-frontend/src/assets/images/login-background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8a89eb8291d5cb7d9f37ec4f275deab911c9e28e GIT binary patch literal 521275 zcmeFZby!tfzcxHqP>b#q7TsM7q!u00jdV#!gCNokBHhy6NP~iO35qldDiTtnqyiG} zV(Cq z=das;R{it&x)&hC1d>3g&JzM)LJ))ybo~K<0`TiWP%+9(4A4!uG597x(I8-S5P*S+ z1;YlR`V9GJE-C>6MMM81lK=pNAP_JVjE(_8MPM%w7(xgI(CCTyk=p3QRtz4YBm&7r z7^ID|jD0#&p9RUR_nADy4w&VHbW@6(Y`pq~&JFJk?CcvLvtG^_79>m%G-wLzx)tYGQ8&-omtU7vatCRp={*f z7n4=lF}k>Q$}Xm2?D#M?yQ*{S-S!!WxT=Yhe_T%W^H=Y8&QaY5qpUeW zG&em`Z%ee}5|p2&q{}%dycNCQOGz}*)%fLhpv@dd7_X*f>9@c)%%J4OE^?ST_bip+ zM%au>nieEHR)QnB`UobvKN2g!HXDskOiOATP0Re*zzAJJtv~JIJUm}#j{1sCIu=5t zOZcLnM06{Kf*{>mvy&xDyzLqQr$6845?v&qUgt|ge!T|L_>T@_+$cZo>yHjj3w$^K z;!3~7JUFM^Ekb1mS;3>$A|}=Rm}nVnfUV9O%MJ^8_I~<`jyPasXLvsCm3D$ioKCXH zfI~UPS$R=F7uGFT29mSXCb^T}B?i8|kunAnqB*0qE;tWsLsg*>h zt`tiYIqL@AkF5;9roVD%CRMr>2Ta4m9-_@;-nd+j(!-7%440We!{`RIeZN=-%|H?S z67k+oJCsvQ(3t0f^v=a%boMoE{j*D5`+ zQC81h>wfMPd77TB0>7l2?zB)`5e=kY7j8&7*8Q|ob>Wf7(p8|XiAXRLFzC-nLycrw zj)X{@+!*c2fJ3R!)HnVX9W$R>$R*r5{yFcT0g}Yjh@aNspb>wWbIAx9)A=DnX>0Sglx@^7FJi1(%*O}4J5>iLfR5Ry%#Q_YyR?`eQ%D3NKbGIT`#h_;}HUgR4`kISQY^nM$q zl!S&aOLuSA+;x7SVw+)3s*gcSkda3uYs%p&E!@? zU44FT{5J|?^54Q}32E+oS5nWA)Q{9DNH_CJEP}RWKXTSJ6*PSnr^~*|N^p?K-#rt< zx&| zn^+b+Rl5enNB6E$@pr|jKjd17nvAHt%pk}*G`Pe3Oip)PAhfN-KhDrY>yV5h#0>2?6@4R{H>*EukL%a=u*v= zT%MlUG8qp_;QgwTU@ExMunR>~eHTO~REIevcRjtj5bbeDSG$5!@fxqVlLb~OgCNy5 ziQwB2r6?mlm)&?5sKh;}t@2fZPsfG0!a1ir&ujPHo2d+8yZXUjD{06tS)&Qck3QVh z&N*IIUiOS~hm03S_>o0Ax+`l#gE{DI6!W~HQ+PZ1DE8}s+c%!AJbbDm(|eGzf~ z)ghTl|1^e(TV%qE>S-z^mO}W_?OO~JZGE=3ASC=NyG7~7X0|iNG(o$eYO?BuoJS$) zcazT`7CqX%Gf^+#(A&WX%o5bYJYmkkee|Sx2HE3ABcUF^LFHO!i2Mw@wA5M9y$hNqYkrVbqLk=X^!w)RwAGaT^V=&z@nNIzGHF)lB%SlP2=p8~UTx z#A1sixFp*^Fl<3U?GF0ZxFdbtjGB_L3ES<1`pck);CIBGf%IoSy6Y}v-9go@zuw63 zXjV3%Q@A|g=6q6|wru09%2PtEK)-=Bb#VlFzn~#&89pKWb+DE`+NQ``z&qWHsFQvq zDHT0YdFaSt(6HZhI^B_ue?7C>9=%FOe}VcUz7&d#@tNXrbm_&d)-%5#jOz?EnRQ!> zYuRR(h^gmjiyig-HkB&d}7K6J!Y%Pt~ zG}wBiMU!N0tdt%wRAs_lmg^a_M{qvNu72{>x6>_4TFRx znpOwDRVS-4s>*PV$1u@eytrJ%rgy8lctVHI=E(lRWuanX1Ngec|NMa>wT+tc@RSHtE%`gzv>g z)yCXi%<0}IbN@2xvYF3$TL`)T%-3uOS5I%|3m%x-S{VfKJ`y4#0|NAxHX`6DQM1`; zeToc?Zki`g89_~(Ql&JmzEw*DT;(a|eP_%Mri`7Mb!w=~KMLu1tUS{Z(8f1Nd=tZ* zA&5m=05Un*pp&SypC)dDOIs`fQX;^bMoJEX(XTr{$oQQUa zFmcKiogng&`cpXzip47k)|Zf+NyhRLr$>T>r`9CZuZ|tj{E6dq9*@Nsjzyt;k#gyj zF0+E}9e7^@`b}LIPg^b)X>;`MI4MJ6Z}pB}J+>7qo(p06dN&=p+aWZXA@}rSS0i^o zx22M%f;zfIjK7?sjVId~&1@j0jcRg|cKi28G%T)e$0clPS&6}u0jG$LEYDA9)#||< zS{Dh_29PgmFGXY9zvfnOJv~6b&1t}V*0W0~Dj>y zV-tC8-;?t8zrKU5eo8O#;Z{L=zbrGCQTkm`r&c;jH<5Psp{U)s;)qaD94Xx!m5pp3 zb`!p$02f7y>Yn}yiX0j=P@B=nAg*QO!>OMN@>5J56cL0%gfkdc)jGHyIxpLsbDRX z1f-*&>5SA6%@G*;a^Qi9aU@R2nG;(}@*|KY0y=HD`?44lw?!`Fb8sE#sUux+#UAlH>Ph-0;anG}8@xc%? zV07-Wa3*pMgdybg($>%`Fy-@KpNSNn-P8V=m3XQ@mT*VBN!`$=U1JZ^Vi5mxN*q(R za+l97iU-FVbYV7gy12%{)Az4|g)53k4)Q|msGs1hbeu=pmAHpu@WHO;38I3Tsjvr_ zn!B$&kKFo)>g+yq2gpzoWfcb+R2CICyWT$ep3F6{d{L#lk%w6Da!nTtk}egpKaX>O zcu`+c+UVq3o(HS+4_1k%R0Oag#XG*o6@FuX6+J+zB1||ae0yD0=HX0 zP6%=;JudnAEaCT1?!m|ck7vu56nJCccR1}AU2VyYw3QMv-++28`rURO-OI)AHm&2! zqAqbJ<(z7()H%WIL%LP73OKA)PI`18?Vb;z&tNdF%D1!WW`CwZ?q|v? zF%1ou#ZLYQCW>I7Qk)18KMDPaj;%Cm{y5&X2^V5Q@6+*Kchu^-zj zN|z$u1r=u{4{PKls8^{-Kkttux8!Uip)W7v*K7N}_e=MDMenhWCCLg`_#5H!4gXEH z(RCXcM5R786>7)#KwiLGY1M)%p#$}&dTP~6YfSE<<~)j>YeB~Up<{j;j3RJu+-9si zV9}H$W$&Tut)H~QVs|YSoeiw#_tKSMfbv;tl;Z2O1-wy)@7o^K;kw7R2J`-&GhcH8 zo;9{rDrw)r$@u0WTkEtUsPz`|lD*}X^)Q7R9aU<0jZ_>F)o^&DN2K=iL9fR-uH%CN z=C^a>863q*5!*cJ6!(iu^Fk;&oS7uO4$ZR8g0(SPM=$g}7Z05!-m%fNKBm8DoIK^f z>VyxLn+*7SIeO8?GC~d+X=|oL3LY;H!G-m7HM9E(!e_U46a!;=GsLP1NSlzc4|%iM zd{Q-kzN=VETmSOmaVjO_*SI}osP=;k34|IABxXL$U_`W>Tx(43#ROI(`nrtjy zCCy~+u-C*TX=>pa%|~hfYPiouVoOTeUn%CSx>|`{au7(pVjx-54GBn)Z^FMlH*&6W zW+z4y?-&r>9vN&d&pfJt(Ky75a@%!aJ>BFLqt;b6OH(PB&%^-bm26m^;+xKP86eDDSjHJs6o-;-eT%Ki=m49M8 z%M~lVRovalSFx7T@sl;8O4ApgM+M9sY+rg+lFa1G3rjW4RMdd$85DPjD3d&Y6rEJ} zV3l1nAN@g_h*YWEMXS)c2Zvb1fZ21O*-~T=Rf7fCV}t>`86&{Pl6&rR8Xf%jRF`$Q z7r9H9nhGj*g7nl!f7eb;_#Dy3mGjYLmp?eE`HETdU<~L@bIW3`t1F^wx&{VtUE7-k zruS6YrNCK!+Jj;9K1Ofx7*mzZ&FQ$3B1=`^E=ZomY_`~?z_DA;_R*AiFB*$YCtR5w z<%b*qchH&3_mEYzw0kP|Un(CSj*a|W$B+KiXM+0XR`$aVsx&v;cuWRe@1-c0#D^J| zem9E*uON!i(&8iXvJKL|U{?yA($Caw=DZ`ov?WlUv&aT_STo+S^%aSUek~q#^~~n;`9v$S0_J69#teoQtEo_-qFaKDJp2Z83HFPnwGnwQIi6fLQ;1TTz4~;f zal5>K<;TF~yoQj$YPWqeNiEY#aX#Bm-t(bIk#^y8{^Bm}Z)O#(H4Rvdr@c&_-(L!} z^k?j-QE+avVWO}5t&xsKE3jq`XhwWb!FBa~m>g*+cexW>D{T%LhV zF8oNqJhV&Q;qVpNN-EKb%qaDF|;46*BlY^A|uS;9R+`#SYXnt z2So#8Z7lt`^jQq)b`Di@%EGK0q3PUSJ8D!19NOwC-;`9X+ty56dz`(FD72k?ACN5= zd`zomj_u_s4VrU4wU%LsJ22R)5&L$3&$^}EgNCnxV!_%$^0hl=Nci)}Ny!hTr@4o& zPg7^tlR}<5yrfbY%zL7|o6Hr4#au$CqlM<5g8rdNBx60Fiv8qm{;ZY7^NvM5jXa#L z!7>_#*+WZ%ilvNAPzvm|LZ_$GBb++=Fz0)hyzHYNo^S=X1r;nL+qRPiU(c?!=hSU% zV{w8giU{k+O>@7fQOH9fKBEFW(D|&Qm-0Rkk|k@itI;8NwSRxw@xo?U#s`wCoVRQZ zi2=%5X3Ud997?8+EUM35MTzQ2@vuZ=QWxf>WjkyRS1=8A?AU6Fr?4OO>e4#Bk2Lt{ zWuPM`I_=57{w7#w?3<+W_M4Zp19w#XSvT(Vc|V=|#;kkZbYYq!An*cXqrvnPwUsoc zv^9CfX% zta0JO;cX*}NJ0_Tsq=RDOgS8%C@r<`{+mylO%CA_ge_eQ;pwE{LY>b0#UN)Qw$uv8 z*A>g0cZa`L4q>w~Yg}@0#;=mc6be0hB*s%upMklw;^e6h_ue+WN2t8Y6@(y?^T7ux zwr9jC$qn33KsNLFst2@61lZB*(Xu%s%|?E#Dvq+JPpmJ%wNgIjRDEI?CBv^PIBrf` z_v9~Y_%S(ieu$H?fSR+pRqe08PH|Mj8kfA<=vAjZC*2^dxN~IcqUYJq`~xl)}+hI_!sPBWb~M>x@xDc0*O8M9jI7 zo!?c{n_y*qF2gqlUp3#yyIu7revZDVmL6+HxXKPR>w0K*d5>322t!TDIZxC=zLND| zGM$FU&obvIyc(%`aT_Sp`05}{V>hNVu86GWV{&JZXUeNYQWB4)WM;bRi0FJJHGx(! zfwYkq0=I~H97~@^HZD#S?Ax5L-{-|rbNybYTJVYWf?za-5;KFY_POwD?Qnbo=QQ;m ziqh_p!M{I_`PC}VLN-1|!d7*sptVTY_(M$Uck)4YJ@rCumAu8348w%S?I*(-a~W6f z)0{CmS1Csh2Kw77+kb>y10&)@5@J0kBatov{&_iZBI%HYDgk9_?U6_c)hAVsUt7#r zNTerNlCGL#!VsDD73Cng5}A>~*Q_Tap?W4NqavIq~D5(X20DBk?uq>(P2 z*r=|{YTj|w)$eLkE>U_GXQ(|g%d?U<_9?cUh|noqyRG8Fb4B`S>KdrWLpQbiR0cMT zj9@qKvh_HBN{hC}8GJ{rn`;ev!eA`@rPhx;zC}3p#S%N+K{~2}1luZydKK|CK)Zbn zFq)#aIE(uYggP$Bo{c=|l}}erBawco_*@@6=ZSw>NntDt68{K%V;16{(@dvo<-+Kc3Iw`8SINbq&96u~L7MQSB$XCgs4wrWx7>fgWE=rjgIIA8LY$x3}y zcQ#pQgVvU#1z#_G4u_G0J@k7EXgE3yM@U`k*5c|xlR+I?AE*bdbXGJII2HuWE5I*O z{c`o|T~<9>eFagGDc03}{U*&L{2mzDVa1*y*x;~g$HU;U+XA)0V;iTOGqc|LqEFQw z*FdD$1N^EQ(dnnX!OK62dnPJ94^A>tk0Yr)MqaP`Y*JRtP+VnGTm$~2C@1&h3%w)s ztl1snD-F8c1hXauk4rS)T;$zHqE{-^2f#IeBVLd6zSK5%Vm^L|f9BC~t}dNGa}B8S zU8?d9YjRh8bBVrStJoj>p14xtc%gSJxZ_Cpi(EBe?5cy`4Q;F?Nu*XIYRz5S7D%+F z=8WlbiFPavD%_uNcJr;;ds1Usvqb8U3!CS-2CmOy8DUr0mn`*#|I6OnsC0` zHcnG4FIMX}+D-N6gon&@`r4^p%*RWSiqbq3yay8@YkWClQ@6c_e=>HlL;9Hkt_d$!e+y>}~4%fZdt&C9{f9WKbj2Owls)FFF+kdXv`QMaIf-EZ=s zI5&B>bWjnBwfuv)L{Yd96nu7*E&@=XnAhK2>~AjiHy8Vxi~Y^T{^nwTbFsg<*xy|2 zZ!Y#X7yFxw{msSx=3;+yvA?<4-(2i(F7`JU`WD7* z#%6b07uy>=h{7=seC$vdatDQ@9yr?hqVOyVGu`)baYW%$6ee-8v+_cnlf7Y~nY`_6 z98j1Kg>gLfbmUN20st^^?)?K>{{ws5`JwCt0691Jhn|l29K7LdHtcYIF);*O+0OUA zowql)mX(dOm8UIS&dtT$%Jm@t{MqN5S^&|_*uqhREW$4)BEl`egBt$7!~dB1cd7pw z+|2EtC62WJm@^RB#J|)2UH9Kg+hIvW7mBLRTo{lC+g3IPB=0sz`3 z|Ir_^oAu)0?d>kk%j@gw%j0Ng!*esBe~16q0)LnM@4-Lb$8+<(zsC+PZ)b1ibKe_& zGpIIh_uYIv;a=`mHg<6C|6IiX*A@R^)<5jv(y_C*^R#nCy~+T!${bzqp{Co_*3sM1 z%@ywG`rpm)|7Ep**l+{?T-PW-TzUnNSnvP@lOzCSZvuc4V*?P=d{hqT?|Hj}WdPiK zd4_bGf3ABJM&s~QlJW`2bzF( zpbO{&hJkV5EieZx0V}`;unX)1KY(ATqv{wSTo5sc5<~}L262J}KnRc&NC9*QqysVr zS%U6?TtPmd08lvSF(?I;11bVlff_-bpgzzTXbSWW^cl1bIs~1AAz&OZ3HTP61`M^Uxd6d(o%R*U(Q;ssWT3oEVZAniy6X-WX9B*%-ALJs8s%8yKgU*qC&f z0+@=JCYY|6VVD`1)tE0ar!hA%e_;_|F=L5iX=2%8J;X}HD#dz^HHo!>^$SJ_V}(h= z^k7b~P*@hM9ySD9h84ijVQXVMVuxU7VL!tj#r}+ahC_tIfg^`wisOTmfK!3f zi}McW2p1QZ1y=^w1lJok0k;Zw0QV#ADIPH%51tC1EnYC*Q@l33X}oXvSoqBNNPKgA zfBX#mX8cL~uLM{GECg}{Rs=x=PYF5+76?uVi3#}$H3?k^;|Z$?#|XEGFo;-)6o_nz zB8WO>o;l!oHL&O^-=p?KpDkM%Mk4frC-jM8*5|Ij#8j$*tJ|*oY zT_J;zv5={d-6u;VYa*K`J14(IjwH7uk0q}spCBmj8=x$i8hV)CG9pHF`X2hBV8I@58V!&6pn=7hiAbD;rsM7^vd);^hNY< z=zlS=GUzjeF*GoIV1zLu80{I;82cIbndq3*nF5$N zv$V5pu~M+Au==ysur9OVvPrXfuobh-vZJ#j*qzz)*xz!1IfOYJIG%D$a)LQUI2}3j zIH$PKxWu?zxr(_KxN*3V+z+^GxIgod@!a7F<7wyl#>>QO!kf%H!h6Lh%y*x!ly8Zj zh+maIjQ=_Rp#X<~jlffZSwUPuMZsXf4#5K1q2B~3lWbP6+;t~6$=*Y5<3$Y5%(5v690ai`?mA#>f2iqtP*w-r4nnB z43buoMUpF0a4AcvLa7yLdTA@^V(C>GMj2a~a+yseJJJbRi~J_bC+jKOD*IDTTrNMO6b; z2i0)Ybk%ayR_}1!@x1d~9jvaZo~pjA!K~q`(XI(-s%WNae$Zmo^3ZyrjjpYw{Z#vl z4!=%-&WJ9tu7z&3?hid#y+pkweKvg`{eA;{15<-4gP(@-hAD zVy$AGYrStHXOn5OYb#@$X1i@CX_sQRX)kG?V!w4y@?Pq_Z3k(G42Q3da*jEU2TsaP z1x`PmHJvM*ukRb)Z*akMv2^KlC31Ck9dx60^K+YW=X8&9U-1z4NcY(HRP!wN0(qHv zb$FAYoT&*PHlIkJ&kv*?JbiHLYv9}LN9gD3H}R0;VeG?Ae?|YYN6<$$kNN}X1408n z2O&MR`Uo zMBk1sh=Im9#=MCYjLnX{inEP-^_cH*#^Z~4oA_5x_@87wxlXW8c#|lSn4g56d0owPRhQ>ambl_D*Ln{ zmnJth_h+7M-gLf9etp5Mg2x5tg^q=bMM_0&#Vo}cCFms&O1_jDl#Y}k%Bsq#${&|s zR=8HIRO(a?R*6Jo9lXkY0t?wIOS?d*Sk`+4gNo);Be^j%N8NxKtY!d^za zyzcSuIq7xp{n~f0Z>`^=e`&yIV0KV@@Xe6g(CDz@@W2Riq-Ruev};U!tn(G(Rr|Qe zc-w^VMC)te*R5|v-n31MPIkN%d;5G!V(R6z^mN~h{LJvI%Ix@@#@y7r{`}&C>B7pQ z?c&zE`|l3kKY0IZDP$RKIqn0&hm4OjAB#V+e`;6}S?T^P|9N6ncXesaW^H%fYyDy) z;tTGVj7_@Dsx5)7u5HEbw>x)tHg?^2e|?SGBizgV#`dk{yUh2AeWU%21J8r&!}uf0 zql#mpR$LdBShsVavh1bu@otK}7j~9@T_CtAI&UW5#YrA`nu96JjU-U4*9c?8U429MC z)ZOLm92}MXJ?(V;HS}!!oo&Qy8Kk9P5`N--F77UN-d1owm;0_>;(n40e>5(R!Z*ph z4DdgScsoln{GrSPH&WL^`C6WKaA6){ZW}&60XRPozYu~?K!6J_z{fAZ%O}dqFU-x4 z5a$yS7ZQg56&R#oa0yRadvP6k#lPN#dXi-Lhx6(y$m8aDj~C_4i}CUa@CpcUqe^gl zJ#_W9^5b^(V!UbM&o<;yyBJSL_dmS7n>MYi-F&$IQir z_dja<&*lFpj2bR=b#YD9-ow$=>b|_2jgO0+tG9~0B!hymu$+LXoT8{KKT=UZUO`q! zQBIT(p{SssATOvWEb@=)|Ez{;kjK{1R@}zk&R#%>Ply{~FCfM(BqqkkZ6zisz-`5c zu(lH67q+q%K>VW#6<05BE0q6x^X8i-9REKyA+C;^U0W+}D|st#J4puA%n5SyiE{HH z^!WJ21q8+U#ZZspe0&VNHxtME*UX_-4Da7d=4LwnOMv}*5!$(;R-P|viTy*;Ne1P_ z{weA7ha}SfE&~5MK>iN@sCpwv1pYhYe+&MQbOH-Pw*EuXDex~zCv=o-(~ZylXTZQj zM}uNPU|v*GAkxQavhIQ3hQKMIh0-!i;yRbvTsV5^|P)>*g;CMyq=eWqNtJ$ zo4$b|Ra5`;>Y;78cWQ(kN+xM$jU8cs-W$$TF zP(dMRD48vEbTpSgRH*2Q(D=~-24bW(hJY1`2c~Q&V=}2uku{khRwGm2l$_^2`R6bp z<`i8D8~KCcCKh2zJrM<4R+v|Ks-pLez4~@O2BHy>e;9nkPW6|p(_b=FY)Xdqh`*(% z{;fmxPZ_HJwWQO$A_<)f!(%hYv6rcjc5n)-$V6vG7A^uan{gdxFU+tANffx8ku32< zf+j1vp-lJ%lhFp8`zk~Qlxej-@+y`InRAxYe3j){$qlmI>&_t17G$ z`_LdGR%xE__vRuhcbv4h*qkf38AE%aosh5fGLj~(XhLq0d1+wG0%I9{N+0-a#0!El zmr;hj<7Yo2h-)1)p6DMPkAh2Bx@68)j;mI^aaUys$85BJ?hMxs7_JM+rfORLaD_j)M=W*QX!$d{e{b$WS=Oxzv-1k{Z^rK z2HS*rk&XSaf33^N>@Vx8$}L|<&eFJb$lx{b^i>{bwpHxgAsUkzeGcy(dS#eiTvZ*? zj>+G0vjhoT&f)Nd(A}~2 z=mc%|q*|s>)i8BD)kgHPR{IwCZ20Vt3LfKI-sjeYFU!nCo;Q@KVA zX2+@mE_>W?&h8~r3H^Q%CBPpoM(HF6AM~owd}h0*ChV9h0$<8pnb) zb&Z*{4xJtutL+2Q77Z=^O8f6-R*EXcDsUD?A5M274sS^_usuP#X!HViNyZ5#ZdZfg zPWPwN`bNZ^(CsUd`iP_xhpTsJcV0#-WGonc=r&{w3+kDgDy=HXA)`UyF$L7DBVS|O zp?(xQGCOuZ77-JvBMb`=mm^%Vh}kiR6F7&|g*`D~TYPc*iQRo8#Zqie3l(DJ9ak;i$aE* zmUL1k-Y-Nau{B4{jT~PCR^O&nYxJ3-JaGd;Mj7%gnq$rqswmPw2m@@jhT46?B(vVL zh%uqQdQOczc87@SC`I~r-kI?TzX26bkcdt@vXq}`Io$2_lfmluAQIAs30A_Q6w&Pj{voPUwBYNgKA8)FcQOH-%E{j^Ut{&B|zFj>G2kcGnz$bX1ejyq4A-!!4vLkcu!yu6xsXEoNMO zlzn$aLdr5XU4BkrN7y|_W%26`AT(IYK4S^pwbI1VOWOc=GAsCtp>0Z_LG>0PW`p-`CYWA^EG zSG4a}Qs0EQ9xE}_#QC4wTwDVyxA<(b>u!;f^f$=Wr%-P`T~eLk^rNtL zUt80bitP)La;D5c4%jk*o5x9ew=kt@*wE}l|NY4_4aelTk;gGH$^*rT?k_xBONKcb~Nmq zbG*-&85xhUgSiSSf^Gs-w#2GAVEzTRZAL{lB;HPd36^tId)@o@?_7eUnI4<`{HgGO zx0IBjRrpu%K!16zRNo9yT~k-ghWdc0YX*}pX;qmcQU;%lHBx0q&;0{5E;*5nR$bMb z%uV5StuP+|>21C2a5oojh+!{{pP58Yub`o=sjR$5rH-sBf^gEv(=zcZZ04acvfOTpc9^D*B@o+Uz7RlI85Tg#c5e3d6k zf`e(8hB+jwXC0L{SBJ)~Jz$^-T5#5MY#Fi^j@j`1S_qe&YE4fpC3X%FXF=wjy_l^5R}2yGkg=`BgU?n$4!(+uTS+Jv%RdB zS#~-3YVnf$`yhBJ{HbGv-^ysSA7iH*1im`klLk*lc{5)O8e!txp}m`5oI(POY_{;8z90e=Nyc~ z$$=TNzNPKfRMJYJCn~{QTVZ4f!DJ2_m4R-{1d~heF5Vp!H_B~wG$BH)(G{GXyRy;8 z)a(H1BX}gjMME0tloS#{W*Q7tiQi-??__cpxfBMf5n!+k5*}|ZIlZbT6L2a#%V)j# zbfCZej!H~TiGu~doW$dJ#srL?u8G&|kRBohW=t;eg;nPeE=`AR$lZP#$UAcW@p4Vb zDx}}o-RGT`H1QNn5g2yXKp}KrdsheDOM4@s3jeIVynAz65JL|7;-1goeutS! z)asHdB&2MfsMp8{k9&{oi_zW{r2!soS3p(bWTGtIU8lM(Q%`2k=hS7a*VOOROC>V3{L?+cR9kSi!1QcS<6A%}V%0B|_g{LPohieu`SAmk< zY3GZ5-|S*iLrrEc!$WC21`-u{E3E3Dy1HcBq{j<9uovCiw$??xl-VwUwvpN-zG zD!z7VrZ>zRjSNJ?TM5RdtLu*2Q-#h9&4Nr5c{QZ%oDX-nmcKj zWFd^yK}NYG4AK9@jlxlwU37JMpDVAVWKLThqboK_rypN_7M zLB~T|s3l*Sen4{iZk}y{omyB3U$t)FkD#JXDA~YwN zv*|*A_sfW5M~SH0&PqDCi$XdYIiZ#F*?p8V;U@OkTM|3Mhq)bmjajps_IX8bRo_3P z*x|w?j0S)De1h>F_Y|KD8`Fgm-&trAw*b5wk6KZ$8tPE`F1T{tI;(*Fd@e z>P$D$R~2J$OJ1}_6>Z_&5xn=q4{^F8xt{l46$VFkSHDAJNY;O0TvK3?dER^lEZkO} z448;B)G{T`aeVD1D+9+9qU_xnN8^(h-Ox`9`#KMOCtJMjBJQfh6c`wb^SIPWcqH@8 zPtW~i3+j@+#yy}m3p-0e50X2Z#e43ceb0=)lBh{iaPh}yRTbMO>qoAV+d^B` zrV9o#ha(&2lvk$yvo*z9R}`>;#C!!O_gMg}5O66zxiWLL>#;qnhXyOGf+0nplhZw2 zx^(l!5!N;qy0V?`Y?!PQRL(iBbK-o@hUVOx9Dg3Zqkv&t60L?tPaW>NlEj5MXARQF zQK9)b{BhSzwqktLkfq}6;^KL59~M0^+#cLo?zkJS8>WsRK1=9q0g*1L_iuexsOPDe z_O60PtuKZI&;NY6$tD-7Y0(t@BY5a|vOmSqQfP*Rp|ef_9Z%6gA?Qs z=_7KiCNq3tMOkbt#b3F&lmStUBv;&?{`ns>2&VAif}M*Vo_Nby1A}R7GGU6fxHCPv zJNp&mMa_(qBp|NfMjytj8rxmzgj~Xj<}6yc;Mvo`%2&+ zB0ZalzTBbf>?ohmaJ+i>t_oGC|3$~yWqy~2xW27cs~8mQ+9Tr%SlM;=bxIfbRF?U7 zV+k=-eEocOLS?k4PW9MA=VSkhCjbdSNCXC+CMkIw?G9n~=RCXyq{?)04QW1LHa}ke zba2Tf^RtKE9O`}z@S5D3D?`!-PaHpe4L2<#rrRqG4S^wB>!nH|Bb!HOKU>R?4d;n^ zLG!XIMHpxIzS3=OE8WQ{0wFI&dZcO|CpJtkTmybTpNEFe&pFE=PVj`ZG{Ngh+?Fz( z=a+85JCajb>$)}U5-O-&+UH%SN|9MX20H#r+t1-w*GAX!k1L&P1R0te$@i*JoBN)Nn?*!v*T;g#0Jw zE5z{0;lfQa@x+D8gugv@6h=zpnEX0IX%4U4;mxp;uHX;x$-8)6S)>WehtV!lJ z0;N0Y`1x!^EzGIpqF_gQN0P;F99LOpw2*Su!8wkA&&z^W<8h`>zeV+Jdf90Ecb#Y4 zZtB-S)C88*(B9DKkGouP@SM7(vgkM2BZ>!%M3#D7#wt>2!`1mrpbI+h#~er5miZorH|p&m2BnnMsp=D&`;Z_nPB`_sxzO==~z9Jpb=1wID1<#Ui6bK zR@z7P&=JUF{lezw*h}I>*c+;V`mz32SVneUUt6X8)cj3tR;58jeHu^eNXivkjE&=ov)WucK+7od_XOR85? zUriHINc)XxU6&GZrcv097Emsf%YDm4(uZAl%`Ox&z>ySTL{J@QaYz9DwS33c-DfIh z)ibyt^XSTqr9?@hQBGSfSBr{{BmA)Muw02Y!Ys3WSMbqOkY0=SNMc5-vyBl4Yl!mn zBo6X(^1D(;W`*V`@!*e_6&Xg!_A+y6fMZy|>174taO>NWOU}budp>-uteK8bU1W;$ zvWLlPE~eP_nrJelNmNRw9-&i0j`y{~uAC!j+G$u#X=64@jB$aWQM^EH`5K6BD|xKY zI34w*BDQkuO&~_**X`Z%IQ4#e_hRJ>Z5g3Z4sEA@!-BWN{jyN$PS!A4{mwkn_ zbq@C@?8?Y_<$yh(^x3>J?BS(>#3NG`@w5gPHjFnfdw3r2Wi-BPi7WmnY7_OEnzDjw zMuoKSj5c$LC-tjH`MG7&v`zJcUy5V3w?&lbEO2Ubvx)fVD|x!Ywh$7JkR5_uSUWPn zneOCfDsqc+Dq%qR$)2?fixdzB($&_GEN?wwByBKcz9@FFI3$ZJi{q4=TZ;6KI36vo zp+Nm#M&3E>?h)!+yMK4wgTu_6pHW-!$}U)X=?tYHDyqr+I$@g0Wa^EA_ghXF7=X?a zfoKFI#oyiOr2v$;gQt1O!^iO(W`B`8e`6iLkPuH)^^4S-(GTdxsQl%3WpU43dGTFX zqB11%8LnFqYNv_%;M;zBBB6R0%~!51v8jsR=rV%GcW0?iwTra6s?li{t7fGN9!J9S zgW7&t8qMYs&d76PL=6@%(DmkO6Zcp!rC+bGU#XIFnXBZQS z<`@fAse`Y;&*lhc0Uh33K96AtN?Gx4_;&|Kg?j0^jTyWrvJGS;`IKj~GodIoZ=(&Z-OBU&_4;(n z;IhZKhPbage(d4ZYEj`Iov7eFf5A!|hqE?&_1?PMhXxs48fw>5p@QuR(xH4hi`eK9 zMg4ZlSfOtBWoWXg;N!r=>9k! z2$f9(^^!E;3O6ijX1+_F7ol(1|tq!e((b9GcRt(`J;N>GKDJs%~ zOgYVI14YN_ibY^@V1o%>M@&1MQYS4{qtL6K7kW;1VsyOYM`K633KSG?l~g!_9tw|& zT`&~-FF-1H383gMFrlWV4;gEjID=P(XbrEwy9JS2$+lAVtzF?ye%)>)oGW{0NK!q! zIYGCr8;Q7({67?3by$;K8z0>%ARx`8LsCT81_%rUHrVKH7$WUUcS(;DkgkmmDNzKZ zL8S&LDAGtNEjhn^|L=OP>v_)EIrsgm+q%y~^n_ta=tP{|~_H6N zn;W>b2@UofOOd2-ghv(33dM2(+{koR0GGMi(TWSz{k=w%(`_|p=IsnifSpbtjpb6b zi3tr%jPoC$wtdq0KOGfU9irE8K0pMTeUd?f30EC`B$jx#vHs?f=efSZFuyGzmLb6lg(-63w?Y_TNjWPY}? z?pbgOE3x&LI`owSJNhX9x_h~NWTcd1wq@-J??ipHjN}S<$MerHS2c56X#!fU%ajV_ zZKTm7lVsjL(c-$N#n?D!KgrYJpX!3B)a>LIiq}$^QqUEWKS8Wo7VWa`uvbg?djDBV z`No-xmFiluHOeY?8~M>jqSBjghq`EmW376IgO{vQ5|1{=fWP=(r++EKkNm7~L1h-j zb!{JD7!EH3YSv8?`x*5))r`p^iQn=^u0_}p_z!6PDH1*Wik7Pj_Y3*fu_5JIEz2)J z=A)hjuNYcP-|>HKTOe*L=*SK9}cD#aX*V z)W|etbyjz*X3KcvIoVy(;C-blZKQ<@n@OR2J6qeNFsXrW%%!QJfq{+^9P}>*t14R4@udJ|5})Oc&($`+9GfDR~9R!$UgeA_76(a=Bln9zjC@+Z>ESJjPixD&^%AIB(;@-}D54FA+o%KCuF_|;w-4Ks z;Ko{1YebEK8?%?D>5EhX(Xw3QrJ3{v$V(E!dRw^)e_B<{ z24b|#r5=L1-hW=c67T%*+zN+ag6I`QHtJM+#0)5$G*IqX?&c~<-9=!vW^hh(y18Uf zE+i}IEJ8|VR{6R0o2Mi(=A_P!nMYc6wO12lsR{hVk!tvaowK*u@s0v)-aRAAUF!;G z3acaWO8!|N$E_7^Rx7t^Z&dDb0lN6$F_cJYy;m^X9ho zNt0$ei)cn=1e`5XP!X5 zz&pG1yWzobPJJy6!t~Nj<-_#l>0CqM)ZqAtUN8wRRrT$Gh3QOt=`&OIx*vighMP1r z(2U1};IT7zNawwH`n^A~h`XVNE>p~41vQ&PO)_K-fM5@)%8;M zTXJojnr%*aMCW9Dxb;J5gS|wzTVKmT-x?VeIDbYxQz>0Iuym%rDl$bhF8V{J8m>DR z@~Otd44^G|quN@(KgTX1uEkn56#hMUBRtGP+|wv08orCY%Q88Xq48ktftV6cbw5=O za!;yuZNls6I4ceFu$HTb*#lr!#87(A7xfmS-db6xK-(B8!=fq$$%ukm<`W1z`H4`` zmr@j%6(y=Sdmc0U3HcB3b>2~mNRcEi#SDswR42LUx{9#%d?hX@YV0;luOmzvwM8j^ z#;uqkYi0039B@%hWP#j$c1R*@0AU&7!3-Eoi1~0ip~!x)?+}^mUrLeOOmo!^7(+XKDq1RGVI9P9c{Yo}E*yi7Aq=8HBJg`I zw8L6M}GKnzx@}yU@ z371#3iJ|PV$|{PkLY8rG#M+LG+3~};?QCy8Pj6+pMn=hS>L)b^}3T^LQ z`~#5u@%i5DU`AZ1O7z(sW&=P>q`A+gdO^3`Hhg#MksDJ}BqpP0sN3w59XvZOhb&Ky z^nT`c@^=aVnYCs^KmOJF&-V^)9~fs~CEqkkatT1Vu}Vuqkf5Z75lVqxzOj@o`?eBk zN_zU2RhC63SH2TnDVTG=&uvty{8OhQ`S+R)%cYM-eOjMTERI|c0>f=z9ohL$&QjDj zaZ0imzPMu;{Yf-sc6BwA%y)-W1JQC{hu=}n8AshP_zWVf&d~5uk?XoM^*-?woa+*C zlYi}=HT$51)zxfWWFwWeI#UKNGf1SnQAisgQwlfaTzzTIF|%lx)PYf++%cjOjB*UI zUCs=yp`POs2_16J+8#M5#TZmFNuVWS|7IA7waw-#@4@6g#%5>*f8xK7P*eV?|6NO- zm?@cv7sv|N#C`R<4LkT&#%Xo4aFcU-&A=-+Yh(nSmdruQ5LrZ0N6@3uMOJha-ttWd zX31x8B0@t<@bN#;h^08+C)0zjx5q}^-GPW2a^A>xy)-;O)C-c5p6>8@Y`~)|xabQ(u z@BQQEFoBu!Zw?4?_f0qoht2+YIy%T0sf{Q&1R}=t)ZhTVbV78y z()vt~U*mTVDlG~ouTXmnRFs#MIP zjde|Di1M_C4-u!0h~*Mvw5|ZKk)IgoZedc~W#ygn4GJf6Lf(GVSHEHH``*d^cx{rqQ-NF&tnoxN0MIjVCf zP5F4i^Pr>KBB0%;;{JupW41?A10wK>=4}W^ppQGk^9S8QIFE} zUw2oE%|oL@(HSgTrl4Vlb z{UeFr)bJ@#Hj1YdbQT9!e4ac%yZ4%Jj(`{vDHb7{xg|J7=c|R{BHV~K1g*lf1^xkG z;Y&H;X!@kT#S{xS-)@T}maXsl6zj9HdEmu=>0uJSxS!)*BDp(kfH-FGbwKCA*l3pkyq^0+G~>hT)d(s{-RDbEw= zUfEdvlCeT?Lsmi0qF;9>aEpyPOkm{jxk&r70%DW5lNszyHzw|B6$fJ(ODMk#qE+3y zjs-zTMmN=PYdQNDr~YHe-v#{9)mBa|mE9)gxy7MYt(@-9kj9_IV}(y^t&;DR{Ff6m z3+sc(H)`{MUmbvYWS*Dlze{%F9M} zRtK4}wc}=HU5P|BqfxrwUJ;(Um818d)DV(e+g;(R`MuA1rRE$_lQ~T?oL#lWGj%dzSj?3YqT{7qQFtr;7Oq)O`tj>fp=d^F+>cet+ zMJr1ig8rS@%BTTJh#&Caaq4WQ4f@JZHgg``AO&z`(~ToeOUO|#0|GGFXj8h4Ki;H z;Riy`9fn1C>I8-=?)NiT4Vf`lRbJgHNob#)r-zm0_?TirHa51>6BQu1UB{gcv{569 zEP}D@tf@SukoT3P3L%KM7W%5}nmlm2Qs=Rj9rwCSN8FOqlfaKHVY+3F9XHn%gkcE_EnQ}ZoWquVnl0<1Ly zRWb%fi3HQnc_!x#)-XIy5w*ou$`l{+*}EsIWd`C{3cP^@Te0aDH{JV?B*@CGM4+`+ zU#9=-Z_pKnhJS#M_}%+bPbGp*CK)>CdqMeOVku2!=sYf?w+Z`B^p-N`i(^GiGMqtX`A|Wz^;?G+LH~H&(-1~!@9%})~3vo&e0U65s)Ip)?iy-xq=gQ^MiL)al4dzNrhdXOxN}*k@2_@#v ziP$%6o9~|o2^u!50KjkpRT6fx_=9Ez4t93_MU0LZp!eJL4fWy)Ci9){i%K>)0D`=?Jue0tCP0smLwp z)4gQtCeN}AuWKRtJSGA42nUrc{IIZgk}4;{Ty;gX%;r}_Uu7UoKDYwy3KkM_D84O( zv$i+bX_tzJAyG4MfT}6)Yx=N#Wf4-Y!=}s0vP+-c6o~AFqs}Zu{5&JVtAShx39hQ7q`Fi!Qp5ESI(KKlMM9?1lJtv5z1DDO^{)Hre0E7z`+8|1uBS z?$zy#lBR-ApVBwQ1G02^kI#5&{M9JM$1)ft$C|rHj?Q>asj4V=>V0&ZjIdK~0{HSk z;a#fU-4>e5e*lIT3XhI=YfF))?mS-IKiykN*ca;yI<15FcoO zR<|6k!-}=P?bsS*%O?kBr|A$L4VR376FUAD5ONO^gT|NP?2>)y`Xu!69r)5)&%T?G zX)Iw!q*AZbY~!0w0!B+85}%WQ9_6yw8a$fpj=wE39}_BiU@}{iyEfm6OOx~vC12iW zzod<*o2Zo)1(?eJ131UGB;Yv0IFw3fJXg|$uU`$@LTbbo_}{50gX{CNxkTl)RJSedX| zKiG|I<(v|miGObp7Cs49Gsvr5HL+-4096?`>2vG5jCJL3h*q_rDgTxQ7sxL@q%nxtLz*+oQLwEAVfKyihPbbSUD^_1$F zS=24XLx~?xo_E-{q038?kcfqaG|HPRj^mi8^wXh7-EE`G>Dx9+-CG-Sh3(J6OdQy-51RLj8Y{`yS>4P zOE!sh_4t(Vznk2iHWGWxAt)~Hs8cn3W2NPii_sIT~TKK9Rvv~XfgbWDK50z;kWw9 zcbzJp0Ol5PwahkN20(kw$>iqDABxV3&(kgrRhXc<8h5v{c=mnvz-=f>kp4!QEZL$N zhz^e+UbsW%bi=eu+;*+6>bGsvJ>e%br9X&ev)~lPT zpV-33LnA_R#Rwz4zO$}XisV);fL?9XOZxdGoIR5<+o$xrH4(t(guPQH@E+> z{5M;rnU>Ral|%7s0eC2h|GAVbJcLM7g~vSMqb4XjlCgy5JpxoHr0&T7p|iWB_8$N- z6xs5BvBruf5k;NJV~e$k}gk|==gPOW?d9`Bh)MEyaR154wTKcux8IRs zd4zsHu42vuy~}1q{2C96yzUa!H9a}o)AVPAj5S1j9bsrLAP)ahHX6-eRbLmt-%e%% zBo7BuOGPXQ?)-t^3Xz0cEUWebG}5Ri@1)A7U|}4yJ&<5nnTQTB3^{xT;McHyD*8@g z^5IQb*frb&e(8Nu_75P84D7E^j~T>NgZ#^WgP+5;=ZQ1f2ObFYH!gHkQ^d76i$UAp z62O&cvd=fVCz5S%-y8fK#w>pER0}ri-k>gHViZvzyGeUQASwM#1=@8N7UDqT5ecdyjTuZ(XFB+|8i6kPH93fulAX_vWbVsBgIWNY zCr`E)LYl~lBC`%#f>Yw1{ju(hx`Q6vPWZOD`778n?Y+#cuo03>1yV}OkeO7#i404q ze($8K(e{jlgdb5iUBxKF|Ju?Dr4lYV!t*^-vvxtbW)Qw{McDea7T8qU+^SFuM)9bW zOoo66FM95_O64m%wRFt_Eh(KY+@Q=U04*}dM;p|$NC1LDXxp*DQ*i3TDy5_2%OL;3 z#rtVW6Au%IF^OlZ5D_twOgh+7abDF^R;P|QyR-t$3E3aN*Ose#xBmVESozrtD9526 z*TDVMV4HXbivUlg!X$zyJ9i}aPdJwjfVr!qm+bct(e$o~kF zm^8%_SzR5f&(8`Va0wZYsh$*GNxHQ)A4TtzXHeZ$SxU$B6#1(9XNV8yon5Q!To(9G zr+tf|$se!+SY5jFxnH1ks;Lkb>xsXK=(k@k=KQq&;W?Co>f~Ry`}p|Ltgyi6OWvpe z`!yn#DUFgzj#EKF9IuTk|GT@UvT85(8%o`ovdRJ**bUu{=&P~z{c03Is$78_<_vy- zPURJgGL7Jr4;FZ1*9kN3PFJVfV~s3%nVpOim9X%F zsW;Bq*x9cT!IxtCCgs)E@`gXm;!NK%WzYO!&yeM}_hRBJ*&$q+52?sV^1#vMJ zv`A_O0VUH4D*dkZPPC5Gqw_DJv~NS>PQE*ak3aC0r~nprc&C#8SdYPu{qJnhFA9nD~_9)44t z;##Y4s4u;weD7i;wVAIymzop{AokjSk?Q(Dww%%m}P`n2#6|_Q7Y>vRH!>uKh`fP@Kd;KM(8w|%KM5*O^T159Vf3$~Jihux zxHtKc8T-cRg9fJKhx`8+DWWvRWD9BM45+{|1a?kgN6dXQi^C{4j+3i8vGR!YomiVD z(Nq;QjmY=*-eb$+WTw4|EA}y zIgsKs+;VfQPp4gO6hq|n%OBd!t5zDZW z%|4$I^fa)a5joLS!1MNGAMKRfW76)+%x>y-Dtg(MpJ#!k385T@O2_we*JSC!=e}w*p+LA;0$sJQOtW5>$@4<1LZe79ZTTfraZMw+ z#duOQlw2!+LiM}C$G1mfwMSkU70YkvjOe%Hhm(0HeT46Evp4-9O}%HFm`;IOPSXxT ziX-E4HvrD$?eT>4$_C6=$Jbil*X`044e8{s-)Dx;i}k%0`Mc#3MB>2$mebe;IUbzc zbbm*e>G;2`!IE+-kw>neV+PV>bp5?Spg(SV+oi>|rI#*lbos`k$_zvSnRSzr&m|`p z7TpCSKMs8t^yU~TJ1#xOhZl*|NU(1BvJ>f2XR5KB!P_q#N0ZK;y|#JVYPM)Eo5#%V za0I2m(Ft01M^@$y?ca^^JMB8R`Q0+^-=njpauH-IEPvAfYoRYUkVVxLVlAdSE3Uf> zU>?$R*QG8bvdKAZu9umEHVCI~0csno{*$Vw$!D{S0-BNr`~L&js7}FZI` zeJ0{*ZxFW%Yj=~f&vCSa0O|Yc-`Z)F2N=bMf*koyv&WzR5(S#23>7>whvEv%Gu7^- za}Z8jbQf=~;LgQgXI3Hzb&9yU7%UI$^{RCDxS4UZ@$PWyk^u_nX`!0|fEPrPMj>Dm zRmI=xEeif?iyKTb*FS5>Dx%eYAK5-ea+|((EiKfeY3zqcKc~EwNy|T!%2HREXlN}j zsHjaNe~xENluF8eC09pjeu46xvG z0lY0ct2Vl_AYIw}Mg{zNcCV($QD#N~m=g8FhV{TvEsa%}uec~7zcTGX_2S$Hb^R#% z^%8;^6SGPfu64U)*CirqK6Ohbq(zp|r_$a!ViSFconp(pI0Z?V1yT?gw1? zdHsF8t7VN$f;VEoVBK)r&8olvTMW6TJ}r5fhT0Fk4b!#Wx2J4$( z`0jnPVa@lb$>-1hUov@$SGH3C?G`2{CMHR4RIM(&$AEf~b#{zUqGoZ3dnFr}&&^KZ zI}XS9>0*!QIIYoeI_+Oxf}(;~3|0RCTuO9&hxxg++C(F>VCfCfSy`>-#8azB@OIME?7!6Fh298dU0K!2yh*jmsL~u;NgpGWKFfo#I~FMAz>W1AzR=#W z*_KjhDd-L#l{IiCzNfE%^X7vS{Oa)t=jR&FFC89&;YC6}x2cR$SR0>fBB=V@04uYZqK+YD9OB}1QrsLIAklwskHjaYV%YDa?omMRb}kVJX@uDL&bUCLK1U^ z2#HAVbj&h?gu!ye!pOj{N^gsd*SjRwgeYD-@YN6CvP*)`a?Dz6*AG*%LO=@CDIH>c zt>IC=0n(K`&%(S7LP~|gIG00!$W{%&s7(wRVqo+yR!~^;u$V^;V1}t& z=y)RPD~z-K{)#?I568AiP4b1yt6b6sG8yDd!2p#sRcz;bf)W=jj{3y)mc zsnUmDz(@*w$V>PUDalZ^6pt|4SA5^jtaZA-=@7;B4u&)(Z9gSrrGdSf3M)NYwRzv- zW-fA&L4{D)cawoIEGk8vC>3P5c?ujgRy|a?M_fLd-z&+7dnbcQZpYqje!Y zzh*UR=eX$=eW!#k8!IiQBbJeBjlS$;1*)P=qW3w~I=+EEDfzZacz4KTl281M?P_`C z*70|JUrG=ARnYpBPyb~=m3uN9)NJ^@(tLF(XG<9-p95p{NqL{FDD`y5E>f~7;E9NWVj-%;%muDq?{ja7zEthjx2agblaetw)ltTctU%Rni))=c$r;8@?11$ng0 zAlrYiq#Gw}$NWy)zzG^v@OF-Wuod$@R#mK)6RCGjDnxOp1V%>K^Bo=w)^9N)cX3g1 zxr0pgD7!&fM#6l}Ca)LnR4f1wR?Ni20*1QXJg-QhHqyuN1`<;lL|rUAUgUc17Y6t$ z_Wd5Ql(esD4(;9KBmDR4XHX_Bv8M$K$=c*nfjw zVY2f60lFT|&zB|J=+C9Wr{%0!=W!^+`HpR{@0wHceD~&V@#V zxQl2CjFD!Bbfxh*x57YAf&9lFM#p|3K(@-_hjBv|} zX#)W3=q-MjIk=ePVB|($Iq(N ztp?4GF^T@;9DG6d?KazsbfqYO)D4zT=s)4LS(F;pKwV7}v)H|WBVYlTM&>d9TCBa6 zCLKhmbYU`)D>!9p^GJ!)D&|2_QiXI@rMs&?HsK<+Ri7ED#@GA(a#W&}xRPj;T546m zG~b-?qJ0Ys`~Lv0wIXjjIy(xKWA86K;=m61XnhV`_>|&5i2NY?kz4q4MoqEK(gd6k zkMWI1e6YVfjn#Nwr$zj9eNl((zQwqn6T_Q?Z`*jH*6^67DX=8EvMz;3OxV8FNnd!B(Az=y8-wk|GA-OTF96Cb%fe2E}M9Yxyc&8YPr* zjn8kp@{>GHLSvJR)9C0l)oNyS@!FV_p#YFk37EZl^kW-xaSeO4TYX)ouM51^CbKyTtE#3f_Fcx ztEskvg{2H1;l%^8HeNF^CrZhutZ*m}mD43jNr4}6>^-g#sUG*L8hlnYYgC&ftkdN} z5-mO1@ug4_MO9@0mM+0uDj$hg&UIx#)~GBvk={)JO}~*w9jaEeuVioqy<&K!wl+3gIr+dV%{-3+lqS^ zK}An+FWD{+puW`KNrE$!EaCfha?Dn>czH5(KEqh6+$b*GEm9U5dG6H~iTr#T3y{I- z+{J~dnWkgbm(S)B$?NGoHb4;|RhweYig`u8AgAr^%h@mF6S-@?&O`-j^Ld;ktq`+kL z1VXWi=Q};dkc5IjVsi&E^(prOgY61#>ZUMRPd_Hx4;Ke8hF2t$cwsKCJtsNoi>Gv_ zR<0H<5u3BDdY(LrT7*23m*cPh0iF+juj%=7wh_`;QHi)bYv2>D<4vBpEzWNp*Aut* z$RRKSMv$&6+gN_JcEp6jhEG~w+hu{@nQ1)XVNb+$+Y0DXd{-4vE>lUHNsWas7 z`!6TA2k_F^d)+dEv;3ryW;+}AV(dqIh9k#|1c+8h8^(eP^}z?Oq&MutF4Cm& zyd}@s)+j764035LvL%8v_Xvidr>`@0I#-gfE1X*v65KzdU{}4a))%8(mbW3iN1eE^ zh_4}7P({Oz0?V_s0uOtk5!1UZUEuZ8&e=UY=m3jcpr}qlB74NsD#`(#UVXr@2%Rgu zvLnJw(My!izrHRZCIG*Zlp67gn>q=MmBcG9Iy^(p3!LmF`@?HnBSp8A%yb zZ}^?!OV&qvVstZh9jBD(m^-u1fTgC)eI{N6kh#a z8~%e;`xNk%{vK{1%eVSm_(xqq)aI;iEueHY{nOFp1ojR;AI*ag<&f>jYQoE+M|jtN z_Ze~yK?O)D_*k{Iw2 zB(un-rRnE?0C#HM@#uhhzZVVEuEXCrZgn9$g`fJ3Zhw>CY91B+t`uZcdIZh|St(l_ zHNN`KXN^6N_Uh6b^AQJJYiqK|x(T*W*nPIAEwPCD8*jjQ;h3q4Ix?Q%6L?f8lu#}3 zQyoeWBo|GmK7(V+C?+UaSutfNzsx6u*w5d-Hs*S;U?k=jO~i*V2zUMDZ}j($f~n)5 zyUAu2pWm5^)$3uWYv+WjsBSsUZgmx`VZ64k*Shf?={9Z^;~0WUNX(z}d*SViP5#%0 z-cXgm2lyv31EV)rBp#J+i{$`!K}J;CE61h0;@;y50i=7ZW@3) ztBOg#pvSmMumtBdN|PMQ%E4ph?K9ID`i$l1H-h#XM=qscO(onQ?|!aUDxqel<`?%S z8@YQE@-q=&vmyq)#Q)SY#keH&g|xOlBB3cjkB}ET@=dwK2$nr|HU901F(=>2ZG3Uh zAdPd8)+>9q-&W#q1RmeQZUNmTgS2dL+uHt$cl0C_?U8IId|7axJ=ozouiLNRN`DTF z9#_BhqMp{My;CWpdi!(8B4wMDGit`BN!VKPumV#b{c^{76zN>IoSQ3xoOfEk{z>FU z0TLb6*FGcLKu5fYLkyT0p_H8FV+gH|>xVQS?!gHx@fW1jQ+$=Un$12kD{64Itf^_` zlaMQaq*V{A_)xBePRsEaiJtwGD>ks|>0=ck4g01vZP3zN=%6$xvoFV22dbS$Ups-5SF74BJj_@PU6~L94_Z4^6zk43Rv@ZWP};Bb($oK3 zZC+E|+|#=h;^3R=TBlFOa)bB3xj1bdUbYYN&VVMCDrdCGi+v?8)F7s)7Po0wy3}zH zNZ*)e>;5mPrgu8`cQS`~dE03Vu>Bt9(RFReJWWl4|^J6Gk?!z z=n#e$0c;4}D1&2gQCOHqUjW)@oboA#3GBy5v?wRZ)+*RXP1o-SfjOWr%Z0@QTf-oCW=6& ziTgn}Nj2&y%{Rv0M&q*pn)`YY((l04JHJ8q^8UO!1TiPq3=$*n8YL z{TpD9Z9u_31qayT6tqmBd%A_8{Ucv`H!%j8!~#hl|MH=D%F73@qIz-3mg!AAx3zI; zO;$kxFXYYoFD&xcH?8J--^^n59Z6anhC5&IS^V+wHq9lt%s~en3Pi67rKKtT>zuh< zW8{8=zK;=jvheCG5}*c1eGkjNgqk1h9^jOCaS&`z?0xi+ZwhPWy3xGL&aLX zr40>}Q@5(M&*5!#e$!I5kQl$2xStI$rb7 zP-H3V+|B!|Q(upiX5+pgm0fa!KqO}X_pI65w~F_(gXuA|7hDkyw~_~aD~ME;_ZI=$ zw7=i$UPi?fD^x{D8@%489jBs3?t)|jJj9CWKDS%mc3y;hwJ#PtzC6=;l3Y0*Drf{a zP*JJdg6&@h#PahW?DC1*Cf|#MdP-I$KD>Kn^AE5uuVaX|2>-dXxe(3fQpYCG(Jy>3 zN^wA(ce26{Bl&Li01(qhYMZ`v!?*wp1A`ZS#t515tY5qTKg7xOl|K8^JB@7=qe$-y zzYG*~b$f3nxSdPJVb^3?y*6!Lyyn@$woyshjnBafQtXIVHbNyL+kb!pIB&Q*(*W^- zXdh4r$5TvHzoB{e=s(_Vf6?@rWks@r0dysLa`xn46Aeu@Q6?4(pT4IwUHbUdk4C%&Yt)PI-9rEqE?rP z^kQ&P@ZTr|Q*N}P1f_gcNUArvkH#h(uxik3{N%G2hmpn`ahVsVnQCH{<^?Ne#bCwr zmHQ}*^78oXb$ru#aAU5qyWLE`E$JH`q1dEo8Xg568*BMVgW-p!X;52J_WPJ2{_+f!qeG;xz_kO6tAgE_tv>mz2MtXcKzPhlnkV_- zeWHs=*htg7KL0uB2iCU4r7(MpaDK3{eML?13ISFtUIswf5m9{f3S8P;M>RZ!jF)X` zywu~BrGBh99lPOCRiQO`FFqTc=FTScH}Nl8L38*%JhiU|2jv9N7J;K-jJTq){GhwH~X@}zywiTca{0mM-@HddRy7- zC!`G0ZHIe^FwIb?%h9{>f{c2`qZJy8;gy|WU3(s}nH){nBd!pp`vETs z9!`S2ATJz*6jreWuaA#y{)m-qyDU;lu`<^r7d^kPDj}F@CP6kb710xNM4hr|0G^!n z9udve;{Xd z`ln3{Q~KlLVcd-5Bipth8{<^sx%kwaudHk`k9rloFx}9cBnYy2#(gB=)xpk$OQ4kf zp_8#gA4(`PORYo=Hi1dv3}WBH26O`-b+^*io8+hqPd5qg36Ls)PVl(j&isV1e7f|D!Dqg5nR6x1ChSspR4CU=EnZgxluFB(FLw@Z(lY`tn;}=F9Q*b6?DVuon?*=pom-c< zHSf|mFmay!PQXJu%C(yi`!RV>D+oc<6nw-EmX^v@;|gK|}Im#xFO6@k5uWDrjj_gKuE_r4Ghp2}I%M0UeB{mA27RKKzIabLG zTSpjgI>9HK7cWZC$cg`D zirUb0Ly4a>m11{3%Z`a@3helf7;6D)N=L{}YRGB>(YV-SRBC+5y_TR*-$?m#ccLQr zz1-8+gnBf>Hsna~Fvl>}^h@5OMzF+soSU+{3E#}LMWYAOzPwm1u}mPokct%$rF;69 zR_x=^5LVk)nvt5k?AJRwJL#3=pEqIqD#^7eWNM|49*tcFZ7*!j#2-4krYZGLbJNJ~ zlBiJ0^Q9YFB%uw5#wj_Tng{sSt-H=77^LgCsKnw+qCCQ+fA5H4Vq1}%H@6JL6vy(D zgd1G4<|PEE9HQ?=>|R!*z{l+Eu4cf}WW68<Yj_OU?+(+9e&oR;IeB*8 zS7Irf;lBWaq-)&N=^h^M{927pOc}h(*R0xCSft`cV#*g|80k@*#nkr8@p#;#hKhFn zZccu(Ez-l;!1FnPTKdmtpa(x0820G6lqcb~@NoAPWwehsPKS|`A#vw&ij#a*&nP-3 z-O9<>#KGucxQ_Zz%>O7l4@b7%K8nZQwMP|EQd{j&MeJF`-lJ%(*47#&M%5NuQG!~r zN3E8kW@&3A1RZuY(P701@_X<5C%8B6^L@_uoX_!hRP@(HHQ)2xvTYkVyTBn7+JGg(0wRquUFke<&V1xqOZ>wLADwX1e z)+`=(J&*2aN`E#1m(~0MNher+W@v7tW3r_+{v42Fq*&+ATfo6HtD_hG%S1NECDc^2 z&?;8XA0mM>O?>=$X!#<-fxd;lG@9LA*8(b>XlT5hweA0oUn}3tvB^727pMpH`Ltha zni;~n(eskvmiBuiI0TI zKDhJcmi4IU^9B~R6>obnlicvWV5I;FrL6lO2al0^e}8(Ec=(Hj;Y?s29@(n6GT#JU z*~j+?9$J=8QMtqIUO#CloCr6om>Ps9ILWMNuWo5TK!Nm~8U9=%m+)laVq3b0Rhm&0 z=-DaKmqCdpi2;RpmZi)-KEQORZ$>8&<$k*>)yY&Pv+yAZ1+8D;(o}yCSgQ?V`h0$Z zpDOT%P%o04)(`5P#GI6KSzd^$gDzy%JL>PW29&DOe)30Jduu9LQNN?&20U~&^$Eaj z>+#>!uhtTC2`5;k&ynGK#ysM!1`FIK=?Cjd!Id;qTPd|_KieC+3)*Twzq~aSbBF7l z(55A44N9v zvD{d4yVbd@^wqNaqm>aAd@}!^CEnw1Dg?O#u)`2tb*Xj^-LL!UsY`8^Qqq)$w|++O z!44{Nt2*;2imfzE{sZk^D~qM&=3|JXxp+)xMqT`;R#roOZ?cw-zv zL5n7we`q$zOX@3I<8%?*(fLN%H&Zc!>^#L^I=CapezTZ2jzuxLX$L-z_z%ST@6+@! z$4oXK)VGyMVQ*&D)v)q3C7@>>IA zL8F=)Y%%WpMgS8fG&rw}%sABtg|n#N2t z2m;_zr8!qSrU&hR2!Q$0wHg)|Z_7U;Vq?ySI_u%bnVJp-P?udj%a4^*!eLguFT3NZ zTtR5BSO7Hi#LKl&3-wKelK3k66%3y8BHFcUg8fN0M4~fNs8BdQ)Zd8X`Y@kac&CmXSd~DJ?G5_x#>6-o1)4Z;%hWz&)8%y7nTOe*+i`x7XsDbJvdn`S@OHi;NcwuMi zo+caj#(t15<;H!H6*uwu@&+n_l<=zBd-Ug0UtoUa4+pEZ)C{>wnsCGGshyfur-(eY zNLSrA^XQ4ryDTs$j*|6v&%t)U`D>~YUt@?jj2?!@T%}-VtBPhh%im)kdLsyC9DX+k z3}j4l+@wQZKt3Fe4o_4FQUm4{3NVA7Ww@}jQ765oYi;LCAdf%7{{&Iap z?N_KGHZe0BEOWb1#Mi&$2H1Dhos#2ltpENg>(!`6c$fc!^f$x#20}^ucH4lz=&>@d zqayheY$EGu1E9k#p@=@7GH}XUTbwaytJ}>QP9R&7bFeFm8j|o zKn3cHA=~dT>e}hH*Qs*Kz9D}}jgJHi*m6YkBBrncuP^zoS~Q^N2|AK&!DOlzt`o}NN+;BrN8MYop@5C|IBGndEtWY72dGS+E zi)7PBWh<>lD3yPODYctTmb>LK@xm*rs-S~1!Rc8ONdUQ{oF>F6I_4u7f)WIFCcTR1 z0zK0tJ4^XX-&u|3`tGO92+Mx4mjjC4lr&bAg3D~ixdvr6?z`o_sC!N_}g6H04)Uh)J_7nka7vd6L5_;?dW}h zv02?FWt;bEI=rQP9#(Zh7y_&>b+0*G%p^v>pw<7N=e?+covBf`<%BPBM3_`C^y z6nf^cP7c{6Y_@O9Ord zIL&Pd^BF0ARwd+&&+@gDyy_mNB=Zb(F*pzs8$hG&yzgpfR?=vaHrrmM%3 zpo^6tspA!=eC?gmbHMP8fC*EdC=;DVFD=&BfY8Q|k*h(Qq{2G299XO>C?SLZ#USxL zrw@i({MC+_KfFd1J4}Nf#XUz3ldr*sD`b%TN=NXz_1^R)~%+_ zuu-r;B_6@;F=6l#QpGn$vRYMwft`O2De4lp_e;$OWr|<)e5AFJ5jwECq+!aaQedb@;G9MS_L zJNXaPbb^H5&aj8GGn<`+#|QLGe1rH=WX##bFEM5sOA~;fB5~Zznj;Cuyyvoid6`l_ z!_Oo3z)@I^nu)be(=Zjn?hEdSq@2ru~F??&&S>v_$>9EZYL`p^+`fs$YPei8J`cq8ZpLxQ!* z$u`nf6tjL#;PQJw;>@iOdsuIOPo$8!ruwz~U68NFWaa|s}^Haf1 zJyt_MH2SDXQNP3y!doU%35z!*b5o`68&(7zOK(b`{GUwNNu-HiGd&CXs&4^%=tQ_9<#+g>$lQSTkaUt;+{!k>+MEQevez0%rG?~Kvb`w16ev+#&jXG~U@8%X@*CyPH z_=ILxK2zv^f&%-XEji43~GehST7{LcZ+B< zbXngzy|x^FdYvkkZjK;Gb}!G@T=PTp%E!F_K$b!7nhA|3;6KTIeJ3)aMpAxS8qWrZ z8794tjVSDl1i}elG?>3)d0M4IctY@~QW!tGYmxATo1Qrj;2UVC>9S?TzoXflYC_~Y9ZGqxi3 zTMlTF7klOADhfE*A?Va$KJCwbZo+?XJ!3{7#?QF-RW&Z5pJX1m9{`qwbDc%wN6Z#G zCj>jHd=v7C>UxgzgGzyV8f?81U9CMDr-G5P@n2`t?xVp?LSn*FAz0xyaoc<|Eq6^y zpBIscnG*u9jV+>x5D>J$BfwWy)T=yrwJ2dsW`HC_MfB%M*|yVdIC%h%`m$V~&SNBb zBIj+wc58Kn+bJI)?2rh52{29GC_*JIuVb7o0UX4KTvnTax%g3DGi+c{Oom!iGU|qr z0E1A*sPL6xT>-s6isH-r0;n2e#LuiUOeJ+M%2;GP$SbtR62AW*DCpm6B8C^;=Iawt z$d!a%c=7&a8?gzaoYu0Z_OeKjAu&FWQz=V?ggiKX9Ig&uIVYI$mN|!P(v%`h)-mW^ z6Q5?rM(r(hlQqSC_;A1QG1)nABkeui45$H~Dg3}SM)xVXEHec{MgeyL^>KJf>1rn( zYLGNVlNWyE88}%1U5+8p(Lj6l1Xb~>> zV@dP?AhO^`Dkn7I_zTH}AQ~g9IN2ZlG{4s)d0%;{s{!=G`=0d{jCUlqmY7nv;(=T} z@`+vG&k;WHljD_iMRa1PHFbAYi{ZAWt6@V%M$6~pz3rQf4h}0?l+1+B|0Z|Wa(`(l z0?VyQq(F;AVMK%-y%tUU--eHsob?T@nxp7j-uAj+P1DXpEjulW=zqgB+3>HivER+B z9ZG9enT1EeKerZd*@b#hWsbeFOSfUf5cw!vh#|O64nc~b>4S$|0aW^>lSb(Aey)>W zb(QwnVWi2gKx2^ROIIM2b4yE=`VFcDK=_@f_lUdrv|8z8-MB97L)Oh26JX0|`;zVY zmF|%T(|@4)w_Ie!re2prvn_fQ;@KXP*>N_Cca%JRBNs}>LoiW7K3N>g6vQDH|9B>g zX?zD&KRDsvtRMnXdex0mhi(#A^$y2h!{)W!B+V)3i09}jF!J7Yvzs(hH4NV$VJ$;G z(%Tey#jI|9-p#Jhu>8vRA2RaR$vIKvS18tkK2q+BYPxutH-Yj? zZk$fMkWl^j zRs2lG$2^lH)z~IEn30T*(6XFQ-@8@vl11@wsN(To;+I58ePKjl*aQ-~gDo>{&r@w| zcyp7Ck(TU5W8R07s)h!Lea)38NF*GgWp2vyh2Q%=xO!b)q9wyU2flpqxZK`&$$=Pc zE&2yVT>xn7PFX|UVYF5SEn&7;^VPX{Ij3#jC~S>}L$)L9hklE1G51)S{4f;$K`^`f zlia%rK|x~0RB4n5sf8io^n@d*!VX$7#Zt!sRotiY;RABa*VkN3!e1N2c>LlcnuGIG zazV3P-g;|MjzT9Y0>i3huB8I{x)=0lQj%Vh{k5&(38bS9 zhR`P@IN4xMkXCBcDtfj;IcoRpo!&mDh1_~s^zKgT%R(1-TqyJU6~~O$-(=ets&iHV zH4bu#TcGq}^t6457P3mrG-YqVKjzd2O3bs@{b0)=n;G@cx3O>8A#cksGZ~2CsJ_QK zI-G^Z4}d%3Eo_!CkHSzYhgCm0vXJ)6vq=_pBCEs14|XY9u7^s1g_dJMm1S|^I&3K` z(_`^No`Yz?9^2Tbs%hF*n^eL_-gbT78a`QwJM!~jokH`Yj^taCIk6I9Zq&~gBhTr5 z8m9rZ(|_Q8{Ix1=PP!(OczL5uCMF5GDWUwhNWJ!xB|VzmKb!54Z&XdKIaCI`uNj(y zrIiI^+!{5mK~MQmT!Lp#f`47MzI?oxLC#h}8T{FXPi$&<++RPyt?Xn#TE_9u zecl1oC1aOhIGX+ZgY4X2ESo>|I=hGAS&V4Ox4@^oSz7Za0wR4RJ`%jQYNybA@&WH& zt`5|ps#uFfkTs8MjM(>g6jJTpcB{|9#F&pI*^;FH+)K z<1QMAss7>X7*sY(N{&Ja))&YH$Sdi~3EZtDi{Cz!34pg%Q?S}XlClK~+K7YE5q!!m z=C*Hgv?i{}CAlMu5Ao*0f9%8Wmkg6ByzKkV=Vx)o$~N^SJ&wT{oJS#c%!|K$$eryId`RZnV7N-8m$t{L0?De4OCp<#-kPrPK!PnW%I`Of)P-tS zZtU)wzv|63G%KdF;Ou!KU1~6hv>`aTjXbKJFJo9k25d=f+9^Tgu5)bQ_IBo?W%H+} z=g<5ayM?LWR9(eyeX7VSY!n`!)@4U|I>$o459!qZqUGL2#96snNDIQ7t}2j)cC@qx z4gRqOEq=PAclc;>aFQ{J#wuI5E9hj#QBYfNHnm2yexedvfLlK`Wlki`FR~6A+?9qP zxNQ)Vz6Hf^EHz5}&^Xua+Yc;@%sjHFs$4y;58Bzfit3_2Ab~2+bGAF{E>E=XDsvQRvz%0nEl4|d8m1}DRYcALng5T za5Z$TZ+rVSAsd-IQ&yK!SgXH+F6xpj#%FI@TdQ5p5TX|osCm}e@bbG;Ohd0v!CE8ug|;`40-x9 z+2S}+Xe-rAK}$T7!eMJP>jH}lwFM?2f(LCRVbLse)6pbH!+sI|2<1wnJ*BBfiW0sQ z-r03SX6bZDIr^3cuc)4(UbTj~D>z~6c{bEOL{233y6Wan6zbC!IU`Y*$Uy35NB)GS z^UH*(3sv~-mwGHYsl4fn9{LU~V$=mZsN>^xwaD76*;GIEBEp_xP<*}6GuHDfhFSBj<6>YPvqDePH`}C_jX?1XIehcYjpK4_1Oxv3!t33 z43_XVHpiJb>kL0GaKhQiytl`-#+Cp#4t4cO)}zfhSGPPV0X9{a%ZWUH$;RjV6dcEu zv-jpL&nt|)c*{#2A+%0Ew8ERH?+5ofCj?q7eT#WFJ=UCQ4x&gQBL!*lt4Yt>IDLI* zBk%_Z7ZC5mXpipjsct;77Mgf5cIz9CVFZNt*q{ijUTu{D#-SGZ9QqfxwJx+L!G-Ew z7MeXmPkZbPq?+sd7OK0IC}FP>B@q(G^}}9UPqG{z3n%`j`ttJQ`k{H=DHo9qD)8&e z$jfw&R_KT|OqDU>X@(8Gts?{3PbC&+fV{ae#zt0WX!@q^;GKK}Yr{KghR^@@RP1gR ztR(>b{LliZ$xfO0xKf`Rdc06nku&WB1qs!cQ12qhu< zUV8d2C*4s$GpUgT`X(PKZ7>#?#;aX%cAtUT&Ock1P3R0h|ArV7OI>>*WDZ||yPEQ| zrlrVgm~Eh~E8WdB8mm>Jk!JPUoYU@FnM7?tKwEJY;WN$b9~_E{Wj;%&lODq+8<{5| zVGbHrYQ1J*P0sf5i6HUJEl??-`@9bwcsuTSOt)|4whaQhg6KW07bp{R%#ZgkT^Qil zIy_Qr`|8#uWm#6>(achW%SjQ$z6n z2TG!tO_)WRLms?-nI)ahLS^uJY^hM7GX2^szbZjFm5q_D-O)F zvqzKQv-hpSC9CntoYWOsQS{f8F66L}$qo~3-#Bu#Rs}HWxg#or4*mnhoQ5cI_$zTt zNS=`5bn9kQACMQ?3SXbP9Ar{G*jVZZf1y`}oQp3oD0iEfH+{=DD%H-8jmf0Nx*o^# z8){ZCYhv!cd#vF5QNbFMPPPM7I9PXAGaU-cs|gtxE1uya>8*jy89cwyE3roOL6G>T z@FU9Qh=a)bt`SQ}lRjfWRG_e2e#8)OLCEj5pe(B&^I8*c=i(w2>)9t$jGoew2cCZ3 zz&GnXQ?d#+P{<^+(r!^xC}W`ES#b@^n_{PWz|U!}!$kIsZAMA@5rOv2k6V#Db?HGz zh+}xAN}upIpW4rR5*-kj51%39Hk-VL+;;Sh-#tbDf!_7-+};*i7Z2pTM(edtY|xCF z(u@t6HNl+o4{1{@j_~W*_+>eLSPR#8y;afUR2;=O{UN5TY=yu^x4Sey;pGG2^+xw9 zZCFV7a2(g0{cV$Q;ilCc#qW--jxWJRgU{((=kuC${LaxcSYh;ZIVhLK^?QQoBg<~bMxzmw z@F#L#CX3t&-}>3j+OfZCtmNqT3Jjencv=aUVu3gGIYwW$-tkkAq=v20V@t9I<`d*2 zgH3%U zsBO=YMZ*;Zu-`|Ftm(P@;7%XeQCoc3*w|i zTD!oK`HjM3kCIxdTgzebt)@!W-z!wCDr&8#^WZ?%m-mN~0zLWNupLD4`P;DkoHuM- z(3PdzslilO!7}Gus1X$UyHwePCd6+Bw00^1k>W#cNoN28W=V%m zB4qFIxc+d@3-YFr`W3bKtKfBJ@QmuDpdiMqqBUR7Tp={e z_TwMeLelqz-))>nu^%cP@8uvCpqA!ZYM)cL$v-Sn`gNyZuX^9^srfe2%@zfa1X?zh zWT!vZB_{UWnPf$*0I{QQ1;Z-^VN-4lVtpWr`s2E)u<^kah~kWQ^g3zQt-&j6**Ci? zE5vd#UU6N{dgm1(3m1Pgy-G6faZDMi6H(c}?S4c!y)P08h|*oi{rx>1L7rgHv-VI4 z@zEJhXrMUuCjar8?d*D)L`M9Yt7OgETH!Hz${K+J49UOo<3b`;sbQB*zkoUHrFinPa2gQrQ_TIU!A=`NtGQ+q{7Xnt$5`k+S%KB4%#(E zWClKztbvAu%E6HlvFeH&LyLRaWsPX_I~M8^6>yPDmZ(0_?|kJPd*LmSe6d45Ul*PX z!UKgTstqaG1L?N1?75-Hr;y-7Ty-}?LPG@@KLx;|aAwH{ED6LoBsMqDm>4qKE9Vli zV>cyQ_ld~WtQ|mF8O&;l$%u;zxbXX8*rN88dKv*efsbM?*%CnQ-D%DTXT4lRa6RD! z&uS7;Ex9vnT~8dtblkY&zoQ<@yt%d)v!XgnPiUl13~+0=i??IZ?+=>9X48Q&@Romz z3x;kWC9)RZ=v;%huYYbILbvr+u>!woY4{_h1NO{IRiv~Q{F&}SHWJ=+LfEVuUS{`@GN#x&AL2T7!h#5IgU9_y%>=Gms@@LTqNmBgzoCt!+d-ru znk&RRa#i~_s{+pdzx%^_2a}XbXFSt6)R}pon^bXXCFeqLq6XvJhs1xZtaEUH4= zWk_5IUVxCf9f4P-ppTCF4|Ka~Dm~%`2)ZN@SF=_T*b7*7{&_YbG<#xFd?zo|!!UF< zpbuLaIog6{0Jk|m=*efh_yc@$vbom%TtIX_3ZIwPD6@7bOZd_hYEXSgcyDqLPw90+ z&~AEX?Op113H>=~oSEqh`>{mH{(v+mn6kbMZ-#3UzBPo60d5dcr`uHtbrl;X%(^i6 zCQ;g5kY2{(#EW*IXytA~)|JH4q(y@ZzhDj$9S@&eG2Po2(evnxVkcFG%)f}Y^MR@B z-rze>#?ghg5ZL=3$^|SL&fJAQxZjXtZJygVDc7HzLsWS^ z!QB3%1$cj>*yuc%RO5K9~%k8+S!we&?E)kcJo@HX`&ciH77BN*qYuq!{aH@g%+dFWP zcw9ri6FHN}vcO=fKvxqX|%GuIjJN1u_fdoo zRS#>LR#xcoeRiF^jjL+@i-``;k2!PFAoYhe}&1=q@A{KgWhVB%&r=UcPC zkH_Ib1|hKJKz0irIc}H^?v|sr(pi8`@HH{IJ7}ye+S<}{1t-7AW+TnP`dL^0qrXvr zgzzFnK3T5Bu595KeIe+E^Q!KRkMj5h+icpZ4LoS-3v)}r0_v7cS8jD*5D%HYQgw=? zRSi9R^}r4C6h+*fUWMzoitiu`@d7=JlA}7knM%@pE41RC-b-Hcb%p9#PDASCv5nbc z6RGjPrIfr7C8DWqt7<~r)4ZMO;+E!wXpDL~T2$0=)#d@7$hi#N>_*qJ0y28EC zv)w5krAbGQe0%Yz=kGnNi_uRmjX!`gA%fS>~VOe@g-}wUaO|kmM{hR0Y!B!ug_H``R%9P z-nkzJv3bYvL~#qY*{GmvHU+ccVjM0)1u~W3EL$m6c7zMO3_Gi# zC2S=4z1fIM1?-G(r!ZFO9uhd=9g-mg<1=Zk8#V_5ERj%&=n&yAm?Xdgz3BAy=tEf{ipa?A* zh1MC~o$fvaG?cG9X#IPdcNo*2b~cIMG)W?C%ZiLKpO6TReZnclW);(=c*>}Sint+} zUCeThq7K$8fJMs<4u(m4^g<8ZV=&7>w|f$F!F?24ZlZg-5b3&(HUS_EU($^82>0Avh-zGwt`_(7!UmHgja=Qz!j3(9{sTpyz9)Kn0%DRq@Bk}I8@;5RtSVu^ z7tH?m-t$Jv^hdYJ?1^C9YqVDlIT?U+yLg*7yd~i3J0~l+ZTE(xyF+RuT+o?8@_7GBV&6f`%Y^)ff$mPY48aCm%7ts$J%mRmc z;ky2vYmN{ch-mNz600Q&9B6w&iEo^CPomCu)z#*27D?#UnN)z+l>jGV)U!)s`c1~{ zw=SKo4*v7Q9TGN4Q|jzNCz@CG?u<6p(}_brFT$2;QcZ6CJ~QIre?#CP5GG=yw_r6j z{vD*Pd)!l%6`V@Si9rp%>m*gp-*1*|x?$JAyO+d3Jxmjf&1s?13l!X-G#T{OX;;Ie zk#U%Eq2(s+^91kpEPWelO3{Vk{l{_4Z(DOB`XFtrd!t~VmgtDOJRhTik%;wKf9 zydKtyih$=LYry}+Rf5m;_M?SciGCZccK}CGyTA`)4h8zB)||&%7pnFxz6B|AmHRnt z3NE<`_jof8aE+y0p!^02VYV-o?3JyBwv4ZghS^CV=4eb^R%5+A*(emifGv7SOgour ziK%OGpmTE!c;?Q7>%x|JOG7#Bv>ZSS9Ef;NQ4@o){D2$ak-pt*jdz>=QuZ{vV_p=Y?X1MKZcD_*}kBHjz8JwDiJ?9$FNx2j=3v(5YDz6rnx7?Ln z8`(#A-#*S$Md5%Bz)RQ0ew|QTrgGbI+2Rp;?=A>x+gU87%Hx;oDAw`w7f@^%k0h&6 z?hJ47jSOf18^88grZy=#hBCMCMp6uliu*zzsCL}l`XSK8u$)rATcuU z6e2r1n*IaHl2K5GKy{cboiu5ecGc{gqp=q>`AJs)%J`7fe$EdsPi=lI9Yffj`Nn&7seU* zZagQ;t?wxQ{}EDM0`WLMpP!xWd!5qLDbWkve)D_GRhzo=O_O}i?dizH&wmIfvNIPk-$VMNOn-V=30w&HAsF*;w93dh6%qDw3%ddWZ3mZvUKN@R6WRuJz%% z;St`|;<>%l3u)~F(Dx@SM61(%ceKa@v=yJ+vSA?6ZGC_2plCDY%A_n6%dw;i+~VwKs$6z>mv+iuup|rBCsfi1cqdw%sm;F6?3nG*=^`o@ zK_;(v@eDm_F-Zwvt7#ek7qAT1@~ARWbYjxO3iWT1EM_WCJp9x(wDMt0a!%y>p$6hs znx6#nl*Y@reoyHzvsi~zCFCgAGC%Df>{C_-j`nD4;%jRgVsu1Ip){G|Z|ihc6Rdj7 zz-7DNAx?6Ni*JqKY~0bOJamTemsKaqpvKj9h9a~kq_$>J=5)(>u$zYJytg0$}1rf&P3C| z0!e)D78{94y3XlT`Yh$8P*O?`VD2kk0v{Q!7#G#o0b?W+|Gi&)L;;YW8uq`b+Mj zd+y#4%QLj7XTDJ=<#1C#&%^99VL2`+CN}o>3=f@l`JIc{NN~^4Oq=j2o~a2&qf{pr zSz1AJR8>hyK#Je%h<9mtLZVG%o8>t}L~P{1o7 z-S;Ex5*w8iMB>M_C=h%gnq~1(w8rf`4U}m7Tii!7~5CeSc>2Va{T&<`NztT65~4IoX`Xt^#oYyS`a=c7y=jnh;Vi@O5a{ zp%XlZ7pENUIq9lA^H%2J;;d{jqPf1QFl>*?t$V>`rOBOv=?Wp+nE&>9_V6ob3cN;K=dc!+mD{9P`UsJBxJL0ex^g?&|dWTNnc3!Wd5WG ze*4kLl}54_x%}aU`rj93+M4BtlbfgzoSl%o31P9DlMptoWU8e1Dg501o{yfK#5hmy zpX!Fwf7eT}wh{55bq#=0)0~}5|4S_%RVNl;S$a*8;uG=sh}_k@P`QtxAwEJn-c$8{ z06gXG+1t|RbJ$Bh=;mNFrY1!yPAWvTfgE_*%!SdV^ZEBG{c$m3EkkL7hLm=+`m@Zr`tkRAKU0 za<=O2+TGAkdySd#mwt2}v|J|>Sewpt5eg=!r~~M%C^*RE z>ES8&!gnn*7$q;2xQUV}O9ZP$hB|F68D+Pwh3)#3-50S>otr=`p(fznBKDzQ$mCi4 zl_i}$_@*;!<0;or$Bny7Vjs%M8sR=Ip_1A0GwhMpU7h8|0Bt>QnB_<5oBVWyluh{C z#-{1rUR%!{AsQxoV^e*TW8i%Cv4rXEFK7!II}*mGYO_1_-{g9Q{Ovdk`B1IMo5X%j zx-H;!=e(j#bg?Y$^QT8kJGRE;rSXD{!Ag_}-K0r=-OoGsN|1dOwEluG-tt%q7QDs8 zjv2Qw@T@pA#4Nj9nWYHK-0zi5OT|D0e4!WmaMF#vGZNJ7rJBDjC2fTd;?ko|Z_FbPBML982^EpVlYYxqEIs zTt`j6*zVFlK-a_Q$p-&w4ABMm9CTPEH`!7oLyPq<3Sc+N7w{UEQ!KE2?S}R$kwTyX zcRk5XxTfCHTH58!Tyajsk9s)+Pn~A~SiRk~JK16(zc& zM8*sf1UBQpc zQkB8~;xLlbsvHZ^V!uH`Xw9mo#BQt&2z-!bxg z)m6rADja{4x;ma*4;}AgPeAwMG+`(J;zL@kL|E89~XLk`}5CK$dOjLJ6Hz=)t z@8vuPPqhofxB{F`geJBV2t$Nj02EZyF-5YiUomgAwMo9bCz*Lp3~AAb{%v41|Iy0u7stl*tgF zwM&g}st**wfTuSDH^f-@NKATk*P=X18`nhetiW7 zpjgt8Oc7MZppa1D+4qrO#%cWN*wJ0I^&b&;BG9)OPu>({tzHMWjK*>V$Iibj-1G35 zqX2dkb@Z6cTf}b^0avQ+6o?kYTkMO-i1nIzV*sE+h%FcI@YgdXjmAU4dq^DV=np}W zAJ%}HMVdv0FWOBEJ8JdsE)s^Y_@JQ2{eN2|vwMaZWIOE|Ui zA8k1WsRAyv>{_d~*H``EO*r)$;&kWUSNQQ#qPix>!f1ds^fptLIYwhW7VM8*(vMA#-p4h<%0yLFd4avXy3$NU~1 z0DXwf*}lQ)M{bPD+$!m`!TJS_QI!z<$97v${anvVe|8Ix^h^fhs76W)dHsbiq_Zwc5k^W5^U zc-ffv>LaA!;wWTb*r1a#yUsb_g$D1x=8W$qWml2Cy{D01$J-0vdG4c*6kF*?I>_J0 zB0J$R6>bHP-_uqmXaS| zNuKk~AviAng2N$Qmwp_^NmFQ83~W9QkAHDSF_4bl8-S*o&GfZFR?R?!n-N5%MjdbO z=L8mdJHswYV@WZ!%Hyz-#>G822sD>}O{n(n=>zHc>nl_sON3$!VukZSLveZ-$ zi0O1|h#9Ge)mW!-s^SJc+YYQ}jA2fNd%x0>g%9pX5J0!{R{1mUP1m#suo8$D%lBKf z&R$sjo+SK)xPI*?ckwK!u6|KBFKBoKmv$ZznOXe5m6;CYKz7%A& zjxArbGxKiylS9@n_>K8NvY*f!t#}%KO`lmH$Xu)=w($MWv;oiX2+CZLUL=pU#bTq9 z^G=K8y_YT;0zp*Ej%s}RuD4YfdX>Lrnch`HvIjc7oM{wWYVhdnB){$oSMIQ(wKu9M zc-IxOZY+q2vd4r}wr%IzSl4d!_J;Qu*>DC*8#@&qkIO1pYH2v!Z^(>7g=E+|rd738 zH#Xg;H`7osvrD_z9cN=FYpNiT$MmJ@T;QudBi!9MhfRG#l^5)8O^QPn?G$~}e^~lw z#A@$nP3iwAI`2TJ|38kOy=O-fPAHqqB%D1i`>YT`;w~A{*FGa;o)Ot+-`RwWlI=)E z=a7+Xol$ny>G%2l>ksEXKA+F~HJ;DsLzW}WsI?!AZJu||T4t~r&!C>aCjHa>qS&j#g3 z^NvP=jBST{%yW+RiEnQhwgM*XOITk9;L|c)d&=} zTavxpvgI<|EAvJ)0SomO1JFOoN7>Ye)z@bi3I&t6gb9HWGEhk7@7~l5eFjpCJ*ai5 ze;-&q9Bk!Nl(b6h+5=UT-k&kGT~z;t`Y+p?W^ZiMq9-NfHj+d9E-n}N?|3%LfkL!I z>}ONHe7|$71)VrMe=a44Ffd`^Qi7!)UwVABKHM6;g1Go;=!4@{o9$1k`-A%1{mezC z2=~pHQiOk%wCM#_s2oHG_;@jKBE_8%P0aXEfQ0CMM6`D0Tq9a`jEa0RV7a4sI&m(373gjujj?Tfg`cl#z zeNR(03~)sX<@r_I@7XLSLfNgVBlUWeX@?fb`ZPwON`Qy0Vf%>R`fK~DYLp{iW^!^g z2&^K48axs(?DhOT*vD{is(4?0pko_6MW%o3xwJOsD1Rgu9IW-5|L6o@k)Oy8X zs4OUmML z^So3#DiO=pN{W3_p4d#FAkpHnpwjOzJ?0y69umb&kgdz^_u_O$&qRF5?WYmW>zfIj z&{qskC~zz)L<)OCD=5T$S1^#f#1V9C^Xlw%2JCh_GM!}k_Ir#UxdGc1x6Bd-8z*cqjN3Cb7)i>Y^EzCV~i=fEXkal)-T31UxN7 zm|fc@HzJkmG&mTzfSyO-SWY?pUQ5lzpu7vKgZ(-n4v-NSBa(!D(DGlpCh;Z7??1t) z7l4RfYgi<7cBEX=woT}DuaXkJNF{8msG9oOUuxxw3JNZf;MKfj^XRblWZUvOM-k&d z5=;5rb`}qcr`jnn?w)z7BbV@wSdT0=QaXOv5AyYc8P*|{V!6gp>xE*2*O(_Wh#Ewf zk#5;Pei%2bcf-p_|Di>*65)2Bd`qmo^MS#X=JGM!9F=K>KjNijt40}k1x7W58LQxM zF!lb;*TI_~1p+yUTJV%S`(~D9WAU zlqDk&&hhb8Zu@Wks##`~$S1t%pQ>kQbMDWG1iFVyUbEt+-W1+D(wJbN*qth_EDdsp ze=Ti2r=d95-(2m*b+sfD*at{pSRQAq#-j)-OU>dT1FHuImr%&l>D>?GeN+e)u0i;j(KglB;>`LXY|Hs=_v(4bRc|Rc;>Bwm zqak&&d7|$~ZxvDPs~(-+9JleqxE|f+E193zn2VKLf{<5+Yz) zH7`6rHO4S6xm-Mg&xGv_o7s0&w7uHNe4b>X1yOU9$L|IC@6BrgDY~>bO}1q()jqDK z0Zp$WL~3Jav(qTi#Ht=d9c|RWXxVaL4^Is-n-I58v>XIdw`O&pS7@sl7@c~jH=CBnV*~KB$HbqtPb>HiR??!3$*2r`!M%D%YMew8mWJX^n{5*B6NvjLHzyU6-w7E*ct!DB7W zt11HS<^tgJAdWOu%y+M{8&M`SVfPFeV;=V2h`nD|OTXB9iQU>Bh2d_P=r^2n9|fPf z6%%L!UZnBgxBe2=NRJx=Et#`ul9=oVRucJbCV7b7{?_exK-aU>P+jMQi!g(F)fWc0 zDEL_m+{dSK>%5#504piMH_0wTBf5WY^K3p`dS^lXW(95Oe8_eZ&GPbeSC3k_;rbBJ z?Km%NnQ63URqb!(s9{F#GGV&zA7R+ zp>@V76Opa4lc6U*m0DhBgcBXxhx!2JxY4Y&RlCv3a7)I$Z(pvTaO*MEIVo>g?$!GF zGHj53S0LnKg_X`D91R3C3PLJ{Z#~xL3D2SqZv~A{6}zpO)?TR%9cp^Z^C?7}MTxoT zrdkz~k>m|2*75`2UP;pSdkE)C~*pi6L1cTduNDq}2r{iU+hY^yyGKesRrTt04s@4Usbi;xIEZ`Y6xZiK=_gOO+&46`N(=1 z#qS0 zc|BZeP;L;a;diTL#Lbl=NZa@+xzJO;$qP3D8)pTtR42iBiFZcT*vvO>h}yt?QJz)4 zsjS6X-og*G-!-b)6{)|i*De&X!9v#0&FoXvIVsl4>nI#Kq8X_6{IDgW+rNvdg~Ae3 zzg?w&#bE8?#4bgX;L*K4FMavuZ!5yqf;aN3g8h?yH7{)iJQ^naa-cy0wAoi#KobNf z=9#bh?vLI7?29&Ze<)kkyGnZgN!-aug8O22X!@_;4VU5NDr3koP@Sd&MC}3jTrOK9 z>W$d2SV>IFDWO}Y)B|h?6B;eI%We{{v7iVR%@;5mM@1cf8&^z|pa*NXWTxvJPfU+t zrAxh>2k8eXdK)k$hfAj5b5&RT#0USDLi7;NNeBLa;28X{~MNcJ*)&bAq5VD|RFsrsFV<3`ls^3T$u3Tz%$odF)3uT4l5)(wV~R#<!ITwB>#D{402@UNzF$oKL9fy|QM&Q7^bAFo*(etCXw zP{_76$>rh2xY(NrpBM!%vYmJ(A4wG5k``)*mq)+#qPM%=wy_87`0Xz*U^u-hLRkFV zxoW#!*<|SJsbas8FG0J9o6p`nw!CU?$8tpOHcqZ%(2 zrK);Qx;JmImWs{!T+Sx#{+&5>BYY3+`$_oeg(GU{RiB*|K!R)jnWI|Fpdp=3?RF5b zPJJs95qE}pcobFT^W3+`-UQMI+QTA`H-PESK+R zd`L02V}I9i$S=Y~DP?%!_d7Zl@!0R;qWx&;%9oh_3pEw;2i9;Ecoh;N`J4>9US!2F zdi^?w&ruyvfU?CKR?(r)x+gC>7qW~q92z}Zu$8DWduG;VFLOJb=(piTGTq4~J=E0u zkNqYHhdbhn7{dtgdk0=67KpDMhh4N!@BoXBC#Ua+AnOpD39fw_5^S@dhf+!6eT*V) z6^~t9y20il+M{V0`uwwJK!Vr7Fl3t-^`>s7k^B#YKy$Y!uj0U5qZBq8niH=M@B&rM zNH)=@kEa-eHp|Y%JgZ9!UY1#%C#o#uJ)rQtfqPBg-o1Hv&M`-eG;~~DOog_Mjt=kr zf(Bm8{O<#sz?J2ta&}DtcXm@c4$`;Itp{pvpA{&X-rGjr&e?n{@dcbg95gWDLtp<| z6XLmo^Wj4t-f$p<$F~u0y7-ArNU}>>WLm&%Dhnhxw#MhOZ(T|7{zSQFP%vSm!5NgC zA>J3Wgw?tPn%wu&CT3|F*sCV#iPhqQA;IodKFhguC6VQ5kpmw49L%PewOQ=dS&^c zl9q1em6j4>ez&JQgmBr)A0+9ks&rH31$NwAHLw(`-WCet)o=mbA}&5J<3rBe$9p&O zgaWU{aH)w&Z2GdwgCIpAwrycUA)uOf>%vH6$bStqG3Id91Nk5+wE;QRQB z5RF87OQfOAk0^5dTy+Q6a8FeOfU`BfP#11i zU9B2+tE}I<1s@>pUR&CQ!wsxWz=ZqsuNgLDmRmO2^c>f!qA5RRWf9b#(xtd2!N$J9zYK$Hdg|m$;peovqrC)s*?~pm+QzKpIjLj#W z$y_tUAJ9H~wMYVoFjTEKcG3Z?=9 z(Cxs_x{8rKjf}=bKf(zKbz8>^5zUUlT zp||jUbR>eVd!-DrUqNsEo~%OUR6yUbR=fT&X5};0`UkQR-^XoXVtq*59u%4^{tS?6 zyx}7Hc;Sz|7gM@Z%eNYf2l7GY(Gn5Pi(7p(uh^^YHbpXIzx0SWrn>|~x5LkEdmQ2c zs*8#I3rPycy+M`TgTsb2{KHpL0pIs!rdEvZEV4h730m$6-9yQT0ZC)d2WNrrQsT5$ zHZH@6!1A?$L77-#=ph*8stGt~MSmRSrQD_4xGb4|=`K!fkpTER&LuIQ3Inw03@rX%Tv0u6*TD&!FKp)5!8#fkmUZph|-W7f!xoJp!@%! zTqUCKw>I@nzgRLaVj*+C*gc^ozffozSc`doL2DfHmPT%DZv}uq6@^q)#VhYPH(*b>5 z6k)Xpg`L7Dp&QzSh4MO|=?#hl%eKGrdsmg;*VRIoyO-7H^Cqgtqt!zaoMd*H*a*iP z``*Ca;TXDy;M&sUWb?7Cvia0)fx_`!N=3x%tN-po6oqSl6+LDC>EK1pDeF(KHkXfb z_8xhy(dA=Undi5~Ep>(Ozs0w8JE0j?4hJXDZS{6+O9*RA5HT4uchTur7z}>~w#V=K zoIuP%pYd#ImnXZ`!pinF-gHy;q)J~7gyN3&x7??)_}y}KWCfFy@Q|gGtACrL)}~*9 ztJIZMS@jay1T*MPfYtyDnZ6YNGl;GaX<*l$2D~OmTf^N%H1?=lI0_JEo&PY=cxV%c z;nq`Kv^F^E9v1-4Oab77R(?c5-`Gn~;AmSn?|~RvM=ra~8pdO=dzW)RYjm6?0y}M~ zZDT`{Vbf zcy-f`QndzjEs)AyK<)p(qx8qZ?lo1Pme*p)ZSPM8s@_i@!oO$s<+Ih1Gw(l8Vm#%$ z_$qho_v`CC^0AFpvG9Msl^XM5DSrDaQze*d8{O7ZeMR=Z`EAM`lqG7`w6F{zJqj($ z)Wl~yu6-9|#=7Yi2g6=N4nT=(b|tYOs)tpbKG?Z0-yPIbnVE;vz|vi#Fyu45lpu6* zhFuI7mVKPvk@7ZjtRBRZ&R@C#!vCv+w;LM)h@QZ}T%bu;=cCuf2Zv)Aj!V7(ih1Mi z4?Yjwi3>N>^!2ORjVch5j%c;RE4gg1UnWE|zILEpOyDBZbO^HHMU4PK1=5gylX4P2 zOa<)p{C>T+B}rDNF;OHx*&aI=C}g?k^#ofnxP(|Pg}kNuaRV-TtlW#&_{^lc2 zSh|(f-2V3AV5m=ij|b;b8-R#k)CW^i$u)L093IbtDa#Z0pcu%0K%arq3LCAcA~mkE z_J)TDMi5%jAI}<>(5A8Y{r&TXCOOw%9ynYybCzDfc<{Bd#HLj&M~9}J(3eri+2f95 zxPn2*{f^QaFLSm+@q)-O(l{Own#9EaQLOG;xo<_`n|2u>u>*u4oq}9F*o&ciIoRHdgI_gCV{%DXj#oQp)a_6gp^S#i#{ZnjqJ6`c)PV`r@w&KdZz@MGSdq2=oR8hSvQ5q-mdb(1z zE5MBMQ`1)Ab)(1as1m-toumOzk7l_Eqf8}5!8?%Q#ejtgrP_Dr$^VTCwMCX!N6iaI z{7|yUBjG5qR0-+~cWno6(t0Hy44+EZC0ED6VjrjSrSMdjo?Be0E6dQ0Me{Yf1XIo4 ze|yQ-X^p@}Dpt1@YVBiLm2khl_TPUM9(wn(rx6soq!uD*ta<$N8K zdTyYy^>7xH!I}8Tv7RO}RqwIoH<;mLNpelh(Qy(oqpcaF|41MrP{4p_jy5e^f7LVh zbUo)z@-q>pzNmnhSp!~nEozU}Wi|&ZFK1hUI+1^%=txu*-@#@7%-`MU5Q^B!W-R!k zd-B)~=Rc%4FH>wkKNm$V26e@skG-d)7#TaOnpB)hzRE=|K&F=DY}+OIo9rN|@OM_F z$5yufe8=(uY5=lM-EClypLfv¥}>uauIoY!`8n(7niSl_m2d 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

LZs5vs<4*dn)6_orH;Y;`bzi*q=#N#x6 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

8A%Us3k< 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𝔫I?i~lBZ>)!e1oWMeunx{5NppvNEpsOQan<=bC2(c!FLwt0-$^xAjSH#$}__hesxf@2I&h#u&O z&?Zo5&N`j(Z9>1aPD7b`L{Nq0g!cir1|#}>-NVeE1YTL|{JhygEvVuA_vkgUHN~yF zur1-+Kxz&33NHMB1(K(K#DgCJF z$x*#-ulP3?$DF`nG3%S`wB*`64u`sTg0flpL~VxOk%o@@KBKNhaos@4T^DC@K#C(5 zU9`&#*=t@_HODREha+q2!ae>Y=4yq-P&LkfLf)_^y<3brXnYcIY}W3BeEjmSUFW zP2tECC7bpHy zh&Jcy=nZNBBf>CwTv1!wh^`nPlzH0vsSVR5A);|ZC()JlWheyQb=T{4Z)fxAB25S% znql*ZuP~%PSEt94Lx5ylmA)Rs%EI#wb=;^S=Z9WkP7V`}s@5J$p4)I;v9;KdLkQzN zcoWstWo9(Ka6~2DNk2JVLTmY%a-tp{qELhD8C39v39)(~t;*;oBtwjUh$XoRnGOb& zLDFQ=0ePq-L6v28nV4N2OXuC!-yz)<$Y|>!tHC*vrlRmYlh~e38ml!O^HrOr>4+Gf zYulx5#M-%zNK=th7&0kLyjMI(KZ3WG4!iWu11lxoU|fYzQGUoG}W?O(nwfr)A3m>>pO^2js#(BbRAkUG~5 zIWZfVcG5{Qz*S)c6;-OM}*+d&5LTow-A*8RXmJ<3$ z(?aiR@@*A#0B>a%rqF5PzeZ--9^MRtZ3KP2MKkUJVLldRI|Vh*g=^6)@lah(^lA{t zuuLydYc}*HMNuv^;f?AoEdB#E4W6|k@;G+KRi7AvN}aq{@fTqMHAO+m2a&JIK{eUr zfnvejvhH^lqyJ{ZK1nGC_^;hIoW*_%Fxg6lPc<4=Re{yQL_1D|n{aRn_&M|6fkIA` z|M4g=MT0G$Lwc#+U53;V!-{5=QKt2p^|xVbrMxE;pr*AehN<4jw68HWgx&zZL{}08 zCsl2SN}|Qpus1#EaL4FTbDYU@i--;^c-H?W1%&%G?;&jQ@;y_eHft$Qk0OJaHoK_j z)0ZRBacQfEO}UJ}!)$y5mhYR>a%gcT7+*$^M%#=(tNaz=b#nT>itF4DG@E^?H1)no zHlda>ed|+u)s4UF+pb%g^RFJ$JESQD40;FDWPshz6q~0Pb#+&dT zK$m2mezjYxQEZP^4QB@633{QZ$@+z+j3!P6R$q5W%1fNDvVkt+cov*}NfT#lu1)^~ zk<=hC+=fm`I(Qk*6e^I=NZW9z>#5%+TtV9V{HJ5UTvkD-PSlQgw?*34wkH2|!s#NJ z0qu*_heY_)RiDlMaB4%a$mT_KvX~V0)sdZTnI3fSbDRS;L=wVeBHWr`8F071YMgKx zY`_`G(xCJW_ItF%Dpi{*fPH;SS)HUY1h)aOe0(12e|VlTGIginM`S0K{X>O2y%&*Z z2W#KXk9~1%x93-%F-XQ}Xm|Rv0{P}#QO@rGZjwC{aL-`Dfzv7c4#H%X0GT%%n^Xib z1>PqfuR0nu3=n|}Odnu=#eKi{Q%hVvIFL-qDQ-LeQtHF_BmR`?-J>I&7JOjKQ{r*XMtzH4-cUX6O9yX^J%SVlT*Q# zdP$V9w3k}4V7r>$GYT{>c&iDl6Sl0VP)dG~0HPEaOVb+U>+iUDQLTWENi`r08lwjp zO|zCK{9EouK$yd)dS@bN%t{$fLi@V#zZmc6-UZ-RpejWQ5j%wg^7U%O57q|0-f;&% zsqYbI0@@B%0K2neYkBYxqvKb(P2m@sB{TY`c4}%W&QKs(W0+&@wM)78fm}KnRQrC^ zPzhh$?9l!|^mXXp(n4nawlH2gvT$L{>2(c`Teg7l>%Y-s0`k0!EBL1z2Aw9fWt(cQ zf`tU9xc=67bv-n!yF&}TCB6M3W1|)_;>iGVClmzpF5l{H6QzH$1VwT(?E5rLl{E?J z%q-hbrdGZD>V72ynm%aII=<9Xp!;xNmcO`R`8)`;3pdV*)!C1b22ugOtS0p|51vyp z^w4tw`$Uqj*F_)MKS?p87#`|eAs6xb(S}ymOB3Gw$q_f%N^7fwL!rYDKhp#q`}fIi z$i8LQ!y{9*7TnmSjt9tp=27m7K(xE_p9)Xy+c_jlwu_O!)UYRD*0(mjqX zQ$d7S$U~KEENsR-I{#vz4Wi$SZr@Y+L;fC5x6oCd$U{aMNBU`_Rp#Sk(&rfkj2V;9 zJ5rzH(VJ0-jpA}dVY0Dt&m(OJD(F_rjgumeN`iWCw!YslYaKr4{o_VA3OXvXZF=|x zT+`rY-s!H4WksSZB>TIJ3?(Q-hD=W*Nx3XR$&UqOBER>?RIXvkzgaV;p-SM6!NDlq zi((zz{qpzYToTM>#c~ruw|;=EY>7B)RT8b0_&hQR1~LL40;wG0329P*+mT11yRX^hpr+8_(42yRguK>nDg zJ3>}l9_$-vnwqO=*a*7|~i zm0?{S?A$Bkwc6AQKQ(jrv=82JFx*QxMzFdHYy9U>G)!}%JfV><5=P$10~)-e18g=L&IU$hvm=j`(v(|(IF5bNblROLeck<4ylu5d z1No@e3k6b*Xv2HkI`19c9h1hTXy)qm^Zu?y&}T+DCrm1uK51c1RA2G5zNBM&R-Z9{ zFOWBw$s@={d_iuEJUQPa->0c&A~q=g&4oXlmX~y4*F??&Uf*zd$ec?D;HQwhBr5&@o zh1AL_`L(Dk_qAjNenu6VIHkenb^*R)Vpz%WhHpE^$iuB-*b9q$IiF17IzlZ8FV{_; ze2?qIQbU%{i2cYgp`Vo<-lL1eMsCZ3&j^qte6z$Ld}0?rAJDcSRb@j88jLCe#b9-9 z32EO!$~;%5Hrsm8U1B3_75^n5g2`M3VDC|cg)od6_?@U?NTeTp$m7=eQdbo{Qp(D2 zIP?18`<+l2^b!|mM*G#!?&Jk>6QlgDG6y1)W>)EB)Ltu3*)~XaB~U-CqaPdU)_tqZ z4UT>!y3pm^Q9X*KhyZwCc{Er9eG3*e$u_>WFb&BlVaN&YsG!R?Bn{R+n(T&SWURs@#zbiR_z`U?F( zKj6d%{AI_i_E&HlI)%~E=Sz%)ON}c>6x(eJVgxb#b69A9`{%E(gbc`BN2laRH<2aq zpFc-e$LO6b2Dp^5B1O6gM<@ea`Oj~Bna#W1J7Y8DAL+GkuM_TArC%$43~-q- z$5p?qxc3|;UQjyr5(!iXyi^1w=$@aSLVyd`VSq28?%vPzfU0 zk2SOCz3oVxKx1XPQt34bfLcBKwHMlPUpUBD29o;D&FvPP9jjCtMh*MZS3Kr=CD+a)3G0m`}SZtMFda1zIr6b7`>V!&HW%jw22*)2QEf?I- zQ*dUzSBq6KDE0k9MOZIbypA}1$GkOEB^rISn_esxZmxFcuo-j(B zMgYNY;+YqK2>o%tNCBl!y+fYe5)rtzbp!}x?ni%872@C&tn9n&^$FQ8!^%&g%wFS4 zJMNL2b8U%Ngx}5@V8iP`7^YhfjIs%_&f6MuT`b+B2-WjOwV5d1vh1h1vfif=71 zf5b+2VRhtw3s7F3{h>_;aBqsDsY)wy$3y8wP>DCUXFsRA-)!2tqUcRu?qVF*2|QR; zRe0PQ&*4b!0|@g^ntngYY6r7DCKNlgR$f2vHmJBGBD+6+eAC9`G6HO$L~V5z`>Qgn z2|EgJlLM>-HLAVyU+o;1=h7|i2z|6#Rg8_;!fh3|Yi!GV9QX>q`}`khL!^XNYJ4~C z{gin8TAJK-nq?caLkT=ks7ppdOtmn65HuUrwwhneaX8|GecWQLRX`TwCbfedd5ej; z%^RVQX4WBj>W?^2c=kBA*(vM2rkLpGQimr=;yN50PMHPwe)5%@w;qb5JcjmF+;FzB607nMQ$h6VF$_Xmz6O@L|O z&reQ}vy2;t2 za49Q_1B`z8p4%ILe9t%8>akW{Q>{p)nJUu%x@U3P}1V|C8*p5)%y9TXMo z0Ql}`{QBu}Bi>Ac(n6*oO89>HLnB>Q&}LAx9k88!~I` z2F%-V>)PE1(msx>ZXCj!WA z>>IgR954kHSA;OBtweJ7C)`!NfgPZf;$%*@#=0q)kQPa8?Mgki%@s>6L&tJjzcp!B z6|_afZ&dsC34h2Ioe7wu{75<)9So?bEjsH46=l5MO$@mc6H`d5M9Y_}-Kj$4FL!!` zeRlN12ef43 zQ*cYlGOybC)6>?ahc4OKV|)XQzS?U8s6Weh;$Bpv(^J7Ya)HNEzj|akTJ$*VU9_=! ztcdpeXcrARqjrG_=g;fyO(lsiwhPK>3j=u6Aian;VuYlh z>*8!>NM?1;f>fnc(Hn9n+yM3q6#BQVI;6WXO@(E*op4K~_s=odiYNT`>55D(zv3em zAjN*9kOKX+19~J&c5qJYI}Helvz-knZf3iu+;M&w*uk^IZrl)wAo(0#_h;vEt%zsf z8t}avNL+i$pRI*-FGm;VpCo*Z03FKzp6?yKw0{|jF@$-UnZCpzFBKhM0I!{UsoWd+ zzpl*N#Nun~40?r^jmikjwH<(;rhL=_^`k0uYuh4~cncGWy2RRekj0NVJ0guH%x?Ej0t#SB3=efN}SZ*Nb+VQAN^OC_Umeay~syYXnvFC?` zmz@94Q1#+rgTO7+!Tpo8hP(FHU#@9uchR#bHW2uIf#M zTH=&B_||I8Ri9n%;ugk=*g7(b#6RYWod`t;Q4_%+bfIUiU)#zE8u;=jwRm6I+s&v zU{1zbJGw_TF*Q+*xF%7&r#^P|LmRa4ORch`Vd_JHSxkcqr6PRd5B{Y+uY~P%=Xh<+ z=Foiqi>Iu*JOwcHuPEjn1hYyvl+@Y!}3E!b`T@3AuSqbsp?OmR(+Vp`ov)88Z~n5KQT#W~Yyo=}l$I_B3hiDH>Pcuf1=Z64{v2{7BSCB<&u^ArJio zTbXWDJt4QJ-^=GTFbK-fZYlw18tdH|=j@53w!4>Wh@C-l*wC1S>LPyGD)mjIFV{CG zVn77i3{B2JA1KL%pVNK-x)1WqsfuKj(eP6Rm+}dKt8el;(tjQ{A2ZZ(WjR$=uPvek zeVolkqZf1j`PBocJ)RazON6go9bG)D|ADlRCCLR3M>#Wm@S8(% z^bw2Jd#Swk-E0ko7>+7A=A%aHDr#<$Ia}>FGs$U?ObLi?Dw73{kN^ z+w=7-oF&Dup;?L-vW6%dO#>2@)k2yQmE;rpKGy2e^25&gZ2#sSmvx;sYNnlLg+_}K zbKb~*epq!Sh7-;Zf@^FFKtUMx7Ni6nUWO3Am8n3QzYs(}wNT zfx26s>b-OA(G8A!rN<5KY6F3^`6ci07Rb*XSR0bMkf;8nL!>?c=mQ$~{e8Dr!aOsNqGd6KUdnW^`uv<%k_hN%Sbo;gw<@$T!# zk6)ffwngR}RDd41#@6IcY+_#ysT-=qRhHEuVvgq85w%iPio1Y$v%1QpIlxAknmj^9 zEWGM^Clw43s3MfY`ufy(N+#+gU1xtl5&3g*jM2(0^t{oZBQv~RasOd)Z@h+tmM$sa zZ(#@BF$bBh0X3*B(YO4JoE2#j7tn>FA9~G}A#PX!&wxPW)^DGoIMtsER1d74OtLPT zQoPcW5kbd+I2rO}6v=)xo68pn6l}KIR=?jD5x6>(0f}lUeI-#@kfMYF#T_c|`1T#1 zIK2ctMlDgk`mIT-vZfg1Fb*~$T}dIo$$0MdHRT*+cNIzyo<9CL#+ln!o&^>LpO^$4 zFEPNZGKn3RfJ$D|5u`6UaI)e>?AQnurTYDwcs)PZ#Ef&ig5i1xc>x{v!`Zri@2PR4 z+L5Ds^aV7MxVWv^ALQSUIN80Wxt<~JLFB^$uI`^f4eSObCt;ylQ8?L0Z2eo?^39J3 z*RYz8G>(b^6ch;#l0(3H4}koQ5CDAwQ*(`!ZT~mQH*~$V+>*~cdTrOyBuQ>ytVQ&% zDVEglwxq}sZvE~}O@$kL1SDX?oBqwoD-eD#ra*)!i(gFDrkLUB{yI9Z{Rdj$KeRfO zP742U=2%0O@E?dG2p8y}=^}8yi$87=ssA2h+Lp*<8Q|Q0PB$eg*W3bpcfCZ#pMGw% z=F+=!uSII>IOJJR*6sEQm_u&3vdm*rE4_jAM(W#wjjW9f_^VgpZ|BsCj+iNzEq!l_$BHVB9nT@TT7sLBg|8`Rv z#zK?n46V}D3uMU;EC=h!1NS>1oUIqJX3k!ty7yuO>CcLy37lj52#o&gbgtKhPkK8&GU* za7bew^OA;0Lq|`l<1>4eH5gW*!jS(psDrkogMC+8klN-+di92{3S?iCtiYQ}>DpAD zvI}_X0fd_>GEygqn6SPN5$M(}Q8+$k^|M?mQZu0Ce{ao35+Pg@q1+F|>H0D=h4t#CTGZGvlhuQ{C+gf^kmt16s0_4sks+IK%hPkIsZ>xQ z_|bJw&}$Y~Z5yW|Wn-4QpnN8uZRG*hbu7f3*;+_g+zFlg@l9%v?H2XqhL;99KX5?K>Dhm6vQKaj%C}fE@)d`Ya>0P%? za_e4o(%@iIuv~p9Ehm4e*NJIKwHi|I-AqMf4;(Y6hM0|0>Xad4RWcdem5v^>nM-20dz|UZ5e@pQ z7by&D+FH)b>Ka(J1y9WOdZtrKFlW@CkO4(LaMXyBbFbP*iuM!hAZ(2L!-sA(9^NcG z3{*dL?+H@4rlM&?v}@b7OLUwH_qm``jJQ&+k$cV-1E40%4*I4Asi_>$2>pZC4cK>5 z@lfI7Y>UYI7>&HCihKqxh?6GfH9Ifg&OABL47|(qWPvvFglStcB~cobr5UNcDWICUj(DzPbbDo zuUlqsvd)2Lqaa92r-J7WK}hxyU@Vw;ujAy_hbF!ihjM6gB`7zTREZ>;vqBie z`P&W#@$xn@Bv6bnLA7;6?cro7NwnN;@+6|D;*72)9QG~PfrSI?xpdQc;;HRyk)O&XtIAjojc>^G_&+)qzJxPC>4^lxj zP5c&`eeLq}>0lh*x8EQTls*PSzB%M6GGIiI&5Udh?0bIsP05zfJ#mw@Qh4@$i?&1J zmYo#ngI#0U*M>dhD@K_%I{tLsD0Px`<7gsyqAXV=2kG67SsiO~(){ZBG&73M8`R)N zGKdGyw)>lWtn)Wj_hFvP+tdtD#p-Hi5c%1lx!4rL$7=YQ*WExB*QxJLEHB49?{jy& znqBS_wtu3=ONt-O^g@I|(h9Xk9Bx7elzK|vlK<(P_EOG|=~r-KrtC~@q>k%^znDOBA&9rogMovpMOAY7BI*@^uKis}_TUw9(&G8)3 z?$5MWN~-2+u<;D7JmF-5H3!9>PCc(XD-OSVUn7y@{pEYE6XRJ<%N~{snpP?5TM5sF z({5x(KM7Z(q$;35q1fi7BU<0ek9`AOO-)@K;6E0`%69B^#FuN|wvDPzwCcniQi0zl zrNt-z6 z$9R6aS6M=xyDBz7FFgR8h`S(&b;SPsnW^%Ov(6-6aIW%}p1V%7Om&(h@}4sSvGkTs zjr-n{JV*h*Nw)OY@{Gj92m3fZXnmxu>xPpdN{V7$rRB`BjXyVi%vk>y-Z}a-Fwn2( z+o6bR-Qc^rMrA4knH6fc$b76xv_5Z<9wZ8J^%91DJ1?UvebB0ZEWHUqS>&oxtN5-E z8t+7f_5B>CZL58Tf69fl6IUv_t?lBk>()4z@G-Vb)xc@mfQBi2usBYXv^g==;-`I1 z2~*)VonOFMb4?v$(RoLM;r? zQxix-3#;=(Oh7??9ImjZH#V>;O;s=#{=Lx)g)NhWXK!W~5}tz|^=g`8Bk@p>eE)~| zVX9Bzxko$yG@kqODpvPS5Cn;zxBo;_m?F3%}tb^FNS|Ai3LX0=;UqJ5eK9t197s z{|fMtN)Q>AENlsh&1C0Vpy~ZF)+;==_Rtpt7u98Y$CqAivBQSJwE1$|Dx+(R+S~26 z15PIv=O&m`sN#=Tx9s#`oA|(=`;d`3he$<~3nOalW?Bltqc+}!MM@3p0` zN{EREUyG+odb{-N47G-MBTyGG(?h-&`>@RI+j-cOKE-_M4T-++vs7H#yO%6qN;4dJ zzq&_yCZTpLQLG(l>3(xgD&z}PVdKH``OLgt# zlX}pmkJIH-e)_C`0)DDFQLE(z5%dTfm*j`Pi|$VuCCelzlrLhGNRQB8xX ze!Icsq;!P8@8<^uJ3?q^n&)G^A#@Cj0B&nK*Y)S#`rkkknxF_@Wm~1nRMu_X?}{~Q z1tWt{8N&}%JPr1>&7AKTy+H!kE0?m1ug7O06!eJ<;)!ok=4W-|DwB2*QFL?+3o1?4 zXC>~A2dn|x0<|=AOb-m}#rEQmwZKjsvvApvzxtWU<(>>!Lc!0_y%$lZP1%p-YrCMA zzCfP(%)(#3p_COXsH;wF0S^jkkPuURL7c}0gwP#&yDrO{Z7<|khptWWQ`= zpE3fHy^qMgRdBO&+RVX4?du1SgIF5Svd_WtJp%9pH{e-@gHA zvVVnyB?iHEJ8%HMf3Vl*uFaNhT%0rH&&c277g_)7)_o0`hc`uRx;BW?3#Yexuj3%i zIl5Vyv0;JRx~x|>U$Q&^A(aI7wpBj=b#Ze{$WithP5{FJaqUjnWaA z86Ua-W!SD1wa5Mnmd8x!ln)WofxiUt%ohl8fk^NS^Fti9p4|5}{zc&)YI;qfcwk^$ ziEGZMAPQmk+1#GQ{0#LfkeIJ(#U9Xv6_>&m?Jli@#ez9v1Z$8y$xK90>3iC% z(Tnq|Zr8eB-cR&vv2;&GwXVo62zDiY*FQyC_B`xfoNMY6_ako6MwsKSZ9@eLT53fH zOoUG8$r`IV-xYyktj769K7fzw~FLydot8+CO)=YMYstI#!vO+Ng=T zfkrymyj)HqXvvBJVTR=)+@*D_kbYtVL!*+-jXl{LMkZnr3`J6!X@L0%O1S$=ss&A}SWODogkbX8SviR_D7FSx&SMVEocP-Lo@B8|Y1KE)?21r!0M@>lKH~UVoJ* zY$%U#G4a>T$9SJ)DDHb`0MnKc5O&xcUdxStVsTb=K-Y|ni`mnI4*J{=A?+hEsJFP3%HlFrHrbnQW^~c!>6!F-eX*G+=w{ z5LJogWVSPawkmv-*!Ud&F`SPtN|$ZQUt{$PWnY7^FfcR4l9ZWJW?pQS)HJ)X6$g4_ zRh5UKoh5dyo!ig*>b)0#C}3k%Lu!o&kWWb^MmqVUw#WfJc;~8n?P9yH3|kOx3)&*8pP!am$+HS;Y7@~9^sJiCR!Y>&RtI-VmEC<{n+d_F(UdJE^y`XckMpl74ln@Vo++< z&WdmLp73i1aKoyfqrC%f14QADx1XbeYU1-_G?al_IE!z#ckVQIzaoCxO8O5J!LxnW zlkD1J=MP`HQ2@B(+GOYPZ+lUaXFi@X7Cdk^NT|u>L42Z5jV+VJ+Pd9|BPzrBF6HVV zTXT!mzBU1TwiJ98bC<8KxTjT+*54a81vHR*X2O`Wx%=u%MPxQeyarf`@){Z$^irt%lY*QUD~I|`g1GztB#fQX<+<*%I0I%q+H z=1?CaK-I%ai`6vUGlMU&X8H(3$*^6KD}MCCzoSoOwaw`~NEw*Y5YX@ml-=^q^SR5q z%SLU^u1*8F&KA;NNH@eMBMTO&9Hw%OAbG#YJ|78_$az?za}xjeJKY}zMigShT5SCO zRg^}_!RfD=M@`YNox>}_d)pDW!Cd%M%EL?eSm-;&XSF}Q!xnZ`*?Y4V_hq=v`T;k= zWm#()D<_@MXNHqN|A4>cwe;V;zD0j)J31WQ!#_6e!D9?v4M~etyptA9t` z)1pmAb70TIzXE2RWyvmz$07SfP%d8y{R9y|Ci!ZlZobw}EQZ(yxU# z&Cd6%PK&{NKy#np&Myronn?l6SR;1u-NgTuO#15XKHtY#K=MmeM<#*ymgV6FcQ4gQsOIlB8+lH%&g7PpdDFG^H;AE~y{ekF|3TI=>V$PqA5+(s{@p8RL z^;YC@`52aJOq2NTrxvL)S4MrZjAb+_;!Kv1rg?rT@R)AWonMGIXM_qE2|JMt@#2uG zEX(CGDc@o^-PI5>$|OD-HJpW50;%4;|3H$8Uu40~aATGOJr;{jN24YbDo_YaXgO)_ z?Z16RgxJDc*&a>C(7aa+6@FFn13tBdeK9556d39un{Y=wTE{v2ruEkU&@k#p=xW%f z569nHl+zmg1+%FS^(y$uE5727mn3gn-4VQf`mjJxI%mSD6MBPxipSR0hD!8#46C>} zi*SWe=>|3$uYRdQMOtZI%2#^v<|F>O+I?RGo(ay{pj;be>j==+wU<|QnyJMp2KGX| z^|79OmO|85#6D>a^2gTE;f|u8i;&@hvG6crDE2>)T!#B$K(03FAXQxw+tsB{8;(}U zmZc0hfSxOq8s2&6E-L&WWW-(NsqxfouXjZaQ%t-_y4X`ya;w0bl+Q^u?3I;k3c{O+ z?RT^|8bUP*ohNO{lKW<5`B^9HPYDb#QFCr3iSO^(h4Ixn7HHvn)VER?vmKmS_C^iO zrCxnK2V)l(dHY$PWdp}>hD)9oRqldPE}d^%qaJit%Q?4?H*sRH#aWjQ#%yC#lhfsf zqa~lAlQS{)eF_P#rSucdDeiZ5la>df2U{?5w{HH3J zRw)POh?U23nEIQ|Lz6ZWD|d23+k5Ez*4jFIjEI)iZ6u1u4_%;vtR$rkmnjuVbIHi;4_&7v#Jl4@1&^GHORR$ai1FmcSo5Sh8_&EOYR8{x6=A;wPikzrf zYH)2b+Y4T@aiuz7&$idiyUg{Kqb{OiViI@N_wI08rCajW6W?3AP`bzJT4V`Lc@1;{ zk+xA>gLJu*tj@ffB42ho`AuJbrnlH`{|~er`Yu0KhRnQ3F$kiJ5FF2F2mS6805sI_ zohdzePm##I4BEp`<=a@G%eDqfZUy4fN@y`JpQ&8+xzr_3LD}v|x(co25c;Cil z*QLS>sz19+b^wMOa9efY0K>xJ?srrTeOJ<*lG3f~POeZ-?XI)GJ*t`83V-zeDt-8U z`g;Ie15*nQ{;BZeTbVU*GP>~pvJ|28;VSakHSE)w(lvg*_ULFwXWMWGT%{Ms#UvW= z-Xba-w|?+SS0QMsb72ZWW zo^rnZS`B}`a;;Q+lFG7+Jvk5hTHnfT4qXZj;QM_BxdxrRbR8JAohxkc8J%hzB<|0BfuU? z?o5?CTa#gt2tEOvEV{yq(ljHKZgOtba`}N{!DYZczrfowcOrP$`|)vQeW zO7;~0pk&@K>+`4)C9V{u$M{)A7H%{!aOFpjZWb#QX{nByCODjS8TcYwEPOZh+%J#v42CfCd zPy@y-O7*3&Cn5m=-ku?Kqr($>6@;-oe>}N!pJV*+E6g{23Puh8iOTA%R*Ji6X_|k- z*blt61K9dM{8?h!G;sA8mK_Ks@LMEtn&plE5W5b>=H@an?)O zPqHYnBpNf-0}C=-X=n+rsxIMxGY#H$J^13K1}tsLWBGwQ~S z=xf@&&3EY>rWDyAawZ4ri}eHI;#Wiz6sqJ3g(gzH~3rJu}LuW83Yzj z-03uz{;RnpW_8gU>@47&lWoii`99{Ke0Jp%@b*8>Vh*0!FYDlxqL4SOu14h>zxKoD zMf$F-+=Cj)e;f}gS-;wZ+m)ymBLeRBcJoEtF*ca1#TQi@t-(d%CbzW_aIEl>HVcn>Qx~3UPo?F6r{u-HEti@cz{^}G@bf#FcGPLy=>Dbcm>N39aWqvZNC$?j( zOi%eLQU?PuD6!KanaOG%rf8ficX+N<$xCV?XvP< zxkbWNU877?UsRlx75F*)1N#hVfGCzJ8y^lJJ8W>>n*TQX zvRt>TNO!zqh$IIMqOoXfoB?{M(@~RRMeP~gO+pXh1}*>?Yr3cU-7ufdm+#2V$h3D0 z4>E_VrsmRIZ*MJ}6dBy36jIw%b%UN0z{Nf@Pag;MGZ>Q@6Tb(pY_LLu`Q@-R3*A5&w<+N%FHa$Kk8l}?5OT6uw35_c} zZ8+ropowgDw7IjYUh7{KstOTl@rXW+F2F2et*uc3R8<-q+g)$0Efg!X8Lj1MZC+JeErlE6n?!8I`oIDb#5X&ozA8i4}Sz}|YQzVfi6=kD0?s9M{ z$V<>GJ$){s2Z@KSQ5W-WCj%MHT2Yt)KKN*l%I~pm44pu{W+^Bz|KJ!J8mdt6VHJ$M z^IVy4pQIMZh=KS@(?6SI!AhMWsM;#jg#XhlSj&^2<_SU4cPB#Ie$+2wCLAW~&{7PS z@Y$j7O^#Ifjd{2Kqv$-t+5F!&9y2Inx6~FZs8SR^TaDOz6;*rGrnOh?9eb775nHKI ziW;$L#O|=Swr1`Bb3bqMA~|v&$$fvX^E%JZIY)moqQ7_As8=mI`S@4DI(gzWKYb09 zktuGSGg>b(Y$}OpSzDbhpFZ9M8?{`gV572_k=r|eKdqEbK&t|v=I$xq>(<1O;Kf1y zZvK_`WI9hZ=p$ZJek>wzo%a+Jp(X*SvKv2$@v4098%r!wlyC_kwE3E=@~G@%9tI8 zdt_@E;b^`?uH@xc4zR)`MB>F=;jrYtVqDnXk@Inj-56GGN*1WO-PxT+cJk@l&qDWQ zgW>f@bN$1vz9l=lCF50RWGOF*km9p_L2T=5X*$G3LBHKS4YxR53d}@qbd0$;Z_8tJjWa1z0iBrW&66)9@pnlt zEy3BXg2t7A>=Q#qf4$}0Q#QdXL;BOk>FV+XrXOV~<1DUjyFc27pDO&pM;f#-D-CR|)##@F zckK-QDDf+M>AMgv%bGYs(E#i11_7qy;LDkn3H#*YpTLPyxL7hg&u=Ln*;$u@OfJF) z;wxEKcRd6^396_lP$TVaWK1LYMMg(jg)$jUWl%~+>PA!NAv6@x!8&0V|AH`iXW(J>N@8Yr@atgV&#pps7A}moA{XAP;c_Wlc=n=%!oC&{H>!FC?Tf)OGBJGg zexFuvCjaFIy0RqHQ74VoYsFdpsrpAw7ct+KlD4tDyR$&JOM_&x%?BpTN;iG|Z*Sab ztl{v&A*g-9%s*2CI!_+SE0YgqvcwwpvErsJ>S*QCU^4Ie2V?*bTl>OGyS+Y;Ics3>ma#sDHR z)s*clKv!aG@>1dgBx7GLmWKoK+&_)?ralLW?qCxoPC+zM5i#v*uUi< zj~X5ptnI(#S4^*Om8>ZVM$Y;5$;Bs(!eg$b~F)@#7-=AO*b=elUpFWifRVd^Tfdu70 zS`^K;AplK5$W_Zc0NLzv=SQ@w+S!Np}h@ex+5n@zcN}YL#@^giEFu>KPxJlyLe8=y~5$4;RUFd1gXa`N=IC z_nIWw5&J}PNLsfrz$@>&m#cmRnoYan*Fm3XH!vt#2_d$0t;Lb`kSblg)D>B?Bu>(C z5OuUoVU)2E1Lt7lT=-D09JY|es!dEmh%z)}x^V?t`57^=J4WX6VDLI4Aba?!YshJ| ziIa~hoO$%0Xxncp0W*4)k97{}GYjj3Y3IFY)Z1|f*a6f25d7*I7^SBxOdsab4#6&C zqOv8$(5W0QO0sUON#u!-Z&qu>L(*PgDqF0BZ^77B9k zqD+m2H$fxC5;8|hXyh#+Y7S+zpjS!Zx0!fr1PZjLIb>a)(6c?!Pb~9O7C&R4{G?`| zB4{$UaWFK!dLvYrE%=lFho0L^Ca~DX&s=&zOMzoi=`#|lzGQmWn<9}bi9F@_3 zuR{2Vq~1k;S0K^kOP(y{@2kZ9)oC{oV?VJCyC zeLbBu4^aF#zam`VH5s$L9eTb`P~XI}l(|@3gKsUqp}c>Dlh%f~SLHW^dGUtsD|-K+ zDhh~Q)gFmeNrO;S`5K*MWqI{{+AMbt~d8HLDm)cKD`;oSaGHWa5#a=|AA#iBL!%czxH1R) z!w%QGT`+MqrNh!ze9o+9ob4tV*|s`WP(7TXbO;8bu~7QF(HpTT;rGSIqMSRuZHdD1{qHwbfgjfN_*qwhlri1t(!r@!aSIu|V;-bf zkz={EdLz@hPw#jtI*s60#~yzx=9ahdIS&<|0aSphT3W5fLUJ_udm~|5|Apq*ig(AC zyaL=XoY{vmq+`>Y?J&=GcvbLF1`0zck>Y>Ai6g;((KE^3vk|bMUXpi7E$>k|5Bx#7 z57`u7yCE*yfm*_}jiqNlEJ`d*Z-2bV@J!rfnmg5Siu)iNDg*Rr*SUY*H;)r4kHl1K zmK*E**V4=Yy4$~T=x!O1K+u%1#=O-8mB)6L$5XttM$>(?Z^2ZaC`*S zx~m-ekzB2CtF8fSA_nicgV&mb8urkhd;$@?p|_BC#yPX6aUvN2R+ zV3^{ZCfTzl{!7Yp28s3Co6co+bRxlUk^K6>?RCX}xc%hvbs|MNnNSym4rmrsaL)Wk zqY3Lm^D?Jnl}P`(PTlSCeE|Diz`ydAK}Bc{0pry%TQ0CB^fauciq32CB3mzVmh+#^ z+ZXuLdBpT^Q+#2cIbsuEC84cDes%>ievF!{hB|hGPp5&AK75MaAgT?@`UqHQGmZif zkATWUFtq@Hzf^xBNSUtiDN1noFr0b$*ZU*RLxIvPDoT0J>)!EMOx}k_ohf7XMQqY~ zczW+Y*dKs_xg zx^iI-Io5L8R@g9Hko-*iRH(j3S&t)qzq5nT%fskFQYgs|;FRh9t&SDhDhj~>Oy=tI z2mu;Z5TO&)FyXelJMLsKm{jRlAj&LSkZHpJ#Z)(WS9N^{0-BX?0#4Sju5GT~;G?yB z&vvxhgbYKn(bc_jion4IQ)rJwuRY)AT^74up1PGbng;7t5Ku;ibvmS4sE!*KPeZw4f`Y*a89K}B|iwb z=~?Id$A^`S^&%5Z*<>KXJEB`NLc4>*+P`O%p;A+?1K0`-^ME%`6q zK3z|~9%)L&x%iUGQUqQCR>owt_GRQ9;)?wS7t&IP*AJSd17BSMMi&#A0nV1vLUY#w zGHkZz8vU%ssuyxneNW1GX|6bE*fig4NL?(V-$41_JACCcUhOYMFAIH-F0bA$5m}jP z!v#ZzpYreBK&C6+F;8-cep{r+BKgD!bNbHabTt4*Rh8XhC*uoY+W&ZR9Or~4UZ;$8 z5HbiNML>Vm&Ucpoe199aAy0jVJgu&WgYds z@Rrp?is(Z&3uAO?JRt)sdjd2ri?0tj=n#yw_p-!JMs-+&bxNM&V|t@r!f zdEZNPY6;F*f5zD+w%Q8{^aYaHhqi9q1bKMacj{6!K#lAYvuoDjZ!!%L+J37_X`&^v z@6)mUuI`*un3ttbC89|5?7eF$1(ga}--OS9elw3ozX+}B;FWhl_7OtbkM_ue2Rf`*XpuPM)nySA(tDahulk2aemdN>4NBF zC=Ki$;nhGTJFj?VylELK`&%e=;ESqdG8KTB)HjfKTK1h^$$L(-$E-hM>(6A-9MeDC zz{}H1TGPj@tL|DnFj{G;-=^%ss<(?Z?~bPT{I0}B@rY|Sd=K+^wAG^H)hsBTgx!u= zxt*n`dUi&*$ABK8{>aEX=F^Z>*ulCiVmLlqDt;;YU`67pEl2F%GKp7^Hb3 zb(VSl`o4enlbf-sQ)=6rOg;X4CHN_;mFUXW??S70AWW(8D+-fIS07Rtzxfa6;_Nf5 z+mws~8a)-7F^U86*MWKH;^geB^64c0MaaROiJ7r<`vi2+> zIWvOD)C#}AXn*vzMr4IJ-&1WhMdI@x=iXQoJX2VV!IoLe0bYcPT?N(d09Kh*`!IlY z`EH4d@J>#BL-EC3oH(<$b@ph$A%owCoTumICMimE+%9?{^Kpbx1OO7Q_q9D|1M0QG znD~nbI-xzE3G}BRK}U4dSaFZCc=`R51~Zof32#a)uh*_j(lG$g+)a_#Z&Kqo&;Q|JehVv#wIg@ zK`xt%$c^NJ%&o|oQeh7V`^e4oCfiF8nIW82fs&E!e zoh?6Q6=;YSMIStW$foN&glc^{XnGE&wU(?`KbfXvJYi)9qV)r+7QFS6xN9# z`qzmSv$lJKsSfd)1a)r`Hj{n}KoboTr+o>8za6>#x()5*tVw5>tJEui$X}xGdiYkH zs4&tQ6D}6igEV*`HZIHOppd2@LfC|EcTp)UE$^&ZuS;3$p&q%=RnL9#&(y9gJj462 zBn$QPOGK2OoXqrTe`Cp2&8)Fg#jYqRmqxy!L5|*7T{$*(FjQXt5NLFvey>yLK(5R% zirGrm`zzO*9Te=6QCwW?kT|PSYvejf7qcyqaD+r16Kz7~A#!I5Aj5^}TlwvSWol5l z(*#qRjML%9#ZJvpPO3bsk~Bz^X7VXwK~uqw8a-UbSznp4@NhNqp7)2A_eg=cW|)28 zcCV)flLbOj@3DAwZu&GUvkXc$Nbe{)dhVs#e{m@x$*wLZ)A*{XRS)xKWbmLI3=SE7 z0e{Y~DHSeOhEt#hPsfr&fOzkAkhz_3l7qi9=n07{lOb3@BRRrV^8ElEi{x*u;x3j3 zXhjT*^M<*iITwPdgiS(dSrmXM6lC>eQNcGeN1Dtc#lYfo1q;yGtz`e;{I(&7Yjuzw z0jNJU-8j{mq&~pN6qnljzxf4S7eldN%O7t%vf#>GOY0lII&HpliF? zodxdA8UoW>`1bn0+c4QnyTbz$Pz_<&xhHqw^q*B>6tUHHKt)sD0MYCSqK3RRiwt+Bn1)dxu>sJ8QPbnnhx_}d{m z|3keGQfs;==lOya7Mu8jYBG<-`_{kS|4YodmEU4{L&n1GwzszrBsArWw7GEQn)m}a zP+Q{9>y*ph*<0Dps#=uOZE;CET2Kr0jo$u*C##w)q&CrXV*P{PR%(?>uUw0yTPM^7 zr*bG4JMa?`V4HH0_M~P_Pa>u)Nh|DWHW5x$QEa zRdSsjenf@tYl$VVH12%u=;rfv@MY$mY-_*=&M$#n7vvEE0M=i2Ix#-tg}8m7A>r&P z*DSB(LDuEii0J|)(vlDWQXUYBiafFWt1{XocdOH)L^H?w#V&2>v{Krw-yK#@f*p=n zWx`V;sduyR7NaZOh#!`+lkaLMD2(#xN{L0S;3CQJ#UYiwi@~*A93s0MH9I==ryJHR zVjE_#wqtQ3Xn~P!SvHd{B~R3s9-U!naRU(^d4u6c0tnlI`3Mm&<2o})DcAMOWfyGx z6rE9o%T*u{MXyYUk0#-H6+&ZRuV%0*hGmIr;H$2=}p>QR*Ww)Ci$r@WtMsS8_$3$h50nj+a1FP+$9{ zA!m=V0)T7@cpZe^t4I_bil#$+MjtoKv%PZveUs?^>7nTc_-0sX5#a@E-&fEXfon z|Mr|xB}u+U!GoD-`}RSwduyf{Ln1}MUC9p1n?zZbzpAdjTC)QeuDW5D$%v z&CXpY9LXUyQ9Xxmi0RyXR31^o}CLr|Hw6EUP6 zfY=o%k(bMhp(`%yFC5ym){mJS!xtPR62#mF{9D&`A&_^zw+`iV^HoAE=aCskoQX}} zNbYlM^>aDr^!=OwzabvIWa5VxEuX6%k6X1o=&Vesov zMF${||1jB*Rl+UOO11Oq-f1)F;mNGXcPF2bhkM<7fPCi@@;F%UFnBRroI_|ZLUM5k{xh7dJIAN z2=EQB`HH|;ir&88RHFg2ZNkE=)Zdx<>b6+CXc6dA?L#QWU`^prBF0A#3IZv)<-H7z zH(FLS5Ef3TdoDwPdYT!BN6(!n~NAVt%^-E+zaDeQ2hgL$>8-dL|bOgQ&Ri z7%w-BEZ9l^je8rnJq?Y9u#ST+bMr^s*j0Y+JkS8v@dQxqnq7yMsuqm zyv{q0=@2S{A_nt{w4_`nL~tJEyU?sUPnisX5o!XWvH~D6lT7S65e6ruRQX=9p)| zwVa!G^()C9Cw;J9a%z7x!Iu&^J)rlnJB1ow9 z*aRL+&4N<;@MyS9fopVT#z=SWw2aY8h>j z9_}xQCnz5oG?pga#dMu$0@MHLt->HDWd{4m7nnanycq*7@l=?yBO}5~4zozO5jojK zpNPhMuhSPi;_N*S6c)Z(f zc|-Vv0<13SB+e_@pe-AMDKYfVD2>;od(qOz7qQ2kWycjS_)>o@vA94^jg+AmRfgEe z9GESXRVT_L)%^vBb!DP_{xvZGM77YQi??(1U-QN&9P_Fmc_-p`Z75kqnK66K5xX1< zq?SQ60V);XowkVZXW+?_cyAT|u}Yuq=4<&h<=|FPW$4Z$0>E zdRkUJa!!^*s>#IdI#Rk|?BPK$<;zsru2#b5OZRN2ezBNB$9yb_IVnU$L`cz)QneMyz>_)eC^X-YscbkxSE* z3Ma@`5u3|hQw4dA63!IqyX{Laeo3br_$;1W@U??Sv*lAEJx2qx^~EN>(Ah;Z+Kdnk zXY8xK{^EbS#}5~0u(h!5V~Yo9Sd<`FrrC(nD#1%Fygr>L2&S%@L60BX|8SiN49$cKbcs!9@D z1|HiTG96~Q{+X@#sp_qet>2!WnN>wkj`d8QLR?{z-H) z5?!jK-Y>@L$CE!KvzN$Zh*aJnBat{Fm)}M_4LDcdw5cOAFf2^qYL}+nL(c=OQSumL zyU?KQ78k_8PM_)j2k_n|t_P3Y6DONwTQKA6RF=|{ftbEb_;oD#XUtvjT-82!OoswW z9-Is)5U#t-ZM@B(`A!}hq8q=e5vn*S^ z6GdWx*ymbn#DsB7hlPfmV!)d&Uwd54A~fErD-0Ld6d3A)T;3~@R{03bLPm}^>X&H~ z$W;E#QH!N|(%H<4*bMe$!}a_BpQdDYL^PglMm1#eME${FfgPKJ?-r(Z1wy%j;qIn~ z(r7Et{a#;W&2yd4s&szn=gyQeZY5(2_+@u+q(cdspk*`Govys&%+)5c_HYC?bVufY6no{523Gr~v z@aFb~6^ItgtMsYx$ph@jkFo(2zZPQ0n$@0cy*N|?H}wZDkuE4$nwJ6viTjR1Ef=!^ z2lb76t}=xheu-yE?v3n!k*+Lq|e zdZjZC|FI$Z7%sXNY3nu%?(${iovseBK+lj!dKyG0-RA+H;iadGDdHn@J@^-(E5qH=kdi|pM`#kwy5k=pyzcSiEe2X>{3Ma)l1rDpQ5HqN|aSy z2K4D|kFQA`tiwUetJ!=)_rI2%)5Y>CB~7av%$ok7s&uQ5*0e&pNNZrxX|SFF*9iTkMS@@)EqTum6v%M!hOqL=v$##TAMPB`!9dF+6^-JlSb0}62 zsfmp8yYVGwIi6F&kCE$rcj5ALy3vW}wlE@M9ba9!8nJdcMxMl!sy5_Mezl+vl$DXk_?x{agTJmF|bLQjl`uPD8cjq=n z@B*`or6Ds3^RxMiz;jx1eD!PvoJNhbm-;=I5+4zTv$wKA^>QHNL(**EnOiig-q3ih z?Cdp?+(Jmk^h-gdk$1GV2H!ZhPNPSHlmpd3%m|0+*Hc&EW#74Pxob z&K(U1#jnu?=Py@Q`4mXdZC0CCj2IZx|)>qcZVZ{g2xTg#m8?}Vy( zly?&6)KRN)eY(>%Fo9;-+e*YYEc<(@5GrwCA@P@xM%k(672Cnv^H)RVlya(4{QL*r zfiVbmbwF7vEk}4d6mU*Z*Y7hJjv?Fqpu?u_XUw*KL_@&ex8&#x=2kK8{y~Nhe9?Y+6v8U@yq0kt2_b z;t6F=TN?I$(PO&d?ns#i3w&Y;=) z)sZ!|1MU2EM!nMUJLQG5kpCvj2@4Q-~{VcJ=D#h8LkYERG4GJ~R+8efY<$mzz;H}7^*t~w-? zCT3FPB7$Vi0*D41WbeUyo;{ALWuH4$PA#u-7AUWMuL+|UOVWnAB8i+USdpYQ4Z8B* zDq{JDqc!j@-Ve6|{so;Jk7qiw?6xR;=Aaa@>XP(J--5HoMeKszr$dxYRB@2*@8g7x z?C#mClauhG_&91!Jnvwi5&0Fm-*qiYn}f^Z)UTSHu)TR3-+m6zkLVL-CC^!OKNiAl zDpWJ&fUf<$ONfvo#lQRPC#d@=OvZkQ(@!X98@AHHUi%T*r~LL0gUj3rA3f*F3SIXj zPFWR~xgB=f*?Pq>kf!=&V@NEdhkOc+I_yfQ3U;QN?O-K$cC{!rX0a*~hgp~WgUiL- z4UFxct4Gry(;)<=Gmu8yI2p;pwLP_ABxdKn6i4{bP-&dQQ4eo!&=49jDN3Y{DWR1p zE7v5Zl$D-NFJyQqL-b04LnWr4^ZTox>GgCMZYjENzgLWxYL*H{0)O7vGmG6lymrTyKQtzzPQXwgJXklDySA6tyf)RX(F zo9<5UYHOb!zwjbL=(nd2)g7MOh$J|zTJ8|#e}PK``sU1kYH04_@JYt>*>D(54An-d=mP)JkPUnnak! zx}@pJDOCgaI5U`~{xJ5{?av&@KG-__iO0O-T>tY`-@DL#F~S0V&NdA4tor)n{MlZ% z`GQ7-VuM08L{89IQU2M5DyqSO8h=P7M^o=duO=;T=0G+Vt$3JbvhmAx_M z`=ZB|5Py<~>efsy_l=j0(`dNZ5tq1?98ZH^B)WQp<}ueJ;t$-eEkxgm-7N}KcnyR` zJarH^aV7~uUUJnXt~o*+M3Q)ECB@#!;ZJG>pcMyloO4s^^l}mQw>;T&j5T7CDcmj) z5uf;WVGe6DJX;wDb)0NiZK&t{z^`O!bYFAz>Docx>TFd7)+DeT1akfPf^UjX{ZyQ5 zVdAV`)-E@5c;F1*lg}1^^jyuL(u^7~?z2r}*Z9WlS-SZ(yr&_`r>7XoObDy-17TO>yCn)A~)z@8dJ2*_2i+#ys^LB$BI)Fzze$l4ig~huQ41LU%2uD zUf?yw!|Ku~meI5lcXhnQVJp$=8hYq-un2zXr| zYL6|3>-PP7p*~ghmM1E|UJ|jjbJ}&y!DzXZEfn6kpVF4mS67nS90SyoN z`BEfGJyMe@M#07Cr>y+8zpw8|&;6I5X6e+NYXE(BVN3bdso#jdTUP49jbtAZWOx~? zRunL2iLf}{f1n;om^e{EFH-8SUuJFe%#s*bA**+QuhWA6;EVOW(t}V17!((|Tm+u& zL_R1O5ctMg-f2W1{w#uoxC>5CUo%1<$y=<=qr%`al>Z~zooOGJylhU`{BtW zDUTU2|J?$?@7880=VpPz+fa+zeGeh>H zT9a~izsx!mySVb_X9S$6x6;Y#OdFc0SwHXd>-R?^ABsew3I^E#H(AE_Rc@8{e0A!N zCcrYZ9n1V?%ZP4}>2&_oBJ{ zg>lwCC=!!KmMS@oxa;9Iv&{h*GOp1KH6z$%-9d;89eym5m04PM-dH^u{?_l52ttil zPGV<6Z)Vj8nI^+e4R8E>srfL0J$aRhk}K{51!!I7-s(S!p^4{$*w144dLk9{SOSAQ z@cO72h@j7|BGg@ZoD25~1y!xk1&_pdLobCV<&#N`pr;;u~M2;v}u z3zOQ-$+8pQi1GZJZRP~@!b!yK3{OBzsZhVD#sbs&sz__ezQ7$vvq;VI;muL6iFPN# zLd!+#dtA5rD|7H5OavV#j$hn5O?Fij$d0J!{A+AZKi({k=lnI_72nj#c$AHvp*u)x z(_x0hv4h7*`h5mq!RE&?To{1Z@xPyjS--B=P*s%9J+`_1I3VMzKTw&@kU%HsI%#4_ zTx!Ed@|!?v9Yii}F9NvvNORPv%m1VLBJy~I6xu005QAe7^Tup-bUy!gq$KM+K_(%v z3??($d~diN(j8~*5PSaSDEZCNV-QvLWPrFD1ydv=C$iFQMy>mUHX-(kTcLTMe0-ci zTO$%-vDx3OmR5a$i4x5inbS8U8*1}UXMy5AS1;quZevgsxmY^df zca|JeG^N0r1bxRyX34-SL);mAtQ6GLkm_&zx z-Vtl?A{)UYNFo*$cpHznPVJS_pN!iKm90wWPv^uj5Jar|-s|SBU~iQ9+kIRSxOX#5 ze6FnG;(}_~zz4DGpfbB3s1nfTvi?_NjN>Nq7oakNj7Z$x=q37t*7K(V!a^TDofD#0 z1~9{*f{mHk1p?Gne-$d?lb}{zSLAt<)thK@1*c9>_$v=-1P_pfpVNKPE@DD9R>8`P z3kPm&@=uD8m4zINGL;taHP|Dm&oU}vRq==IdPcFYgJ1p3V}WJ@te|`AlIccXppNzl z9kqqm`3Ygrj8~29f8BvhFcmucfc1?0`*lep`J5m{(y9!{&lF_ypu6yu;8Q-ZF}Tt# z1M>f!bEF$-mhgU*%PAN)Lm-baVX}Fn%$!2i$?AiUCJ>9Alx|AIr9j@#7 zE=%0Ghd#!1>%k1EkA{p5E2zJIN)@f=0_Mq$+{K5Yg7VO?U8ve8R$3KvvtUg(~5X%rX?nChWxAe;h;cy4) zs8K%(C0Dl57Lg)9p`Th}P3am1xg$A4AiQ9e;P@)Z3@|~uDf?ROiKtK4!T!qG;*b5x zO}xEV)0Z!ohJ!ivcbT7~0;82}yRE)*9~{LGbC zTV(GhG)P}y`7z(Tq@EU~?gsBv`6W;c3ACxBFCvQrbtITjQZccrAdX^X|dmGCg!J*)9kdgMVF0m)2Yrx76u;Yc1;| zIPc|3mjw9`ed*N=!r?vG4)RmvNS=_RzHeaLT3f(-+k)qNE&(7kR{sam_Y6j{hKegW zgK7?1Ky63f80V zq`P<4 zoZ?R9eaF|am-#l5dq1eXi}&ipk& zXR`t$Q#PQ`%wb+UQe%ebyqd}tLcTN3+T$cGw#WSR97GT1QDST$L-lT)hF5oSlyd6; z&;>w|O*2B2Q=`e^ure`BfP@hxoPZ>w%>;%e{B700IJrauo8;I>T_%e7%%^6+bF`?+88GAvMTw=WXK#Fl$& z9W-NH6|qFP6IU;}3faO=X>^D*$Ha1G3y!{1e!Ry{WhQy<`u2H~gbYBQ{M4^)YUWVQ zW{6=mJGtxjT_~oBXOLA$G&0sEX+$=->4{TkTI%^NpZnls(gZJXgGj<=!ciYtla`fZ zxBI{yz%!@CUmmzH3s}{AjPKqeB5QGECLfjvVgz;)i)EyTT`N<5KoJgVPqYmAGbB#J zUCevDd*b%5(*ysN^>LA9^m6Upvo-m{sTd!<1>3mjzeERXkq-bvcu`In=uIt<9pX_z3Ko(V&&0PWI+Ms+3mr>!MB)0S~WhgJ7a4( zMHdqgh+khp7fCm*^`D`ze(Q4{fv2d3))Sq~JzlNW(;;qgBR7ZM%%d%L-h<%Ku(;s* z^zUyP+!<@q)kdPouCF3rO;1;bly_^6zx=Y)Gql#3sN|cjs{BM$n^NZ9DNfnc;ewd5 z*KPdM#bKNA?2c1;-Gm8FDeIe}u1Zx^2@9d~1lGD@AF&v@2f}}}$TI%DGnnCTVeA?c zcQ@HM89(%+2zClC28vYRx6ITZsA>N`?RSx~kd-FjvoUrM3^lO&979KqZ;c=}&gmK%;zYDClt2S}dW1Rqxf8|9A6N^pB<)kDTF7S#{ zvfgF(j+;w;*WndYEx%8UdDcEN3rD_re+O5R*)gU^#8I(g=B{M(u5}MpW@69)qfZGh z6R>7yGED=KaB(%96ck;~#NDmr@M&7LRwh|5D4xqaW`#FhEZRK+0qFl5ywWwyy+9=q zV&cuKjy6S@N=StGG^A;hMo0R$q%)S765TTT$YA1&go&cfaWi36BeHYn1ibL`{Wsls zpqe~w3OGfYgj|1Ou$}6+j!v90r8^oTa?@(M>*3uWH>_*int|Hl3#4Okl4t4rifkp* z=e<4UOy9z&@6p#@XJ@xvrh4cpoq}R!2-B6(RbWZ68}43&vUr=MEmc&Yv?9+& z8h{QF5tG`JbyGs(4afr*Hl0sl%tJvQ@J{8vR!kZ;n$2}hIvq#eq1bnfiV9LI_-3_xM z{S=(jl5seQrv0j?DLiD%jK^x*ifZ^^sV)6PZ2Q?Zh3{q~&RpS)dnrkEawJqoelbk0 zc#5GyUBI{!Tq8@Y6ER)yI#;hyy6&dCEHBKJT379>aWz%kzsC3XGRUuVWp>M_lj%8% zUKFmItvSHRFDUxVNKsdylU4S~{HF|L>`(Ei`|aa%H=(}BK2&A+=uH5@%=JH`8~Qo{ za!w+p31_#s*@8slcndonH~nf|1&IeNFJE?jnn5G%5@Q5J)6%PznNJPGL=+#7>o~k( z6im%^l&b|`9ekq`@mXF3HRwbkuD-BRrnm^#GNr9oM$5aP}3jSRO8>P{ziy5^E?48PmDD=yO zkGz!1QXu$(3Naz`HNo>+Af$RcN&!O2*_DQ)E`AU)rtD{$s>o`*E~s+Ws|$CRNjGMw z6!}ttM|x(SMI)$tq4^aauAxhEX~TpE_$Q`Ir}H*a8yyiU!2xUoIR`pA1u-}ZLtI)O zyOCqJH2CA-|<{Nr^~?xD(<%c0%@Xh6B5?y|IYe}U;x2N3!JGj z(VzkCmv3ok?`0IPvdW; zoN-^hY{1#Gqsw_`7qsnDbt5&_2s<*Z1Q)`BB!&lqPIo?%7vQc;W$8W6wiC%4St62Z zh;`Lu1*wqoSLznxY$RB3s#FKmpz5kBeX!XZ%QD_%i}+SdS-FUQ;h<7@amO);s%6no0T30xmlqJRA-_o`D z_2a=QwtV!AZxn~+aS*LX@$p@^ z3JbAnOQ%;}CoFH94}=}lG*HQLRv>j1+~fC*i13Afwtb-GkJijG_rIpA*8RQKP^%D) zKyNvv zk!ih+AU5om@X49xyY-QFDv}C^>&Wy$+L)T^0hQmstHh}-{x0gW(2`s3J~lJJFV%y< z9yRkuRy_FbCD$G7V=0ASF5?=`aoWhXHMbE+Zg0cRVMJ~?RXx75+bT;(`8|Qshx6NP zSG_xDsZeDrSH6}1qy7D-khyeayrVJm!EsspQp<$BcR+#qI>q*igm=TLVJX#cKuBkRhOWQo~NLb z4gEc9FwP{Kg(uxPcAI_Ql`O~3fKQEV!(bjQ?%m(VLLf_=qT7ZNtB5pYNo#F4I2VME?&|O5_aVua3kog7FW4w`H!oGXgTdtF7+6R#LZtDfSaY3KA=b z8=hcvt(@)LfAqrq_BOnk-$#o zbqvGHyp0qBwFMJEffmcN{QpC#!`%LEml_qto#sFq;zKKaNdKF+u$diuo{*C+>F-^Wa-su;91$Xa1(Kn zu-J?T236fekKXGi?}Nb=5RAF?r)qA_=hc6f-QTDyl_tBQLbbi#NCDELTm6k4OkqyL zaN@38GHp6eeZ*I6xj52U9n9qF1#`2&IU*gSdd#eNN?MrWeyhjnv;F=|^+xP(iJ{wF zc+ExsI?1Yh${DRsl`B zOgk@dB!P1g$a`MN&tYn7;2}oud3c)s83w&i#jBWR5SSQ+cp#xEe3tqX2+nm}7&MohpbRms_iY8scFO=*L(P^D3= zl&vQ^m0Tn04;l_$JK$96;3{OdjIQSc0JIn#`vSRTSg*fx_0Eo!S8*#!~%ngD_n`L;YlMr>mlW9SM93g z?JP#r1Fd&9pXB8-I(gE*4le#hEuTj~VPmS55+KAG*FLy?<|DPmd|Q>wN1*H1BmBYM zq=E^5!2)r#)BjO)o`Gz>ZyQeRz4tEBN{rTORfE_GwPP!4)u>I2+Pk*cs>Dp}U9D2o zrd9+k+S(ebKf4v<|K|OeuTQx1+}CxU$C0QyH&PO&HDf$f@1CwpXRh5a@Hr&*UA|+* zqm|Ru<)21gk35W6a&8P2`$vV3Pfc4_vp(_3B$O*v%TZ+?q5yogt8%)9gr=tIXyv>@ zJwx=KLg7KRPBp`-{cagrt?VVtx{i*(Gn%dXED5s}_QKn1gUfVEoBD(EYBFfb0+#nogwBlgpmG{1g~(#3X%+ep+-j9- zIqA(b;Mh^v;%1Wt!^8le&@Mn`^XBr`CB3#>Pgw;4*$dK+`ELG( zPEdAohV~tj^^BdQLG-JRl%g_aWY!jbFDLsy0ATzfcfOtt##gTu$&!c=XzZk!-%}Ef znGyOAdh4Kx)dp+%MYvGmg!96pG84GCj^bYZ{r$Uzj&c!QJGlYqJ{m~zlFv9Ll+EAf z(dX8x5!b6!_a#dE&oCrVDoFn8H^UAtuhmQwr-;#lCCSSsmnId!I7Mc@p#)q>5dP8_ zcXZp~AjrS}3X~|8Ja4ZgWrNA!xTbdiMG3B#$0~t)-al_d9#JG0k89=BnRnEzSIdf@ zE|ac#Q@t^Nu3T2jJz04r)$MsUjk=b0!cga_oi~lvy1sQP;9E{~e=R9bY-Y{gRdlWo z)-4Z7JpXj&5uKOM`?bZz!qh7_-x#?k9OwJ{C-LXkeA#~)fu^MUqdVzd-i{BDP_06+ zeT>NfFzpeIA=2i7H4#;RE%RyDG2Ojk2dAgyM0{HSS>&II4W{P6Vk)_4t-RoWHeH96=kEO zvG*IYj0JC-WM&Ih{>JF7<>|HUqZuC2j*s6l!g2zf5^CdS%W}es!$)W#3I+B7_6dN37;@;=f3ccWd0nXcrVRTsd^K}3E1m&a`JhGODz|7tsJ9!vzu zPWZ0piLRwWBZ>1h3_adC4fVq&NCR~S12Yp!eI=Bct=QN^J0cj*o2p|lv-s1&7jq74 z+Y?}}@hJN?r~)*;1jAC!b=AlMF*4q*%Ld{~n=lYoKeL(-6z|e(kxmOHSdN z36wemvgCdiEvgNgZtc(<^lZZ=z6Uw;sPuSk zYvLw@r9Mc=E(EiiwbTt_Q|?sv!oy2GD}BL|Rf++CAoek$L&}JT0=DsAt3w__w3qh@ zt&HhISC_-B%offYc|Ah4gbt%NOfoveXt{lmQ#+g~rI}$Rw4)ZdlWZ~Z^p>eNB$@G3 zyhkhX8|Q>5V{W(pK#MdZn1?>a&IQa$_!5f; z5U*7ZV0DB>$^JvmOK7jsnWp!hB$1+pGSb;Lz$5%r1iy5ZH~N?EGbK$)o#$EfQX2P1 z9UoxYjS_dugSE>5ZHV>TjGh$JiZb7sYmySdfpslKE)PNM?zUEjLTi< zDH9;Sjj1Sp#tzkTJIXwdx_y50Y1KxCB(h3SS|a_gMMplllP@NauoVUlOW-A^$kFqB zI2<+4x<4;t0e3L~OC9yN7B%?n4Jli}3tUk{5sLh_Wx?h24I^@r8Y-%G%(g(i2G;L_ zp5J(+a|~6zHNZJ3$l~A$)XPj^{Th&Ha>$XEM8m8SVaNawGR}h^OEurJ$3ZS`m!2a8r|{!l}fn9mIpTlcTNuA zE{=PQucDiGZ<<@os@y6GJB8T#@Qc zGne!~e=L}ndfT)8DiXz{dvYSc7T}cfLAZe0rs&qKOF}^;p z|FI<-@eJh=1)t|(sIw$g=c~Zz?MHTVX1c5g6g?$)E~9474^Q`6_X8{tn$!Okn`i^p zVO8ROTpf)%p1KB|Ocg&g-KXERYIEJ{On@K*R;{vBbbqNFXfwg?>OD(W6FVnmwNr;oK=oxo7)X$koVp4{5G0>k^gYST!{bRU=Cxf0h}@_q#hI1k}i2jP_(n zBKQ=#U9$KL#*;@?3k+gz2;4(Z;w~&r#Fce{`~Ltme4Yv~vOLlR_<%Whd0NptWM_|Kj}8$TBO;CjP&WV^mkODlSD zn6i=Zx80jMrb4S?@Y8#fVmcjI2wYj;^FS;{i2~QDG|Ws}k=-ZS;qbHAXKDjT{p!Uoj^dz`6qyxEJO& zDK^gV^dk3*YmP>BmF#C+RamgR`!foD{$^-a=<--C7*|E6R3u39{ zs}C&$>Usx|ir{>lmGQh}F`v+uD?B%sP0q&YC^fd6{QY_^g_pd)FG>ct}?Tr8ZZ`X_Z%|5ELtI-(g+{v-*)MWPg_$JiI$jybOM_ z>?iRhJ?C@uV$G){B|@zCrsbl^RdVxC2gnkxX#BO_4=}q?&TU7Il$V|q=NPt-cjwL5 z=KB)Ahe$T=NfOA4;`%5p-iT}rAE0pRj&ugC#ThGm?jaVJHU5;xX2(L6Yc#p4&Fi(u z(s!5|SW?C%k+FXz-pLXhk2j_^pi&Ca?LCt&u;9!X`O-qB6AtJcZ=7`lL!(M0BIg7&Y ztY04U0P0%v(of-4*}S@+Vn3mSz6Lt-`869(>{UY$3fR#i>eqkD$4>XMZ9XeNC0JKr z&%>oS(qSk{o=pQ95lF_Ggw_c=x(N$oH^v#wRc&lFk}OB5fnGmGzuE@N7L}LZQWu*p zyb0P!WFu%`!w(d+aV1=4raVY6y?z65&&M;EIDwt@4-si~C*8 zLpCH!F7_p-*spnIuo7b+Qkw?--n%fO%Ld*zQhS$#&WyC4ms(%w9EX2iLxgHZ}F_%M0v??s+v}W&{wg0aT9mLRW zp3U2n3Tze+-R8^jb@xR7QaEHz?TaNZz^j2XW!ak-B!^4TbvZphe7Wa&g2QbMZKDHh zqyI>u3Pq>;&z}#oy7PHQDVLWc6t$i-`uiHGuBL&5W_*b~nFv>N>u=^UPwe0|k|vqQ z!J~@%Sk#Mbu4B;(thCF$hR;JMSTA7GB8gW2!ml`)s;}$XVT;fxeIyI)TNwOJYOaUa`E%KWDikbYF^ehrE-hMdiB zA*zub9A|`onjD4yHf*YNte=R+cYmHSV*`0f){FnfZm0SIiEmQNcqOa+F~pe7?ZJ9Y z^2!F|cNJdhhcPu39ir&jt+nu@ya)CWy^G~9jgF1*UniXM5O&$RJPLi@cV!|R3#_#2 zWXsg2=c`r!^itK$9v@yRWg4B6RP2myJsvw2jQsfki*C=5n0o5>BU3 z^4+a=zDME!f zYt>qUBz`Pi_nSLTduS*WXTbm&0-iKe+F`VJDEIbO5^h(ZTTHN2a=p3Iec1PE4sHnv zh)ugwL31fZ0X|EynH>NLX=N3_DhvO)U;oDTmyufCRkYE&%IDeJ^93gF;v+;mgd9ql zCwiT0hs<&Fn&yyu8LkWsV;X{*Cb-d9K<7-K_)x$j(UQ0Yx0OGRDd4TNI~m5__m zuatfLKYv9D@Bcg+CBb^&|15c$4Hd)qlq1N0bwBxhJas0e?gmQOOl2mVoe$Z>A?lX| z`F^xPElPP5$>L*%h`YB-poy}hHJedqnOn^Jr(6CrT~|kQPpOs02)cCr_l`Xww_luc z62CG%c@FCn_<7yc@z`N6@s%#IcD7hN=`Tlo%hBs$rRzT5$=$&kPjid6sLV6xw2hzrvlb9)Oj?b{eqOxY|21=_kEKvewPC#q z4G=I>Efc$GHvCWzui3MX2EwHl0rU~fXMfxLbUB+ZRZJ|jvy*~@GRyMRHWHPycsxyM zyzeY{|S1@4fC<(}) zNNsuW=Ffn~ zs+D8R`P5|srxzJg4Xyt`iROItiGA6xyS*&#+ut1(R-YQaZdc~Q=dF)kefRun)|Z&g z;_kwVmg5`D-C^T<=5ECDEZ8JxivwZ*#Hj{2@)j2|J(v zauJWt{?>?nKyZ*Apj||iOs+0sF`(p6dSlW)QSQ}i%$p(wHf46 zIz5M}qKrrcqgCe_6Se8G?Gr*rG|Lvrm062LkCRqsu8h=n5_`4wI`nL#jd`z8{jz=7 zsDb<~)(fuleO6vj@%*(lhJ;vJ>QLV(+_9-v=RCQ%PYMsrFsr_`M(DVI%eJZ@ipg|q zu))Zd%&sZ_sTp~*m$5E=oK)c4YvvF$1|%0Ye}-a>0FgpUoC*q1rR(D)ZkZE?=cVV)Tef^ix^cw*9;8k<2v$6Kk!R4GWDF zqklejhIRLGP;T?v>MOv+Y74h!1f(8=_W_Vf94iqjbsY0KX7k6bu6iP-m~=*(ex7On zA%6l^P#{8GjT9LQkRF{?Oz0(I$Uf|q2N{#;l`N&JK>u9?&$kimy&Vnyo01VJVyMV1Iq z(mT|d>g)s(Bwp5K>v!^J^!Le(?ieaLov9596v-r`+N6Ne zJ{EY9y?Crt06c%yP6cfJ26!diPN<2F~CSU#FP zUZo<93ejW`p6>23eEVJz+w{?rCX=#_UCmP)r6NxY1~GO;C6qPA@i4?i&^od~SFJ3l zXfxFY@~#o03pjy&h3!IIdUE9`UJ5 z30ROeQgy4~1qHO}xHS)JSy$5W%Ao9keuhnI^^~O#MF6v$cK-d;GuttxU3cbJhu`b- z%5YXm78|$fA&rIYm=qK-8n?gc+z6qD`Jr-;^%D8M{<1F2^P=RtePu=e=IHmG$jObu zZ~czY*35#5k|;NSe0!^|Yzs3C5^U1PJpz%BbC|$12;W)AIR2_<)sv2Y;x{i}QAx{B zY51q=?mncUX18pB_=@ZKJ<^Pu(cwVSNR?9{{L^$flM_w`9h58A239l~nq>)3e*{^THL!9dA`A2va<0$f8!$k4pagM7+~WmGmqvzs)BI>Pyp#_teImlO{*? z+w+=e+j_f|qo-IwnAOQELAzJ(Yxp3v1ReA!Q>F&@(CH|1MOTE9f|6(lW~k~M-PkN^ z{s<;3+Fz)t#s!tErC5%!s5fI+OO*%&=+o3eg4UTN*KJ$*xGF1RGHGQQCG^vVNlJ+| zbFwNr-H`Z8UN6Tk)TwW0Ump6rj`vHSnRFilWd8vA?Q1~P=b!5bQ;4m%TSpjX2a-D6 zV}UB~h*l^v-X9Q_*&dBP-e=Wz5d7LXf?g%0<%@jx-fP|YAK;)5(;JS^i|)kGSyI=T z-UFo#8HNY}u0BQP?LWP{4b2pO0!|@yVI@|-d2qvp99w$)Q&X8?T)j=mH~;Ue?CsJ` z;Pvw`%hVh^XlBl%o~)RB{n(rBjz=hsr*Z3?;VhMbZ2i2s$Byq_iA{VqI~mtKxcJ-{ zGGM&wJAzx_$6wNx>_u1}s0FtQ0UJWY%F}e5=mqtPg_VUsnPn?BnTD?myt?(@n<}=y zpPJKBk?N*5jPBlpW~IT^G?-vjcGyo_c2AjCReSTQjK8Y^;1RSEzybpa!}`jbWOVUm<@ZVG$Tzi+c9bqdsGk_; zG2zjmK-Ov|`yfD((GVpE2kc;|)78KauaVpu8ax3Sc(32k87-xpIt%Q*e~7L zuWV`paT0ir%%N({C>g+2zu0-uSgPi@fUlOcx$j#|vQe@sWVRh0d90CjSc*<<^Y>u3 zVZKT8k_P(faaZ>@L;7rsZK$7b_+L5c)#V6uYJ}iQ5US=gGgpET1ijWA%^WC{$&*$& zd>sEx@N{Mw4V*PO7OHlgbUd1sCDX$`ZaBCMQ0=u>eDKlQ`%J)LKhW0~LaL9)DUcOi zl8`zl1km!4U(fmRrWmo&w2)QCMaoB1}Q>8MwAF(~qDJJl&_VH(}_=A^jEuO?YGbhW! zha!!|pGSV0{ciX7oo_ce;wHs;wvF14f8mC-3Z!oTp|*e~^EOF7UZW$|aEEs5hr1o? zZ#ye)de%P94w;mA;!v*N8i=*q6J4tnw!Ji=BV>qjD-4>Z|3U!FgshviBb4=i|7jza zki8#%uWJ4dK9NLO(mk@Qr*yq%>X8Ub-rs^S7SfaEK&#oo4<2;&-O{vh#WpJW3-yW3 z$LJ-}iqL&u)(Nzu+d{dAY*xLs3KV;nH6;C&OQfYsgrDDPWv|cVKyjF?m@jncOGMwy zG2tugw$p&3TxNVp7kTD%E_Q=C``4%Qj?V?$ce9ISm4a{I#pk)-}n>sygW{q6IT z=E)YwcvJ^yF3Z;i8s)30X8r80uuIGtR1mEvX)rw23?LG~=7VO#G9k}FGpt(#3|?Uy z`&%R{Hl3fJzqWUDcaORK5k{3AXrQsb-7d6xQ{Gv!RbACz zfur&!wGExMlJG9{giO~3*rH3gm=tL>4@0c8B$6coT1_;(Y%+wE z@u74P<$|{IZFm_h{%(Vq)8)T{z@jV0X<5|CfpdR zi2LF_s>;i=xSA=vp`L*@P8GS0VP4pLb`lZ5U}X)p z#KB-7eD>k2WktkFr@?({nL{GH{A~{kMc$gB$@$GHRn}AuLihFLzn9Mj+lxBWwfTy5 zBA-EJVG;k&p!b=m*m!R;&nXb>L2e^S=2Z+q1CghQ3qVQ&$sf;DaI7V zrP7@yhRN?|du3wOO#UeL2BStipCJaS;h2fskqk+2oMG(e-#lC=Zm~Kj;SX) z#==i11j1`~+`TfY7>hPD^KR*6#hRxIDghp%RXa^70pq+pQ{GMt25)}+kd`y+6bwZm z?&Bbcge6k!r3H`^$=s@C0NYaG$drMGf0`*UY12TBD$8Y3Nb8iBs(i|YO2)w$m5oFz zI$}st&yJ5ur`T-NeGnHnwH3hX)r))tC;&!U!yJ~~k-=TLz?)H{mDCRTu-#@1pTvFVdKxQ1M_FD*I^w56OS z^{M<;aR-Mf-P)cowQLgdmzkBYKW8^3S)2Yf^#zU=6zg6Sof3^#fVx9yZuiOrm>+$5 zDEicNBh%L;DpyL9W749%K~_|^PGej+!Dob+`{Qb5`Cb|*uuTpIdN z{g$&CHe4^*gHPKlV}92Lk1P%<_^jh`f^1cW->$plLkYBB4;qLf1TxI05|fLNwy7(J zzE1{HzZr6DPM3*zs!c65NT2?BL7YX=we8nZ7Qm8Ib;zSQ;^&GHC40bDeuE z{2PDsL@RycU3CNuVaijM5}QQT(GTTLkY6;=QNS0xSOahd9U>eXR12sxM?ZQn@slN8 zYYKXRdETgdt}%+x&7uOpdMWcK%@~tn|8*@F5wVW69_y5Br$7BDzNM?xqyDZgbDg?; z0q}0-nD94}_tEXK&g)Euh0HCl5*GE7&Ci8`VZqix@M;YO(??**hOo$r0WBwz@+g#~ z)Ov;gMdx2Gync0sW;J2`|MAaMYgQO;mE(33 zwe;U*>94@WW7b~vq?cRs)>WmDT9Rrfhc#g@#4{`#lDQtgcjdSvLJ6A371uFQDi>rr zx|?o%F)x5?16CzHT0m?}<-;W7&|frN0a^;3>2L-#a`=XGE26IqfsHL^&B4Y&DZukA z2`Irqrug>TS~>hDN_8VC`T6PUlqL+Y0`#EX_|@v_N;LIs5?isgO{Xz4b7_F=U^HRW z*;_%BLN~sVslpNgRgoXTOhyZ3c!5k6!mJhlb+lWX3+OCc%dEDN5F(ndj+wiX^HVq2 zfHQ9cvj-DZ!gLj09Irw~HlmFWaS9h?k-^yJx4PMT;`;7e3F*xb9ynEw*v$P~IzI~x z06q{p?UY^)O!K?iAKhLmyHs*VYti2B6yRAfc;p9L3mk=pJ@ZUpYM7>Hmt%F}HjJy- z&2{TdA5LhzR_X3fz?oLWsfGuR&XY2qu1&XI6>b?&8UIm>XHiE}oM)+ugxss~1kXQ5 zVKT7L^w7fO*k24x=s{yH(bcbHzCO&%Pq~wv+blx}=6jdt+>tVYybSXML21ra@ zWAc<0we2WlH=Ln++@m#sA>O44vv-wxx%<1_WirZDEeFZ1H0D{w-48V@cyjny5egCjz5YHAKawG7GdMlU@U z_zjHt@N{aTVeU<0mG9wR#Vl(ykc0Ejc&&(2PniDVV+z-5rXYQOS{dhCGz@kV71G)E+%7DzuiEx!z1_p z=@vfKvna$^d3m{(jcz>U@M5tVaBM3ibsc?{4LeM!=VPWW&IwM@?ORLTMbmz2c~Rr@ zIJkI`B3vvQsGhD(g`%AfOhL+I0jX7VQ7zEibAMq}5$Ah80l?neuk_&`i}$&1!HF?{ zQjkrTrxA-E8`DQxRLxR2Ivv&D1F#dV#zVfb6ZoP-wj$IX&{0&V zC^+h;WO)oGs!s=i2T2AE(d`UM1D=M(3%YfQ=6PMhEGK>m#gOe`n&_yI$HQ2;PJ?#_tN})1_->7 zPsJcyWU+(eS*bgljthQ&d(rpu>6dKsAmte2w3>kee8f8(961+Vhj*(o-svlq9aM3@ zlO#6tJhLiKjDpPCbI?K|+JMqD?*zl)qkD_rkZAI(T~?w$KNZeE-@?S2*aoo8go{Kx zuXc|c4CT~}IcD6?%o1|?P#uPEgSK@SqF@~7ZGVBWlE%v+dB&YQ6y*KX3kx0p0}OqY z-f(D#M6*}*phm$AX?y$$$_HaqtHO=(dv+sCb$m-a0~F+s!x}?QXY*$UU1IEB(6NMY zD;L)pKrB9q1JMJ3*`T~HmOzp&C><~V_y%n8&J&XSK?3`V=59f?+P|@i2kT~T5P?6KZ@ zy<%MSQaUwjX5k%7MLfL)vQiT*{iqhrV!*=}%i|2Vp9S3OJt>lYsEq3gDPGq15HI~r z*XkS$^zrqdfwHK?J+PjbF7e8-hlc+TP$s0paKF%YFbk?Bn!#Dgrt_#y9~iTv1_>6G z>iI&#@|UyR+KNsEsR!$HH~Jxj*Nk?TIo}(=J(tJU)wAt6H|KuEl*-l=7j&t{o7UPm zA~W;+?Xzw3(ME{ePwzxSlmv%DTsDah~q)b8@fe)yx-~Nj{g0u#k+~&Jw&=)$BzSB!8j^UYe9Wm_+`FRRU(%o zlrxzJ>jzA}Y?T=ub$+3x-l^KzKW@tvI*_jbaCKHzJZ{MQh$~mV&dWvUS>7 zDQ!l!Qkkf``4lbkgvCZ&wDeVN&P+0FOWu|^1;)o7N0I|pT+U3c9!=Ayt(QDemvH#3y3 zh%d&p+V$!4`RbI|jnzk(AW8zIs=ZDPp70}IPN_4;zHdgn4GfSfEGBi>78H4Be)ISZ z+i(-w>QJ`}u9%?>f!~fhH_I#&QV{mFKa!^GX<7NQo=lY{p&z4D&a>ejshO_p@V~hw zX5Yku7r%ckq^x`yfobdsQ6kgYS|n(UAGUJe?`tdM_-6@6vcNgia`D7yVOlF~h%FS`rUhm#*k((?;QNkY|>ZM;sLQ^&+G^6T7laWf(D9F|R z=d!ypRTiUliM(m)wU}%TlmbhtU~&t$-*nXAa>%8N2!q)Pfl%jMQ+X7{=@~UGc_UOR z854mqlFE^YdL&9R;}MaJ&z}Mc;~e%3#y7K_4?r-PjREy?0goF8)q?L*nH>XSdSrX( zCmK~5pyoW?vL-q39AV{u0D%|K-aBbO474?hY?dwD>*W?eGX+I)d-=uG8E}GgbZj~Q zC~1MhopATVWr)51hZVL^z6Bn#)+Y8Oc4MnDImyT5yNl}HpO%k%dNR9wzGO!p<%Szx zc;b;{5>-)*;jc%F$R;*0HD6Q(w2r=bF+wHGVWPTi9BMilQ=^w~BKaTx&TfR%;(x~A znyTwEUt;~@!ymQ^FPmLB5*J~oo|zQ#6a2XQ^56)kt5w;tUOk~*}-i0s`i8+$ot&0y=1GofR8Rr_#3WpAo*tBOj&}H zhFC(huy%WwufDZAI#Qv#MDQJxW_<7;Ezn6_02)jB@+-66gnWA%WOhau;0FmGF=Cd} z9SeO*ZW1W=#`c)Il(PAnl5~RDt2MXh{{I<~ z7i$PnimJax?(J2P=|!@q6lW7`oJk)ecpa2J1lG=y)dkCCF|UzS$UlEvntM+EAW6}# zOR*F_6H|L~O4)j)9LJZ+W*lxY`EJMQG)vu?{484#^ph9vBX7uPZ<9vW4&L+BM#Dx0 z!e>Gc_Zr!?kdyv;XXMRmr3gqtf(e!X#1q`)!G2@QyJJX${&YS9HV@Nns7Cc3(|M_5}4mwQF^kY?N; zf&W69g;mwoF!s(>i}6C7R27(*OOMPhv1%rnk7gj#2Wk5Zpq5DQYMV(nOJ(hJQrPm+ zXH#8c%1m5B_n+$A{(Sz^qDswM*5npFfu#LVF7<(GWUuN%n;zwB?^~~FYiF`wd%I5r zb$(^8rHF|9G(R$6v`2YzESwL0?{`JK_c2ME!H|x5I=+M;5*?w75h^z$Q4y25i>Xof z8m8!{&PVMNl*f-cVpzUr(JWvC4|b>xIHOUOlg1Cnsy!SGl?1s5PwNznc<-53xf+5? zQldLK%m0jo`FL!$JeALLaT++40Nte!)I~VGBU-UX1&1tIv^*N2CiYTa3f@W4BO7=# zh*jnP2 z(;r27^lzF*jb9N@WgF_?6w#VidE2?l-}K3VUP4j#|9T4TG}4avUj*UPSzo$QmBwFJ za*i04jh$9kzOJmWzRh4Y<^qF5Jx*7@hS@Cc>2>!T(N$5rV9zQ1lw<;94Am7}B3PoNRhv%+ECcMO?@cR!cylC+hq zI#s}Eh`;8(O;_Q1j-Ig))X~C_heZb?LI~JnsuDKB^{+-Xd;Jg4`nNNcwklo3@8s-% z0OOOr)Ecg^`$nHV)j5tEY1T&<-*XURs#MW1vQ4YCuRniHInf=R30(dKz&Fd4sacyIf3(z*_0$P7*1&9S z#K!PLMb-~n|60Z~fMYJ{LyOM)-UO5p&-;uYU=C5Y`X{%G4Lo%P*axeRruD4g&@ zz(?2y=u5|+Phi5{2a5y&1b?kFjMBgZX5hydXe#HQA;|BulnQE;ZhqdhU7ID*#Bo$k z*lNa@!w7ZCpkPUC8t8` zOt$h^YAB)i8uOl+FFM8ZOZ?JS$UHT9hJR_=yOnyr)zLsm!D~Snb~0;$!xX@nWh7ic z+CCMGn&O=5Ju5Q!!aN%y=y76SEMCGY9@@JPw!TDc`q2V9Qv+WmS~&eD%GZ%HBz(9j zR012-?xN+8Y18F_3oweHXb0+9OLDN}>ieiL))y{|6{BN!%qVDW#c2OHn+OEFeqQd) zngLd{OW+dzt&;En5Q|u-Q~)su90@;F6fKR==jXnR@tMmz?0Lw@FIvaw4&V^EUsvq> z#ZN|9;tt~P;XLjJOo5c@}7G+{7N5h8NfjDIss%7&(>MieY z6|(!#{n)&^dyr%t0w&nBn3vzqJrnAC_D^}7K@L>xLcwWkP2){9ByoRZ+xO<=M)fMT zftu~qd4zs;)$_BB1Ng-pgC~t_+;-$YQ+4RGCDTDrkM<|u$R4#byf2nMVffO!1U2Qo95H#X>L2pGKNT2$I zsGj*@w9sj|4;R9d$JM%U>!P*y&opeb9BFUC@p9Vg=xKQQl#Z@;TocayzMU8O1*Mc~ zk~ecX1pH@ua7170?s(tEDDm>)^?l?#t0ksUQr*a|m`UxeDV1 zD7(z+oDYlPOBp7evitz&3>2ddgqb0u=te=OeFSd(SlV1^I= z|MOk^h~iz(4AW4KviN>h9_JJi?zM81}{d%@ZBZ1$-3uxFOBhNlVCrOHJmJ zGs;D}?+?lKJ)8;R$sb4ohncfVV>Vspv(PA+5`)`Hacly}SlMF^CxG$HQa%klEG9-<8BvSe=UdNAsX(!`=(EwU+EE1)y(9r%=tOxI1=i6J zR{k$h_Lrf%!98Vokircw9~a#xzHyshYW`)$mQp%-jyhRtA`wyK-#RU{q%g+GEGSf4o~Fa)rIocnwvhKQyi*|UMF?< zCossVO+e}dC21prKx83E27k=KDBSacBhl4Or@eXorn}c50w`q} z%ZwxK_9oiis>SwYqu11?aNeMgN_ZYJ@DV`CrPu?IIhf+12ntH9&-$=f{WxtV;5-00 zj}|3Bm|6$U_;1uII%tX$>zp|_cs6%`h1bqAW!vpXvDyTTbVA@f9Cl{uG#mXX2?SJ= z|Ash_OVW??E96?R@ayYTKsA!y{%5AoZK0S1kHN(r*)65GU7RfnWZ~jjcf%({&ET%i zI1rR@!a)jOmuwx#%hyWt$gnDM+(xdwCdU#-8UXLIeLY1N_`12Obqpk7;UkeV$~O_R zLCw(abc4Qid*%j9^I$q-bWJe;T4xAUnbz_v&oFVz{xO?6BBl3zXlG+Ci?Pv}H1q$& zHNccK2rzw5LdPqWl)VL?>>H*fF2jEg#kW}CsEKy_<#!DE;!P6Nr{-;w9 ze$|;cUv)GUplWq?yl3;$^3ai5FLO%Q+tY|_s4=|OL{H$f>DP-U64~PGvPXcliT;&F zYx;vsSan4+rY;fUFBbMh+o-m$nPgc{_zS#w*V4LiN;7_)8hykV_pdfLG{7W5dx zjq3U5Ms3XR&5H}6PJWHuGtFn5SQ0UpNyxtMS%C%9fowYQSFGfbi9|f|iiJQ;${JnUe-Nwq z$v*<93s;b?^&Ckx1!rRtWcyLHdl3dMdF{Je1HM=CLf~+DkL!=26(JsDT^ZmgOW~@c zKN;8xd9x;TNvLTa_VgN)sV{v~^T)Paf=oME0^$zo1XsiVAxT)O*=)S`M|o2ph-Ezr zVIi+Eyb<&)LqyA@>8-fSYkS94tFI>)=Slvi&!53ys648q8MQ4HsTGE-Ke9b2KRQ|p z0Rc(@9ewf?(_Xv2a&kGGF=7;Woo$#>+i@zsXDYgN(n;Y zsm_3qbW&UVE0*gND_2ZP@IE^^#mr!_t0tT8U)ExUqWv$Tfjn+fI~ixhX{9IY+2a&8RUKeI#XFC4t2{6reGRf`zEiXgPn(VYq zfl5^i*>;+nh7?BJB>J9EjUQ>X2Pfq;U-(xcTa`}BXO1y5Gc~$NAOicn*#`XjW7CRg z5(x}(Ks~9V^_G8%uQKwhwBS@qRSFL&^(sa0???SF^_9OhVoC1-UudcD-sU{}Ek?e4 zy7Gy*|D)(E{F;2<-ih`LQuV=)(=k$CPYc{0Q3qjVv2SL1SIkP6uUBS422 zsXd3=CV%G(`);5s24L(zz3(Jp2S>-ak52I>wNvt1kl+it83@9n@-Ac`>_AWbVNE|* z%Xz=*E&G0x(qXvm&Bvkgb{*?^5BWD$opkOWjWM>VlF~Nn8GIfA?!krI|I*fuaTeiPe393qV9H zK~}Dc5jdS3*UfZu%&5LX(AZv#U;rR{`<&SK_ZEcVjhE=A(Ylc#)9br9yq$S-jifG~ z=rR7T45&|ax^YorYGux0=dO?>bh75a)vJbX=oKMdG4r#;C`Vja`Oz(m4WyNSJbiT6 z>nXI9Yig>Nm%!*m1^I>dOHl-c3C-LuskbDBBWXs>3uvV9&AxLSG?j|u@KLyFV_Z9N zYpAxl&QKsC9-J33*O$N1YKWLuw}CEsw`;H?l{0I*6>U32eS@rB-%{hz?~#W_NUtPaFPR z-xtVAuMN?E7hw!|Y36G2AFI1XX*7mTN;m>>LJeZhC!2T({PNi zGUw7zxS;_^2A^?HV7A# zTVj*D$SdoJ**+T+ou5l98x|$+dYCnbHVULCa_A-!+`H%LW&x`ayK_Z|o)8fSn;`#$ zDyioaN}nrD)hiXt=t$=PM4wcOd70TkIFFFrgsgI~i`=)5K7Q~Z`qrI2vx1#^-(_j+8dOp7qmzsgbYam&(?NZzl2{eoJN9hOljq#SZOdJCwKGkaAz*l0WDPu}>F-3p}ws57*Z6kxtk z(i=*Z6%k|bAr9pUT&Z-VT|qS4KnOk)Qh442p5e~VZ|m#yhRUY5<($yeMRj576{U{6 zk9f^s69v5ErKWVYnnn~`BxMtnO4jT-m@KvGZ9)$#Ce{o4GBr_Ubm#W*1V{scx1eqk z(z6<5IT>`kG4G&9F$eLsay6lP{y~Q*2J$f94middWm~;YJKakAnIkENAj|@9v-TeInM%Z|c@mr;+&t6~xw;=4TtO-V~z-H1gJQZ+e9DOx9jqI#^M^#&wGZh ztdqbyR|xjmY7Syj1=lnlIpJe|4WMQ>{*8pSk5Jr419`L-@A~ zzb!?U-fxzw3B;>IQL${QFZkhjYC@4@^}*dG^ksG5su-&QTUnqvnS(mtYT*c9Bl;QI zWl6zd=X{+*dSGI;g;e%qPcY+Gj1vG#UGY!h)E;I4Qy&IDOOY#- z*D$nr{LVyY(cRr+P>d3WCQvhkY^uCxfKXY~Y8x|ZwIuXciWR$L>L=4uR0Z*Nr-D^` zKfT!gS&NQBAm$&QJ_r{UP`q2vOz10jlPH_(2~&@w*2P*B$Y>k$jMjpdxmjz=?6}!D z@b48BLY0a(%~T&5n1eR<9~zX4v`%^wpu?3$xiZ5FsmO22l33P%eu_#f+ua*ALFit~Zudwjw#AKhytV)CFGsJ+lmUqLf^s+BTwxZ5g<1FofxgaJd4&k1CK)X z@c+#%k+|!h{HNG7V8Xm#V~uZ*keX+r>IYDaGq^KtM_pgqlZ$AAn;kz9Zga>ChreE% zy*v@pO3BMY(a5A5uRh1fU=ft+jOAc5n_-9^6-DhJfg?5YV=7i$Df`lS*{M_^SRhWV zB+CSf!Ax^HC|tC|$p6DB|tKOv|m|iB=T^E!mc~p(G^15TQ_duS* zUhznuPb=JiqitJfUfbN39`b%8-KIt+x%|A6sk>Kc=B*{{b-TTTv~eaW^F~TiTk@B` zw%>lX!mWWwIuCsgkc`ZRAxD_K^|$kE&X|=aAG}QTQT)#&4QG;lWpqwZ=7b4?Iwd*y)qc*55qg4{H-VQ+;i?{Kx6WcWgTXMzPWPvl()CibhhEzyzkSufA7na zvR)~au-cH>X>n7GSK~LDo8N>Q;V*V7J9v6pyw1Gz{I)Gs_dQal+Y%(H5Cu`Y1|Y!$>GPwF70nViol= zfQgOz@K0Q3=f(TZoWNEmEaPn;h2`b{03VAv>z%~nB~jen z2+%!Dd?EUtB@47p&OY@Q+z4V)M*X!ihO$QSUf%a~tGUK&yct4;{rpyRu!Vix0k*PE z=q(fwcF9reCf*sxz=w@s9> z!$o*pEoLYjn~MHDd>1D7Fne<^_pez$;FplieB7NSM{`ZY`x{?np4FbBUpCue)ZLLH zsFZ4*l9{~JqN3~IG?2~qqX_RHkMF5VMfIisIVR2Ab*+uOU=i&-03FIXe~^T#zVx)Q zz(rb_0Jb+_ir3zTBsaZ9wy!W|a7|phLG|HnzTh8cBK%{EgUNoG6q!gl?0R8X;iVO_ zj{s*WXTu%vh)H?_FB+iTTf1 zozKrSwK=XRXFpkVmWQ>9%NayXEsPE6fCsYqc-53T&|09>#mlvL3{0&j_@=wNA&Qx7u4P zh^W{l)mix&+fEhYQc;+LsAJx%v%8xV;ZUwZ$SI)O%u4p4V>jx$w;Nt-wqK07kGgmG z^SbWwS0NRNP?GKNg|8Whyz^Ldn1lv!4o#QMJIwS?R4py6?(6>m?f=lr<&qA^xcZ!; zvAWL4KnkLCV&}KFgi`+;B95a`qsX_bjPDDIr?IvEpR1|sr|o^>!IaRBWM-2Q{*qi= z9&KVmxw%1)@^e$1jHj8Lz@Lps>P=o=l5YimwYgAk$hWx4WhI;8c z?%|ZBMX|{RoBr8a?1lJ9pj|f(i+FDZ2ZQpx6{R7rxKx4GGwvK@lU-&Ph7XQo#kcML z%%ER+v5qn_UdjXUMB`hO5?=ovl0e&bzZP&Wf0ORA&B6g9gvhGi$Ax#!m5!3 zG|Z_*thgU@9YSK!I2CVaf8Qh!@A8Htk3CShoy7dVP?f|7vA0USdJaEh0%%4~_pea8 zpI(428jdP&QY#J8kyE41VQEh>iMHoA9}m1P{uy7Mp9rsTVT)ES!<9-thI*bq?&Z1U zocrl;)zBM&#=}8@uiIW#Jn&{F6d4!MYBTcA8vsq_r>O|X=cE#g2z?rRt5-)k=0C%% zUPwc!v{(ClZUd=TW()G6k{?ZKv@BaQ*GLT9J+?*q@+>)>>w8BME~&QaoTU)<)4Gie zBHIYdVe^&mZ<)1)leyq;w6PAW%LNqQzv0id-VKX@7WI^Yt?Sli?>JJ8N2kxYvJO;wlOLL*^?s;^}kyZ=>a+?4)B`e(YNh#^+-+}Lc z-<=H(%?^Mq}Pg&+NDS;%f(n0}2)7U~6x~j$aIHR&6#?9<;R= z=uB}P?2o`Q*sdJqe-S=NVc4$=c>kUp3gc8pDH43#IFWFv+8okx7beW8vlc~7Ffc3( zQWGhskN!pv<4MtbPhq^tE`P_82aE=o%`C+$g|${v9N&VU7VW*v;>3GALP%$zLpZO) zUr|PGX~`OduMM#|uqx}*v};ZKb~Xb{)8s8Bayyi2ncb(Fgi^OU0d9V-k(Y-Ut3m_s zFv?18vs{Q(t(@j2vq;qnXM%)Nu49>Ej*LC%s&|Pe=F3#^uyDOvMDnV2TH!Og;zb3E zFrvJ0^d0MFkuS9pa@LiIAka@0obbQ&9CuBjtkd@z=W6)Ts zu=UX1?ft#Mw;NI38<#;&t>q!7B>JY(H26RIsF{JOhMyCkoK5w$ldL)oddstm*|W?L zYfs}yI|pwi^Cv|yk0>99TRwjL>Rns8z=8JdbbE;mZwfBRlznzz5UeSFWjt`lABSVW ztz7zF3N8Q>ULl(#2>vpE5_;!X#wBa|%8BgP&zoB)1My5xc>7!#o6lXB+N!XOJa-@~ zHbvp_4&oSa%-`(;HXYA$Vp4M!UwygR1bg7Tz~xh;k)yR{ zpHY!iAoAC2ili(fe-59FzAAtufP@wgzToOZVXF}yl~+Y4y*o`fe|h_zmE6`t!^(V; zuAH_V=QP8$NuhPseZiX#ceRd50mOLAjM6)Pj7phgG|IuVsBhVHH=cb2a2!%ksnMLN z@EJb#8uv!BgI$AvzuH(zM+`B8Zt9(|F@DIHh55x1j1R-m`9Fh`~$WNm@8C zG1H=>HQ|QV>!ha~`oo&@V2JuAb)u4YlZ8!&3cxs8ANVR+A|#nedNBFY8O6VuW?|8_ zjU24ulKd>DVaPx|*YJ*9nMvW4r0Kgf)v9sFj6th4(GWmKO19d^E8$#1{#01 ziWFpu)6JjDoC?*}s%hJ>`I|5`+-@wsI`6W3wX2+ zhX45$gp*}^cZuaa!?IQN)u{+;6`ROePQ!0SW*yeVCD)!+1M%>oSe!XHcv1#U{UVc4 zM&%>E=hdbAp9)Eld@(^m2&4SS=uB|%c_iK9^waop{Fdmt081?c0{~A%G9J)}9emz$ zws2&VAj(64rRrgB6c^j>e65i3R^0PHUtu0~e#o07Q{Zkapz@|>-Zo$|d%WMtnLvkq zFz5-n;0N)cTiO zbKh3&AiDqjydWnsuWubb;{9p5suAVj!cg~7qfV8x|BUT@odSG@(b}5S?2naO1ovwzs~*ICZjkGFP!Amc>!N|)ix4=s2XFGeEnLmLvUG%d`_Lprd*H*9xtIyQ3k zJk}H`z<8vB7RXVhf|`*LYcvrP{K+cvzU6JeV~H4-h5g4&dP25RQ4fPw$JA{$U?Dc? zNZ+uai+wZqT9bJF2pxIMxeZ?Gijfit#=z*BcnIZRW&g~_*X#h7RkA(7h0f@YL^DNw zpp-tlxGJ!@k&z}NuZZb7I_cH!$nGWEIr+Q#sFVWA=!7SJjv7s%zj=@F+fdl>h`SG# zr2H8G>^7&NLSS$qavIFS%B!lo&kPt#haAx=W$F`cl{8W?okF97Gs;dBLyrpTn|8PN|~3%ik`F9`EOH0ba!3j_0| zgZ%ipKozTu?g(!GnB9GkCveSV97pAY7Nu2{01kfgEYzcXfs7S499j4}#$(`HZ;^_t zZ+W`3C|pWsX1xkW=M6Zki&~L!*#BVjfL(tU!R*f1a5|C=sas(AzSOp>jbDb{O#pEL zL=f9zRAB$gdXKtuT zW|eY*q>*TfvTz}zGt5kD22btC(SLO#3Lq|a@AdNH@`h%#EOM$Z5OfHH7)U-JHjdZP0R4gzKiYMb4fC>t?s6JMUr>B zg(|5pesNa>>l}Bc>(db${xTFULGK=ovQ!wzx#YQ;X(cSfAs?ik9B`9*#Ns*tUl$(= zzb8fZ%@AL6@G4nV((W$&5ZpSks6$ig3jJ352TfNK8cBg6mdZGMO3gAb<|5AVG+#BH zeig0TQrd*$;tWRnzIJn#IuyED#nZ5n%4?6j`;3>bv=(_)s{leasks15xtkUOV3gwzVhx`GmzVLbZ&%Dw#G5CuBi4LY-r6XUDmp=3* zIV~>9a(Fw?kSPz--NSj?H`&r}v&Ptaswnb~>onCWlFN|HDTEPF6$}Y}i*U`C#&L#d z0laJu^k}GzuU)gC7%!!Q&}N^nQsI`~oM!20y=@Haaw}nE65%s?V9Vz$IoL#LV^b8H zQ*OkH)WTy1n5D3bcClE97uBAAbC#;(M&z*A``7v!IK5?}VfjmPS+BEopdOO<_suN> zz}>*FF9N~(U{%S%PImoHr*!oxp09aJGVl}gr~uC23C%~kOU_CTK`Nj266|XQvn2&@ zqiX9H+J3Ejgv#=s;S5b)^iO@G%yI7S2hL-E8ho+$2HvGMHPA>mGMO#AU)R?`mSuIE zbWLyGRsJyIL$`NDHCZ;qNP43`PZYxBYw4jrn zyUT{o4bp+kJTpyu0Z`?zH-p_;iW|kv4`(}#{a88>gtomDfw5&+&?DSxP>x9~kHYah z>iaTVfI=sPDIA#bG#6rG5$HC{SDsKTIoZduU4_blYhU?>uCiF|33oSqEn=cnRf78$ zo{q1C6=TLN%|)QSlnO&u*lk+eHK~cU%T!P0Y72_gR8N&sEj`*lrJrI$bxmPx9NNXo zByL{(<5+^i9REXpjT)uC4Cyg%sMJi;XY?$j8_~r#OD3OEzcZ^xdTUA;*$?kdDCxRe zSi#ihD&X$UI%@H2FCyweUDcTiMI$X~3_xC*^32X<4X*X%4u zca=Km+QiIqDTnnle#lg02`;#241Ues4hOh>&t2Bq!viNYe=z7Qsf&W~$7WtM-1Lh2 z!7^Af3h1Mx*QU+^x0>QPwsM<$u?`%7?cnnt;!;fp3CC zx{bf_z&z6!|BT#}~&Ii`BEr(Dy(&*WJP5o|#j_AS$x;X{keqLDgm4#t-osap=` zssoF?xH$;P?tJi|@LOl>D|v=19TpOzWM2RqWZIxv|A`9zU<2w^CjnN&Rg z*?#q57Q5Oi$@OZ+8&kDWbQui$A&aL{+8aP=34}^5mL6}@=zJscnD-877ORg{RL!*3 z<}grw#S3N4+co}0`_S992aHn36mqmy7UAZy1;%gv$4UpVu}Zv9(GNA6@&GzvTUMRh z;5S9X(JRng^K3mqTdOl)>M~+NTa=^c)jw}ytqK4wGE{Ou{cFKnukz$oXLIpB_bF`- zwhxBY*w-hoF@G3__SJ?DlAO77YDC{#xQa}|rg?DVwtB_3QgwO9)(zFZ-4tQJ(?egS zIw8tdmQMk+hQC_J%H5<*CrZR+5Av6#vN+Oxrg0S-?eQLJ zM*hrlO%;s5_3s|DL(dXf_?itS!6#jf83~-%H`^j%(-MJ>iSegW{xk!mf%jc<~%gdxBB9T;}OZ|IfEjmsY z{LYM(f)xkOb1IFaYNtup6YF}Yg{@YvYzB&}fMn$OGvr3BC`Ri<4a>gd@kQiFsWY2v zezX>kD5HS9x)1tA{U$y^)3-0uz!wAv#2d~*Umu?x{23*j9R{t-iRM*T#i;CbxW8$q zAFhk&FA}W+(MxAWp7vF}_O8uyhGO1Ks>}b9pn&Y&+^%IOabb1FP6;`|C#O~X9hEOy zk6Bk-%t#${5t60k`vfs6#Kq>>#tMP%WUyB;`?&FN?Vhhz;R?#N~l4!|#%iCi8{D<`8{aFnrb#Z!Si zJrb8wI`ukT(G=#c1O=BeEM?^Sqfo`RZ#CjYiN+%p`2_n8ya?1>YN=kd)DHt5J2$Wu z8!nzr!z^BnoFuZE_0Uw5Fc(!J{T`n**jUV`6~E07vgYF+h2a6xbO7OT;CCPO9@|Vf zJjG4<_iuf67t)?vl~4-#kg=+v`snZ@S6)`5ML;TmRBkgqV_Kb0R(kB;*^Zk#u`Hp^ z8DJH@S}%{HaVN7%}!0POl<&NJ0fY5?Nm zWOomv_|NYY>RSS;PitV165&84dB#QFGQ7ZVF+5A39{;ngW5qMWTYfb&mwDFD2&z+7 zYA539^mv_CYD=EB;Ett$QYxaIP0%?0xq~%-k8YcIps00@(1nL5m)v7nK;qir$S18m zl9aCdFsIhD%KES$akhE{G?AHMKrHJ_9(hEX%5?Dv^e-B8PwDjLpw2^>*p&h#)b#Vt z>rV^vgAq0Fi~sj!nu8UG)5wY3gJ$O8APi>1Sa-23fT>kw(B(>2YL>CP|8d5FlUOCz z)2njnhVuiIK)`$J^4gU1b&>w}TH_k6Y62_`rD}1xfudZP_U_z#?Y?U;MS)qGVFv98 zp$nJ){df^~hA8I6A0n{qGp5dmo(1!0WHi*dxbn1N$DzG?1*<7C-x!E{2&W3}=v`cU zb;d_ttSf;8yJG74SoRfE<>d#7PUXVz<=YO*&1yI({OMIa3MP0DPp@J14cshJn6C}8 z%Jhhg!7BWsE$LrjmFUCaUODTgI+^|j$;5%J=x`bSfDP7a!D15+2VRGw>3VTv{ZFG! z8WmNUTPkH=Eg)n`VYaGU2P8^sk4zCz+#9MIX`bXOgu9azl?N6XD85-$mENMu7r=}G zQKq%xEb5Y~+$yTQ7y+&n#TyOQ#^avO{gS5Xg`AYofF=IxANomZe$N# znw;bY!e{!5C^&$eH_dZ^J3h_L5S0qvtc>xvyZPVz5~tg^%a4yQQ5_8OAiyW__9%!6 zbXoevzd34!pDS;;0!>BIT3+V8+PMz$sZ=xgYLVxmiGbE~x!B|*mKaTZ-3?SoKwQ_+ zz{pABiZ63?;Q|Xc5qDJK%O4~h13zMKuhIq<>@L)i}$@1=ua80!2HmUEfYCPi&u+W8i@sIJTNfOU@z z%=6GDWx?#)bA_d)B&+7u!PN(QYr}o#k;{{_`so;?bP@-ttPVtbpsJj~9fIsS}3H5PWG{ zwB>H+35L9=ZY#1&Hhe@nsMCJRl6n*b)DV4qfQ$6g|C#n&uRHn|M1f%v;8l7pQ&A{G z7_ooXCGhiIV&|pXAgRrf9$im$Q9{BI;Vv75{jr^vs~!KwhzldDY;ldHV00W2$KSH$ z{m)N1w)G|?gW8QetE&%3j?UOMKe}$Oz1xR{@PJFbVY^TdL(ZqC{%*4Wz0>}DtVg(D zJ~zcEJj~nSmKn-zc`;OJq#P@I_YgY#rF(3u<%LWkE0LJZyujJ?#9e`2{_J#kuP0(c zBwb&y9KW&h4m#FQRHU(}wIr>WKbt{hJ<$q6 z)!5Qeo?>hI3#584>mPuVcvl~8O~i@iNBqhy)1s`oBZ#{OOLaA;J(v4SE}otgbi-Mo z%D7R8bPiMM-MGLXxbIXps^nN)nvc^|gX1kZe6(3#|9|^33iaPg|LNfZ3qz%SB8}$* z4i5fhZO`at@WVg)#UWCettIYxMwBjUcm3onBA|KEPf){AO7n|+bugPhYi zM2S)5dG9TC*tKO{GKeo*=6?Xk(0uT>QZtv>SW*?`k7+jjDr^A7WjYAs6u1HvrJ~hv zcD}D=vJ=^W<;EJUz=1@qsk;v%|E-SyO1D|6WR)vm|H1`}x~2ZZg5ynF^c|OJ+Yr>z z;>U?N6|2*j?t6PW5e+@`90U+lxe0nWJZ-)&Odnw*Z}NySK8Fi052@D4nKJa%DeNzi zmD|L)%^DTIjV>R<)8)~SBiFg`o4y_SEF{?kcN?x`Q(kzeSA6S|ye?Ho{@{#&5>*fj z8~7O#DDwZV5M8w<6CFGxdrwFM_$2G|HY`8&Egtm4K_Io!aX99@fW?OIc*xIYuA+5A zfALGGa*@5M=_DV38_D~u+!#|JOBi$U|L&cWqg<`xT~+n{OUtu;XZ=+yA#+{$Qzd!M z0}gwk7i7SA>PHUhav#_=JV^hLVMBqZWY0K_+%{eudm!e&Wu9M$!wWO@34P4^^k@wH z>^?P6AUK7O<)`(v;8|oMuF~gw=KqWgH6?I8qtbTg&Ob0(d-K0dJdxa-1Q1SeCzfxq9|U_ z4<@id?7-zg6(r<&Gv_~f6#jZv{Qgz7IHD6K3h_u5eSCwjcxY~(se?W%r=~tFOkcVP zk*L{3q;>R-9QAK1^zFCVypCR{6z;29aH2m5fa3|Et~qONKD0HN5%h8DhW#uIe{GR4 z-&)A0C9|a)(Vx1TqNrsLj;+%+EcH5iv>t1|V^B(v8c<^r1Lw%VZR49qDwhl!VpHfvsz**JCU0n9c>3-Sw z1Yd)psMc%Nzj%)uJCzPXZaTQq!dW)MCzi+nT(o_TZnkp*r*D}gxQZ(@VM%v)J$90__8k^lse|SK4-ha)#Ueuc{pO~U_9%4z zQo!N$GjSnlGaKcIrDr@Jd1e>qu>B6kB**)_nDWo_)9P$_TQ3=WntN-E&ryFWhX1UO z9Jduig@traeksiiE)`eow*U0>^5OZ!G%w}BJv(gvA@1uqm2k1Urbk~&MBwA+8XkTv zP!m%VNQaQ}dZIb`D^jGM$ps!N?5|6bc&s4#@+oDXu!$EfyZtwGMM(}?=Jc1K9EXzs zUZZC{DXNszQ7UpMhd_N73SvVz(w;VO1d)eitp^u;&eRVjGBIf-TnXfVZ(eh3<|52B zA$@47`=E2Df#$P5`tRKwuWafQX)}vxlhO$tuAA`4FW~a!tSOhZ|71$5cn3+MVnod| zBFXdST1d9YHG_9k5ShYRU_?+!btGhHe9UA)a>Uq3q!x^oZ&q_|j{6dt5>1hX_AnZ_1Y zr05P~nDQ!WHR*=Abk-)_cPw7djVh}Gqo!LUHx#5+tDalNK3Bb_V&R+^^+(~a*<9f- zehvx%wt+q|l#>Ly*4HzjL08(UJ%?F^>ee*VlRSFRDs$0rY=3q?a%vtRjJ^Ao2dkgp1wa~dFHsFE|QNiXbH zOB1qZF}yvepf-s4L;T#KJlzsC!Hu;pock^=CwsHJ`q2N6X{TN49TY0o4+-8DU@mkQQnv><)u!NxmSA8mampfZsXwp36}|VE!QqJU{p3_MZQ9RUj8*ic$#onr_hPCx~(6;m=X(ygS*Cz`pnAb|L@SUa4b23IbiU z&ut^++uIV^RjW)L^g}D-R_5~_C?yMc0SB0R&9FITT;PAD=-S4#tbnu4?xC!DY!uYk z!PA&aTwJZvJM#$d;s&W$XdT|+lj&qpdEHL#%_-$>)Sy#gpvRfiCkC<0OQz7F{N1{x z{wE@wn~Kg1vAPt^f=TH zEl_;_M6BXdgKwpBJ1B$}fct8x`pH~XF>IH2HJ4kH&|uS&01J*HmxcJwK*4H8C&zZdPtv+T znq(r=s8Fs5ugYKJ#l05V_s>{(zL0Jd6)K#~+8M9gk)^km)`;gJ;3VF@IqvUf?#|Sa z7YiIZZG=+I`#_>_l?3GY?3O&F#%?4RJn^sFp-3gW297 z?PR0~a(ZfogwQt8DpXWR-sC3glJlvNo%h+u6}W|Vn1(-Dl>8Fwh`9XSNq~4zhZk#I z|CwSUaRYdUToJtljt7|PQ%3Z%zQF|)122t3Yc!~sVN1Uyk$yO--M4P>n2$r`m?5{H z5NFc)nt%0l4jzQqsML=4A=Z!CJlV3qRqb|=H zzOZ|VW5KxZm1?MI=mDgozN20crrRy%0?DZq@$F?I!Q5CtptRA6oJTB= zkB1L}2v#C9B%8REr3VPvOnMxqOR(DJk(!#XB~jJ+{zZruuR}cau=@k!kwucR#pC5H8OAoEh!4+bm_xOCDG^w^LZn% zSsEG{8vIdFKYY4Qyrv4rx?vjZDvz!#bVQH8ZiJt^!$~2qOXr&qg+f4>J>WstsXh z3=C(SxGzeIg&<0vB;&yF{PAh|Wu|JJj-A(QC1q5o3* z1{zZbLkzQ4XSEOO#viX}*aK3T-#GuZA0?D~_Nim{KCWZUn?kHCxp}~tge2~d@-{&g zPY@c$!Lz|8_M8mMP!5sf+MZzoye5cY{LZ4Nb7<|9QbCmZhj}!c*k7qU0r6bWyUk(f z>Sk?4^LU=G!{yI_St<_2H*jqkrzY+o{O0dVZZoh;4XO{9eC2I{lEWoJ5Yi=i@1zKbJOI%))1!U)1j9WIS-Gl=XA%)t`L4Ba-jR@hi~kUmp?lV zm0S2QWnzU1o|hZe3zOd|zhr+7Ri#ueSZ&e;rM14yAR~t`phnnI*a?_0&BW$&hDcoX zVZA~#)e35byZ@Fg_~=p}FIBIoRoBjq&qT-ufUqXHGGqK@Pj&H!S z6mF(ShH05JO(4YS8&H@C-nn~t#Pplx-9HFx?iuF}esB9c%ie?=?f3tnBcwh8-u+`B znb8K?udTNs$vyuAh#e@zJRYU&x6R4-1;0te{EuOHW1gDLiQ z-y{$Cne!*o$T8QKb8b{^^aTaQhc2)2tUXK4r?)&|ekNkLN~3TiNWYUB)Bk@lFNmi~9*}J)T(O7bN#D0!BEGFH&&_<%L#u1_t?3M+&DJ^*-|J zycyFH6R zkpzoT1%@dR&g5C(AZQ~6B84T2&c+>cf7GNt;?<;@q}c^WS^NJyyZWSHZx&+`Mkn+? z0F&}-sx;Z?re+rQ4Cfhos7Ss^Y|aR)ckw_-rjPJA-4m_14yxQ6178_5lHvgg)JjTX6EEEb6abk07ITfWDtpJE5{YIvOVP<2 zP8~-)Y`d}7)RnTu+nl^%=KLfooXqE&LP~hHk?j;kQ*81N^o%8Ae{p|52IwsSocVP! zFof(@80sJY1deQk(anIL!jy{a=aEkLN8;;>!^^l2#yf$ zqj^L-G9!k2=p1|Be#M%JW=usZ_B?{mf^t<5t&^+tM#ow#^RM<52Z2vhuQIj?y7)aI1SaLvI zfg^I`c%lhn`u;zxlYB8w#a6tD!Rb(>G^#Alcm2fy_4(yt9V^&Whw#3oLiuTC_`p(i zv(`(5HxF?VX({<)8Bz*@W?`1806WzXTZ)lp&58&&`BXW}o?>PW(6PWLQBH5SDO7|8Oa1QSpcYGCj+~!7muTu?}qGksXqy(6y z3F{m(jL*syOdiw^x_N*F11SRgW#fCxbqE%2?qxFq%)}&ba`QNGmbd zkQfq?$_lS?hTeQt#&8;}Cr0`F9ngGL^H~WV_zs6v-_d!m_;{1O4r6Vy2}8h1*Jg&ky1*Omi|6_ez^aDy>`xhpL2cU9Wdj&#QQ>K zklhW>+XlT3sfc6jh;afRkGmFV#C?h4ZqzfUq{FyYz)O)_Y zFl7*KTAXG zabwUCTYpx#`rh-0?*|jFqd$f82W#cWR~~F6(5(6~tL^mk)~~FKjLn!>HI+ZjvSgKK z;|n-*E7BPNSD%ERx|6=wyqDBPZna8GKIv;DQ0n-3G%1H%{>iUM_x;=8ZSR&^UyZf0 zI&Jb2EniYHh2(?Li=fm_9UUijvWIe)@429ii6HoXaZ;R|wshNR&H1+TP(} z9u(}>?3xuP1lk+EU3Xrd5?4QDv#q>?4{J?8dFAB}*3*8C?*Sg}50>W<%HjL22bEv} z>=h%^WY*5}*mMu^f{P-mnR5CXEF^JUhvCgAr|BG1#g9HIygiwf3iyIQ@$S#3COaeC z^CNJyZg93bQk^i*e8c0Td$yA+P{Lb^M@y@kIwbOp%)f$1r$$d2+sgfMbA_udqg)Y} z7WqKxrWs7QgNe@zgw-UK#f`)LBktsT7(FV0zpOsz;h@55rqWuDQ@Y&SykzwOTWJ&i zfj#%TbCuh)b-f)utbC)ix78;bQFn74RlS~qubSod8gU@G29QVg6(Jt zgThn|>?S3Pr1JFVi5U$_yJnrhI>QtQf4?GL31oA4M zn&_FVgXa*kqu08OjC{l&HHOmo<|!PEwpPBz&t*}e2!r6Q+scc;5Bw`2Z6{R@bp`1< zT1&=sf+$muT=;Kx;f`Yq*G4q07mfBaO@5W9*xzp`>rUM5?lw_Lk=~S>(lW;sq&Fo` zcda=r?M;37E8Tt>d1uulk=($@x2kr!mF0+sf?2pM4tg4zNZR!uz%=|xT3nqYK=9`N zz8A5csk;hadbIeuv8W;YO{trgy~+#ozAYPnD44Xi%+e-%v{O0T>*|?O9A4qdYi=tj z1OgZHP=NdP;U|_Zwknqnzf?bcK0In+@>wJ+3(uA6|G?@eozqFEg|SHFK%DZe%17G^ z&VawZOQTrL6y10b+#p}+z zo%<}CeZozdR8zYdQ)$+o!>!YtVi|=#^f}}HWzbjIG@T`;n3I6-^9Rj8DKfo@h(s1?Go{zKFOM3J^KWl}ywjSvpiad_%cd@Q(6LM5;mEjrRqw!`|DS5AEs? z`u?YClRJ`Zo~J}Y@zKAYmO2X(&%FXvUl(wjbpL$o@>Xpnd@s((e!434hx^6lssig) z`u+3G(u1C@!@n!}yZyWiQF{f2JEznRQKY{~Z_5r8dH$;YJK6(pzHpwY_tX9Kj|MD3 zam_gTXe*K`|GHI0VRo3!bW|%_*;GewU76ubJ zE>=6$JXK*jZOa}M_9b)}Ms-IZpXFq6%$LJ3Pe~U*8-rblk-SmC29Fa27q0fX0iO=h^nDo(26;n@J=qQ}ZFc%yDBl&jX4eIv?z!z;Y439uzZ zPp;R+|JCuwc_wRb1_aDVp?H&C(3UVS6^KciRB7L1=Vy!+_BbRAH6RF`Yb9QMqZEyA zlj(Tqb$os|?9<>-F@Fr&BI!sq_9o-2B`>T^cg_k(6W517CW}oAjtVyrwQQTzVRubb zvI9IafIN-jAa3`Tn!gOVI9qIC#3%B~jEpEYyykQ{snB4;B?Vg~*{R3mV;(^I292=Q z$R*Pb5HpewLj{y>NAc2t?KDxn#6L17+PvUIk`@gU2@_}66Q*NwskLvc?^L}iA=!#n zbt|!4v&U%h57Go!?l__GX$riSxyqhqjh8>N|Ku+~`i!VnZ^HsE0ux%r3_rvDA;5GG z$O%wRK#@D#HC4Oz(;tet>Qxq$WsKDLk3T(?j5liB|JEB-OYA$bnWtFbLR`1BTb8}> z2$yxDfIROc6$rSs)nAXH2DMZsJZFlb@P=e&>S3Z08q0K&w>>>x2o}?`nVX_WiBmL8 zfHXI0)K1C3@}iVqCR3UKeRRo_W~qgVJgspg%RA|qJiG*V%w#n5kkgzhFkeN^-PQ)V z`mH_4hnOmqF@K>iQ6SPk*pW2jZ()%1eF_Yxig~v1i%`t$aB#Lp0%9yFMW7L7HWWEN9TJ z3j&>Sbg*qw&~dKqAYet8@10(mSewY6zw76VdzEe}BAlf_+<&dI@3q#C$)Jx#RS%@aw>&=D6#;2cZF}s^`i7@grdG zkKrFyn~_0|pG>o`Fh}hy0G>@kfdPztt(!Ap{6}oOSlt9PTKG6~d<^br99s}ml@;ez z4J{UMudPCcrT7k3zN8;+-!K6US0E?=q;-~zws&k(XKr*VMel3w2qd0%qL!oCWOn38 zW3tHVR~P*a{5?axFogof#!}uMbj%3TJ|mK9Zs_-d>i0XY__TS;dA9Xbv2Z;J=PaA= z1>ac;!%NAEy2ZW6>dsa6@CXJpe!h*(k4)P80WE8N?oG%Kj62 zU3)@xbqt+8-wrgyL;D8?7c12J3oU&mBR%ICE~A*O0e9iO6ZGRW^eu8bV$bfpL3iAv zv`UfPxwqHl@&sZ0gS(10FFH+im1p%rvOfXa}V`L)bEI%Ii?nw~xW6 zWPHz-?Z9nGpb=q0zOwNwpaBTOuN_^f;}^GU0SJ;F2isGDFdXw; zdow;u5>=G~!M72U<#|p{y~)%PQAn>(X0bf%HcNX4vB^6b0X|4)YcGDp3Pk;#bN6!) zn>mXtO*-At_l8tFWajlHi~0|s?8mmQqQqBRaB^&F;ng*l>E`+>rq40wvGfaLryny= zf_p#u%o;cJ!V@_ec24Y9go<`hYr(vLmV!A<1Zxv0G0ox#y*RFqZGbT0Pr2Ji?iKn8 zCZ-y9cM6e1&XQkx{&A<@&3v}oew~5k{wL~!EM+6>IdmQ=CB!}+%d`id6l?Q>6*p_6o@9Q#W>ow;36!;(5lch4IQGN1sjH5M+xvoUh0MUz9w3FZ)67-CKK8b)M|H8Ye6kmwPH^an z?0k*3DD98v1(U>T3&+uqC9+{lHIFZ%ny`zH5twRr{l_tU&qbX#<2BPC66q1)VhI+* zXDZnaxvm{VhQ&d{BcR*G$b_HMS4h?s0^8{YiGVRq*6brH8y;Z&ywrHQr%XVAD|)7u zr?LD^DF?%V2K_dY5i)6W6BDJw`SMY!LPxrV-0vg)oK=N8rQePxpj*$go+lHJRi|0I z;>bOOa;dGEyud{D{x4Vl8UXB}oVz9hPu5(4Gh&v? zJkY>_~P~ipyTE3N|D=ikhTVZPGRHv zv}|~5l6nb5D@#jbHCdM|AFFK0@@7Xhw@dAif8nhCD+7CC#SEbnW=vU>FpSFjnM)y& zU>aW}lZo5}Gp{Ab{!+-igS8_NMJxtI_*{KsMqT}A&^O*V)JYHVe8(x(m56qkbuLb= zaU%hvw%!db;m^HbzW`b(OxM zjqZ!&P?5q3w}YE>qz|kw&OMGegS2sue3~#MLdQshXY>g?w-QY%xt>)K)e1H{TWR`F zN<^nT@Q~{a*FHVA*#=N)<8%(;-xj&PltaQjGi+ah+!8u1$ZEAj^)I96mGXRx8mB4l zWyZZ!s;`)zapR2ZnbWyknI$J-`x+&_)tmA9g5dF+gAvP0WKWs(;YjA7Bf>{YUW36Q zfeB;GmaQKWDEg+JmWlgAGgGnz>@4mQl$D9M1HU=*ocNE+kA|s2*NcTZp=N@h)5$px zWJXGN=|^!mt!Op^Ij#DkTs2uYk6$YnX7j!96$&N^`a$sY-}bag3~pLH(I6!HoHNER z4o`QeRho4~lwMoXumKJ3rTL2y`Y~usU8u~H8a1O@c+>hla#bQSYd8d|st|U%M*w)F z;7xcP63k-0Uq`sporc`GQqm9_z zGL9R2d>NnZ2Ap4wJ?ZI)c};cCj1rQoh&}hxUARck$w{(+=e~0^^0<{!c}I_7o8{pR zfD8D3ijv<9cAeyy+l%Czm%PK$lNJ*l)cKIm%QdT6U~x)nDgzXxqC)g25sKtzsz^|Z zMB}Jj{#>Cp{#T-a0v-VkV-v{09qxUc=3+=}wVZQ$o&U~8i(l!&Had3-$l}6(m@q{m|@0MCekUjCg=&Sal->OYZGLtIKk9MHbepOplP5DwwURG;Scv z;mtN&H$s*5vG7``s0C{A+E*-h$@p(Gaf!3^4)C!O8+DNx$EG!}J?~DLZx|`hc7mWs61lLq;nbe@?of67qy;bjuL z-Z7FrRt+Jj%&gzl%~$S<*i+&_73dh|NhFZ0z{{Fylk?=&a&QN=sv(FPLI;~-l{E28 z7BW_zSthE2TAYT5bY5PW-O0WmpY5q$DR==Q*w$_=w<|7%RRsp`I&}d^>oa8#h9aL7 zuCMnDG3zb^1+^r^b?^SjoVNL*zmz~f^qRgS>YA?>07I10%m0j>a`nAo`9tWZAKQ$u zLlAj3FOi4q_gQX68?azGFDw@D-+ptzhS;`HbIp zkHC34PFiIxZt&%L=|p+t+XwfCKDTw{x?si_6r$vaJt^0rIK@8=K*-#X3A7m|(cz2} z3Hl{?Km1{u8RQYGa0agJX1pK@9Q7}@KMBjN<_+Ehr5L|MjN3(#Ag9qq01TM_ouRlb z&=8vkE6{RB-IKv4ZHVJKEoeBj7C)|^^~~J(gwlMXcC465Bygx(cvK4l(@@@XRt}xB za8O;4FJ3s#*ON>jkhlzJ!1+nr`Gmo55*q~i_-e0e9}yyI;pImO@u$VC1HqNn3{ z!l>K``zAOy^$xccK}jy_A<8y8kv@EkWl9<@7HZE3 zea1#6_IQZ`Hl;2LN0@Mzg@J;34*5FUwQwSCN4H5pgX-OQb12V9xXhu7S02YkPOtuC{c|++Xk~0HOGy8edFiQ2# zsUoiC-Aq=SPih&r*9ij$?E4m4SL*^&LsjPlDuOlhzPZP8c_m|Y9K_5~TaQVMqK~+Z zKPtRferz+-f1vX|e|@D7*Owe`UBJE1gqAh0!^noW;-Z3L$#GTm-)tksFQ`tknx~&X z7xIMxWTweHL^r)MKLlUt+34{r4Gku@j4?>WYtKtTW?qsuqfUE(2jMHwQ^72XV~BD8 zS;UI6!a_Bf$2P=dGf=$R{sv z6gGDL_A#cJV>ylTl=pNA?L#%gAu70oDZ@+=^86Y{N!fw)T|`0x(X0x0kQ-Kxypt(1EA^E6`w&k*GF)(5uY-`X5Lj<_Skny zsddU#^T5b#4dq`c!Q_s_(q6>&mhtEGmGvrt@no%KyfL00N+-YIO_j)W%MDM=BO7SB zL|o8gN;bJ@BVG=khK27quJ}x79X+TnB9ILe@H4}{l*aqCzUt6SFPVy%f)8o33{fhG zGEbZFsjaMk^N}S;7}lO=+0WWyf148NfqGdQ!8JhcCLwxep=!2H77;wGuOaiqzs1 z@DTXGWZ8ZhIc_FLS~j84F$^KfXRQOWeXJk=O;axiW*iEyr59iO15pq2Ma4%EJCd#3hZii%sYqUlaWRWm}BIo*4ft|#)v<gZmC6e@ht#rx#2wCR11m zpzI*1XeZMcA8*M|H{ny?Ff8UEoCvM>fFBl{7uKR{f~{p+#C3K>YrH0Y1+OIaHeEnL z2CEgOU=5HGsDQZU9NmyyiC?w(9CyY3Fuoz2H?;Pd&r9I`<58@!0?FhK3+`C4iXK-7 z;Lrc~buG;$h9jjo)EOzDI4f@!U;kH?e4v3L==mcppJw!ZEgPXO^7K+fKQD+lp3K+< zu?^*|S>9D!HJDm%sbq_WD<3a=?ZSYZ$U(0?Ysn`1d;Q`tel2F*UU}`xct1Rfi^orI}WEEix6A{kJm-AUi-cOsxBFq2n7 z*4mwHvg~h|1+``f_FHc~#skZ$T6zA8U!G>8c;ELvI?C+dMw2QB+^FnZ?$KIDMDDVv z@H&=X3I499FIN4cCUH7c%_q3WHv2Di1ZJ)*XpR0*9w9k;P5;tKeE4|yLTQfPgT*O4 z_@Zdz;*zdnhR?&HnAljxnU_cfV99&;UK``B>MjyuuM!+5=Op48pzphO+i(_YZbDi9 z%E-XfF;C1|3AbsHN%0QBAG42zWrm~;pWWTn`8A$uYwC#+M{QjYf=@>~UoAABI{Cj5 z{_;lQSgzrS6FKuV#$%^u-$B+$8L|k$}CcWcQ&%-BFJ^t zd@XIbBoYn#qCyTT)#rXyTj!rz?4*N8Kec4@n;M}yU!=h8d~6<*Qxyy$&d}wCss6Sg z2zYTupu;anf_C(6I*D6^(lqC>oV9t(8z48q4w2CC-`s+*i-_I#lox;+$AGfCN?@`g zk0%>lYl(z()mi#nOl_x1y@KDmLfGY_l?~EWDVpS|0|gAU96)R!rkt=eDV1t86@AK3 zc7$CZ{6gF8zU!CPr?U|D?KWy8m`4>%C@sBKP|*u8Bzkc-Pla`cm#3ZRlp^u8DYD@_ zLIrbU0H!AluWB-0>mhwsN2c9e`SXFC;zkb$yu8fqPeJq^t7pHc33>z7QTT5?c~)wS?+som}evfEC7&x}So ztTmaY9VU2)QDfZsTi^WFIZ$YI$PN8@j73@2bmQ=xq~hs&t2|gx@Mp7oNV&k&by(og zzLOesR^x56gSYl4uK*_5Tp9ub1S6y6%Hq?H5X}!x1JojdGL41YIm6a#5B0oo58YY;=8T=mr)tCf%$EuEOCmw1;+8%_K}tE-XZ2z(uB^a24EjJ0q5SW7CJ zv2=x;2dH;*&>Jc9l=m#`-Jt$PDxbpr<)+^oR*y``iBo1HNTv>d%VqMr@ds@0i46Znpwak&f$nwt4jImgia3IEvLi;H*TabDO}!2sS%o2n>_*+wCB}g6gtQ+SGIn1pV1S|n~Gm_Dcjum zjQ(2aAS7HB))wN#h&C4PL>>|nC`SKM{63Fv{fJ(hZ}bSzSZL_I_N7oe|xxLMD&>%MlJfg1I9*id2bcp>s_*E3J@r#ZgwtaU! z^pPJs2_SerM>!Z}3knGu%F~WoKBBKzF}lSfasfv=SA88l7J~On8GjNsKz4Nu)g;v_ zgLt}{T1PaO!l;cC18XJ##tKLeqHc^ZMFMYL(g!{}w3GA6A6}Z9T7bs^8`&|c(-8Up z08+}`TeOB%4|w#5$$DVTG3fZNQ>AEUpN7+aYj~XmHUhu(z4+P5i7a%QJomB36Aa=e-I%S@}BVx4WsCC5uAt&Ly7rG&F&jhetPX6n;GFj6ZD zSEWN7cC2CYu??FG-OI7M&mo-qEtjTUuPW|kC8fk~=q%*g{s+qIs;M$dj*4K6)VWMk z4LRNcJV?@RKZl9i12z;D2uk;i{GP;x&%>-*6~^Y^`969o=p&+(aQgM3AJKmya?(Ve z;?@g1Ex-hekXC4v0%6yNB;8x-z@>;6cgw&a=U#>vyL z=B(=wj+g`^aSbht0`V(9mJ?Oe4Gl=Cn&zP_*L_DhcHqOt>X0Cd%ge+r&i?>)fBi)y z&@|jU57a(tBd0yCY{21Wo*ne}H7WG)AsCWYN@yo4+_)o zJKq0Opht>8J0~0u|2|b*n|@=wX`VxRX#91y6R-TZnhe>0aX21&sSCNA>zXyv^Xo4g zd@CiFN>H%v^XK978C`<6rFJU)0LA{_S&1e)-Y_ticdDcYXV%GzD&VUwyM+=6a=Pm+KKs-=Y}m?JXGl zEVxN~cQBf_oYPoqmfk5C8sUP}&`86+9!c9V3wBAg|JTDC9$e<`(h+9f1xzTV ze;|52%w25E#L?M*H)yt6+k?!@)-C^Jh%VG2%zvbn8}6z?jnTKeR5(G`xRt!2t`|XD+H4~pFICPr1OVR>_cEA@9 zC7OEBP*$&$?V7ZazDUiM__`^lCqefEEyN*#8E9U0uLfNZWN$YE$*lWrzQ|VKXCw3t zll&+-nKV)Fo|s_ZA*mMA7bMaNl%HPd9}N8VGY3_d`r<*8S?26QBK}JB!gp8gySK3S zP>V;2 z#=03V2Q;?OX6etI>|LB0n?j#_Rj_GfNy&S`SNaVe){5-xai*pyaMvU)C4g#Sy-%2n zrqg0wcSM6UCm zH9YKyHmoy{zr!n56H?H`8()vAuQYjB_R_TKu>sKpC!>>ZjtwDnqo2la_J=0U#c$Tg zf^%AX1f?NbV%nlVm~=anmp+_Pora)%Ci03|5l3P&LUjEl;!Da#{On@i$!6RkrO2EK znhJ;jvf}Y&6D5G{d*9{e@`E4D9hjQ1U`H%Whck5et20>atqb+hZ&GJXCx_Za+8yp) zZ2fqPfL0u|#`6LllA3k$D?=^Dl6KhAt;!4Rbm2^!zOLr`LT{>*>6kBozql=&z1Gq7 zwGDwtzKT#7+rzjt(VZq^6O)1V89r;6vr;YPb>Y?1k<>UR9BG^TFQaql(Mj)~w^zVA zOme$MyBnsg#pnSzu3*d}AJ3kr;d?bTCK37{z%vh1`(Zuk{T7>1`ibg$ky0LNtN z&1vdsW@;%CC9IYmIo!5nYQXh&3sm@1e{!R&{Xi4F~HW=rTL;gPtwyx91Sg+}@I z>E6LbCL}5o)`?Q!d~-#sM_GC9CniZWfj}>Dy-lb!xCwvvn?ccI#B=qaVNvJzE=u#$ z1Wc?N{p(Gg>a>A!qrxo8ujeDCC0VJj+uQE_h3+t|p z){OnoCQrSslkMBk@NKxR2fH~q_leWve+w%t83B3t$gX9uQQkV9= z*s{yZXC{%ja{7(D{52sSl;$_}^wa*Tzz9%2u->-dF}EUd6iVYrkQ3$aRz@WX4%c(S z}dnoBvl=+D?cG~zpRsFCFX`rGX+rP=#xVR}+NDVXLhX&@cDB=dy=!oV%meDH( zYFx3DB{h4R=3P}s{Ymkkx_HVl&nB58-Po~EqLTAo+08b0ly{ZDsQQfjdEB5^IA{}c z?P|iS%|_CmMXD+`1B8l&eby;o!Q_ETYAf^DGa*uQRg69Ns~(o8)-cb;6&G3WonBrc z`l5ouii$!C2y40Y>|``+X{Rzv)RZ@ss%Sb{7CU=TA23u|0sGnv3NKbo@YU5uf2ZPt zMr#-1*PaF|heOYBOimiSc&bQ=t%Y%Tq}WO6BjJI<0fa+$H~3rw*@sZZXUD+UhQhNI zD!$G7Q|S98+wt{}RA}Bq{I0ESKSbToKYDn1uy6N$uYv*TFX%Qrn z)F8rBPD6M@GS+x4(qO_+`R%vf9&ykA15}iVsMl#3>Z-RynON%|M3u9nU;c;+0X>Vz zBNI#S7U}Pw8MINg5~N>BoiAoq2azWs7EyUmexUK5CIF^@0bnM?bM+MYJ@}7wV4~Ao z9ZqpHtj>AHDUg^pS9jUphma_o(R-b_u<-NfOMrdu(2(0JxM`Gl4V+Xb^E%xtOJ+*q z_Q?_22Iw=v>yP!Fk=0InuB4|uJ}xhnQB~pNGnk<|+9AZRpbenE)%bb)EX_$P*F{21 z)S(zcDA}3*I&Y}*ASkfs{J379j`8$$;1Im=C9#VYegnxS)?CO_-m#GVd;TFk=<&Tf z0IF4ZMC7lvY4Z3;5ZHwWUYhR+=FcCIKN{Bgke}MrTZ&<1z+bZRdh+fx%@MW~ol)yw z_!liowV#Bq^3jgYE>opBYr^!0iYPi2G&hgC$!03Qc3_kQ@rT^Eo?Wh@5$3Cgfm7i(^s@7v#ht^4&e}ONyziY`o zeP+sgQcfskr#M;8gpu_xI+Zm|Mt)t}x~0pz^M56VF*z0!F?UysKRJ7KV#Ir>_hRlRxa=4n0wa52+$(BQSAONC-lQq#W zu?B^}-O15EmA~W+hQ;gX?Y4NzP|EMje7t0n=HJYs-&%Y~@g)Oms4$(&Ule>mc!%j# zN>=veuu>bt=R_!ZK@s;xcuk8YL%82ohp@@EkQ$J!>DImmTgLs`$7{9uLVHd+-fLGg zNGsOUAxn>9H-jU>5vy|LFvrX~SXJg6r?x+YQ{j%ag&c+QfWQ1K-sSm&6luEda4}Q` zmxU37umh$oJd#LXyE`c_ii*?mdQ{-c2P@{a8QI=ZK|Mjop*jB4wpCIFz9Vt-g&2+u znDq{VjRcpzp&|M<&izA$^GVcTW!vj*okzDH{(`d1^jm4dYQPF1vkc2dprZ6_1+!?u zj=0fEiw{u++{^H+*6Fe$*A~MpGAoy$EKw2{napxpxzt^QXveC(jhS20o{z(eb3tj= zj}kwWo>dfqt5gH)k<*VX@|k{pWyu#AO19lfo|U9vW?vmvbv*f;KT0Xetz{?4=Rf$= zlHu1;il}A31UMyyDYAbNJzWjloMDkCB>3b#bU<`%as_ij)J9CT^lzSI8ZgIwOiqmj ze`q6pq%kb1qS;O3v*xk#0tLxHkeE^Z`lQ#y3RfG*@XX9j<@2m>uwGEx-RQ51jXQMp zLUcS4vnpEMDcg636j1>Tyl^y3kneP*-DyEw3QSQce@p&{#T%th>ou ziT6s8sjdZ-aodVohx%d1Lr#L4rk-;RL(G!W4a13dscnre%;`LF$4ku}*p8;{H;C_w$ZTy5tRc>u&PCcryd^ zl)vYhr?zx(y4-}0tbwod(t2jih$&KcE*6yRa@IgB5>8pZp7s4ItD-vWLr1~=wvl$J zO78SYFtXCBl1{flT3p@x7MNr7}**z9VY>bt&riTGH}W|qJ0 zw;MSUDV3G;=#HFkEZ&0U;}kO;!0(w*YxS>^^iwmN0=1s1F_**wj6fwGcZU_J$t;tI ze$^eL@jljmrS$audk^<%jk+O zH&|?px)-F>LYh(!4DK)!tyCzgLzBmL&2quMYTTnCDAd_&ZRcez=n45g59(-pDW@V! ztW%uV?s?nVXN=qnHKRRM}c{sVP$a z0icu3&VD-B?(P!7cglc45@Ij8UK!txw>AD`t6$lFsU98-NzNj|fjqToJ=k1KX_8$| z11zV1bBnT&OpU2lWJF4UPge9sodq8*RGVJLqLyKv!w{W}Yf0c|e!uSxu0t*6TbISJ7cK$7Q5ip{@9y znWIWrx;R`!#zFQXqaD}8VP(gV9gQ&%3v!8tn|IpIN4dqke?gp=%s?cB9dBf* zD!SZE{uPTm`Le}-^!h)5q77Mx8%#ymkm=)v|u!tmQ1!P$`PK)wsmN+)L!%s6N7V+852N0Z1vc+tcVp%;{|-R{;_d- zc5MJnB5^S}z6!%d`izaLY#MuNx)K5B;sXpxCOw}kBdsri(@I(10V3V6v-r@!Cu_|= z5W6JJEN%5tkvww;oFqY+(h)&P|S%)w_?jN+X^i00PFUopVF)$kaZ9x#W&Sh?hn27zIfL))0lV zWrDCYU=h{A$p>hU z5&r80N}|+WY5C&AjxiX+U$234?@QK30g^mm4=J9tC9M)NA<7RFyaIMY!Tv3 z!#Vl>#8{1nM2uFdIF+~EgxA$d6!b}z$F{#E zeho;8y$P9qMLvWtW4^Ujj#J>-nts@o=aNSx#t|F)J$aU)-wdjZs5XF>D~<&{%^G1I zu(ZngfTr$6)XQa25Zg#|H`MhEe=lS4%%Z@J91Cs`%19bGwN>+U4rUj`v!EEc0uv=m zcy@tB(q3xw|Dm(gxF8CS%+;@=j#jfsP<;7G$0_$ebS0@+V77IszWb3-Nm1ycZXoRg zPq>k{pIIMCmVs!=ny!gZ*-1a*Xkz^dn?Gk(!WMJhDt-I!&l5dY*I^v5uhjigqg4HOJNDJp&IB zQrUt@H*~scQE-sAsio=2s#K=DXSrY+sWzDoD{)ke8cV2Wi8hij8gKAw-erW0>?2B8UpcFg)5kH+mvq*775oPnG^K0*-G4gZ%2r=SIj@JCGPOodA;Jpf)ubw1 zh2rp@64gfW?4*B8p>E`Q47H0f83vsu5g|Wc(@}NZbEX4=;Hx1a;jKrXl`rAhlAkxa zlFa02m7*Q(v7onahZ(VJcyx1EhJ3|ryI211J{Z=SZWT3@&&Yp&> zx`0W=f=IT|8=-;G)e>~lJdC++Ry=5Ow=>{-#xcvbvOu`Di8^Gw)nie_mU4y{g(cQ$ zA!i=Q9oP{k*Z2r3FXh9+$m&%`JN6xfL;9SdM%%JLDXYzMm#o%6jI7{-6_Avx{lh+(tkSTAuY|R|f!4>;Hja zI@j8zRt3lb6)~Q)GNrPc(nzpo6M3LFWCAI94&y3i01CtQS+KcElmU!w)!fr;E?MQ@ zZ=2u0s-XTFBVXei3=i(QB+-*}K(*7=HT z7jF53p*CxJ>zbqNMB7N~7%(8DsJ^0%ityUW(5x~Ku(5WThq-~d8XWbwtYO60zEY7( zFUfzz2v4#BOAkUXLL&>y>zA_-5UDy%#$~Tz;Uw(0Qhk$pCRsa0F}dB@Pn`KwHxzFf zDhQjqjPNW{?5aAh7jppl&qEEskOA|mgstOs6+$`sZ0Or|a*=t#q{Ce)(z$29eReqW z6)u0=B)k=P*h>DTXYBSq`Vp@5F?_uZSQlR?jXPj+&w1OGAgIs&=QKu7o&a zqs$kzFj31t;>07oKYnD-cQ9iu99))&n&1ekCFbZ*{xj=V%$BojN(&uxqiiX z0a4f7c~B3}#>Cs=dl}DELff&l91o_|6(SZb_WuJI@5$0K8<$7g2#)bH`f!ANi&^=# zp_Re<{$@7&s7%#?5C^nanoBto^qeD4!~*Ss8CAh|<~=Wmph$e>JiCaNjEj@TE??;t zpKcETH8SH9p@37{B@l$}b62zB^NDaH8}_^wV=Su9BViK;Wa@oyh!d4x80uz4Gk4Ua zSrdx=OG8-%{jk>L?QKUqY7^yr=C(0SLvxiBqCsB)oSh*kwAz?x z9zEdTe}FmJaOmBwj0T?ln_4-V(;Z@Y^;9Z)BZs5giTkRRb3ewVjKsZsp)E{0cZCU# z-yY%qnY+NAjCz^WQxE~j=req(T-tE?Bjd&K+6-X2Rp`B2JQ81Jh5waT5L|fnE5;(; z~u#s`3ZS z;&OqNGjix=?|ARq)9hQLQoCM(xvO_Lm>Q1g`YF@dueE)jL+&%Wfc_du5y**(RZIAKrR>scx*2=l>-m;gyn!Ek=a*WZV_4y7pL)Fr~t24in z_a^YLy#-#y&95tG0-3=A8GV;m3rll}isW2@5VeJjvq!6u8NeH#m%FV3EEDzw)Zn%G zKefFB`mGM(C+)A>s(k1j%PSFjTA#Kr1vl$+9j2cTAO zr0Epw7pd2s*WJBY&AYR3oV~oL;Idc=^ldIlL?rh<)&MHhcHQHC9*Xx?8uqqy-d8Pk zpO~q|o9|Yp=~nbINEyU^j)toy+(d?o{*R+`@n^dK-}vTyK829;<}_2nqTOQ-VdOB( z`Iu7<$sxK$&N-i#Ln#a!bC|P22_;W=Qp4^p;)Nc|z&VB#%eZVfW`&cD>`JIYSCPkYPYBvXcC)j|Y>y+T}> z<{}<(MRt9}=K?%15XX7<@|MxFT=ebJodYCgHaSZWE!9qUpv%uNyR<5`!F~G(>*#w1 z1~z@=b|MP{`?v zgJZe7I+2?_Yb^>nQC%!}$?NpfWpg9t<#~hNab`#Ie*mJ?M)ZND*g;4CzSQWM`&iY5 zoC@R7yVIq;VdcT50-n@<0i_nS>98bD6c2>zN+%umw(lmkIQre03Q*%w-X+|7+qL#- zWR_iEhu{MRLD@O=`X1{Y)>`dK+m8_?q+As5wdelKDP0kDRqJN(^fHh(0%U4|X>^Hp z`p=)r`M{TX7i_6loi6Nxy?J#?W^1ng(0wEARIB4+!5aLek^Quu78D24%={JE{Zfk2 z+Q#cI&m|#e6|+f-zEd4G$MhcIX~Zz}ePNP7OV@t$AE7Us)($@M-Fo0S{@Ss8Kul7K zQB=|Os)#_TZ$(6}oT-8U5(};qpebSIZ>^CSYN2g!b~5=mX3K)|OH}Cx|1%C za}VkOM8Hr(U`}E{tRd{l^Keau%lId3Kt?B1P9_uhcr; z6{M#UeJLSwW}bR~JfEEKmIjjRUnd1eE0Lbrbo4mnF{b(ih^=5EyHPhB}6x^pb ze&1{?LnPh2c>zUiezdLM){M2?v3vd`n7DGKPuvky`lPaxf1PpUX+{5_u878aLmqgQ z`Gs`XDS8gWrqE@FDD;rx)?Fs3>?ZNg%&11RrJsQf=97H=2U~-jb5N8+yPa{hgSY;} z(5qm@Go$|i3!A=GCK)dZ7v8=pV0i8CQYO;K>Qtn_7nH5BQh3m;`!+*4p9o`N2H4nG z8Mq~q-euGdV)~!f?2a23r-T(WU9B&vEHiCKkF7v&rT3z<xrZH%VVRG^vA3pZE>S%(|a`4E}UrXR?vsol| zWpm2DD7j*fSVx4uyD6DW0(hOEYLP=Fx!e1c5AyaAURAjq)HN$B(}d(t56701D;hQS zJM6H7rlD*Z?`|GXoVlaP<9E!@rX7-A+qxs^Mc(yeq}gv8b}y|C%f*r@W|H$^{_Du$ z@vq-ojZVm%OZjEO8=rb_$#6CZ&OZ5J4B$1m6nC2zSJS;Y`=*p`cJRC{FP{xeAmdL) z*=ru*2XYzI%(d<>7;{i&UbGVd;UaybkbrTkM6h4^&E}VR-_Glx_{`nd)Nzd*-SG*;WC2nI2r z)2`bwP-4t#8W46W4YF*nk{w`+=T>!q4YA<&C7#dX@i(i1 z={xX8L;EkJMMhfZ5f&oC7n8y11;bUPG7p#yQx7EPdt9OpK(oF`>2D75l@F!_GG!tWg0TC-A(m^`N&^c1P_hKUrbOZe_tx8v6c|$LIdnKI0P? zE;L883n3MDJyqPMYfM!}dV^$QhtWT(N{7hJ)8pf8V&1{w(-1YIF5 z#+fmuawiVHdlQa34hiJTD(nrmrhtRODTC;pEpWsWfU-2Hm z{L@r%FOU}5xrj9bm9^za?OL+h@s?gxa^fx20#5uI$PV90vDdG2IV>4aZ4sU#!hYX+ zpDRf*4NSbc9ygV6db}(6wi>53**>(ZuxhodC4qP~FdFjS&Jc~rm`&geg7A(5V)1*M z*Czw;B~8a|82)XSLh{uI{}Kb7H=}*?PyTuM70?kIg~X@rJl=zAf5l! zNa($x^29`R10GcOd_X58Bi!6y_M14++i3~Xa$Bje`#l3&5}tz2(Pnoyw=4A zAyuXo?xaJvJ2lT@V|qABxd_RTlU1vyJuy++er0v}>-nkT`3BWVmSSG0TG84ulv(?^ z9nkqbF?lzt;X^kJSbKM{&u!+s?0Xj4vpv{C5cY|e_vL1jNw9{-eEcx~VY*=Ldt-*1 z=nRTZDM&4A2j}bgA)BBdKa^gP@pUL|A>(l@>e?}HfI#=>lpqb*UaiKWa_%Vpo{LJ$ zxSl5oU0ERH4w?-^oX>ghXJWmiAKOExa>~fZ(_heQ{3>QPKn#g7?_yz9z zBt0C*>wm@9%PRu3ZiAZ$pwpwgq^@r4X^U)*aco-~8w{b!wjqE5HNv~}Bdj$UiSx|T2IrO~)*p*N+Y?tAK`M_g)9yU%1B=?lp+4D}++Zv-N0JN2R2qGC0uY&#-lz&%j^=uB z!RbAyuH*4YjcUTQob+JR1{3eW=C7?eExU0=T~WH0XwN%_!z5XC-YCJ}9_MestZpt~ z11DVx4&a=|>$10Vs*N3%vwgAv;Y7b>XL$a{Tq^Iz?;u^_tcbY!KQTROIZP$*81$MV zEP%Lv(bLmrxcjfaCcgF|jsivg-X^v}i1-#3nW{;4O?2!_6AK}rf z|7OBi9Wpd|S!vF)rbx#ve5ou);`8WjEXZiN z!h`yN5(P-h6wNMta+i!BQ>eE>>RU?AbyQ^gePA1^9lS^TbF_qi)=uL7HWC9ryz`-5 zI-nB6k$;$0OVWi2N|IVBJrXeH7{!{JIw6&tm@<`65uf)qt?UlY@1gO48JPs|8Q^co zet~gSyAnvgQ^$7i^mVsIb>yv^g^%R=WiBx$5oJ?{N-}cjgbWVYlMYxcvp~M7@+nVeq-~d|zC29nI>FW8D6UYdW7(A7bu(uvKf&V5GdeCD=5}qx9}5yy+XeSFz_T*ba)& zuS`smeQtmjVBv<``w3iMQ(BR=+d1>Pbsvt7D!6JaylPFFJw0QkMiq%nh13ypQr-!Q-n89k8ad#^++y5m%)%kTM=S&n|d=(l;HMudD{{WYw4b`naHQOfZ;)a=QQfGBsl{zBQt)(A2lvpfO*Q1>hEF1mx&3Vg=EuD)9 z-suGN-y&)KZoHRSl*pj&G7IuqY*QczQ$*?1dEXfvh5rsUrpIjVzn+CQ8_58z2|r+aZtFR9n9C&lV-3k`-^j!^_@It3^^ z0JQ+H4Opaafx>E!d{Yr9+AR_N@orJ@&B8p5u*P(2bMW=4QQ_8eo$A%h=$HQWMfie~ z$lPrk1%&?$p36$3)Wq}}Zi_101-uo7)e=F)b-_u z#$K2F^|8{N4f5JQNB#}{=`OZUf^O}!cB4yPgh6Hv&|)D9$TWtiJ5MVJ>Uir?zCqC& ziIb=i{E(BbxDh7*} z!62iyVsC%6-_%I<0vNeEV#?S&m`P_bGu$Mj5R>cl-W#EzrM0Mb9nF-#%+98&wpz7Y zj2z~M<`5o+ZBkSprBGn~#%19FfJRe#uzz?hMdVG>ROo@^KL1#u5%9zWp=KUq$y0{) z<=MF@7;a;3##5?mZ&JfyhtEJSOVQsRT=s{5FCgw;IeX;o<6~zV4I2hXHC0dljq0+l ztE^vU{}k81`e&N{Z)Ohs*``5=U%(lfL0TR=TDH<+`MO&B-&bx0i>Lo=dn#LQV!M)( z96=>xk@g3OGL@|%gg#m<0L{kI>(82k3}VraYDyI|8ol3NwF7Ce7Y_iA0wQf|4e}nQ zTHWSkyjJVz(h^3eaW$ZX4PF0LKpz8#Iu*+O!?7Qa!z*`cVdPKG)!&Wgz+J^_1@F)L zX4!s4$r%63Am}a7(Fcr9HiAU?j1E<&)ah?oEdrAOpiurScI_*od6KAW%;ArZSiiCP z8pT1f%A}KWLHN@*ahKakTr=<)`^3-Sgi2x^qemk7gV^O#SgnQxI5}h+;WweKY|MXa z0X1zL$w-a^jbMfq)*0q54EMegscS(&#HqPyFY=u{3&0#ZM~q2RMgOKaHY|^$v>-xg zbs=u2Yn3oQm=Iw6`S^Z6wsb=a!?E)Zg{Ua**q@wr>r;(Y*xfiTO9b!z41I3)3JC9$ zAqAG_$~33bRI^sn{vrhOsBZmM1|K?}9VWOiSdxjEXZjXnwrRG%t6%By&?9ly(c}ta z02JK?d=vjTmi5}-8dq3;V08yzdD%MUxnYc-@!p0%loq_-(2}0&Ukg^tuNCpmctJ1M z(;^q6OlL>((X+T1;6MxNLu2~NBWV5NWwC!yb8%@pbGBhZj*%?{?Z`$yJ;~}a_i{{E zc$c}pphMm?p&u>cAA+8$>kzYmjJ(C!R2$a!pq-MmHLUuT5hZ$Ra19O;zb{rd=XE|l zlb3dDtFVeS1ebIK&?7xeXH8Llm(XPKUd>Rbqe0d-O<;>E4Pz+u5e-scDPhBDEKMR2 z`-^4wbI{ojrgcnF6NzKXzW)KV>f$*=>I`B5e<+`t1!K%{J4_F@1)IdOrtwLXVLwp^ zqZI|V`2aY|)u}_cks!#B`8Eq;1ZQov%_C{%>5(+ry$6JGJzuxDsda74`+1eKL2(=5 zWgrOKcT**6Bc;APJRw!cjWTPF0)|g^^w*+)MZL3P zYS^&=VJ10k>hk#8D~*ze;BVdSfBsK)yVNG3d#g5o7ZFHK1wIE_6nz<7qPhKQDPZRQ ztc>>4!a8JY(N`_9B+~~7Z+2^Y9en{8T`!xmh{a!2rk(e0M02_Wz%1=9)6bRMx5HJx z8c5$JLZ~=I;6}!{%?zE669PrQCX(}g#q2g{OaQ2}MNu>Bc5Kxb5BUtzK*-U)pSL1b ztxGz=#oKGhW_p%&pf%;KFm)KX`D*Zb#UenwmK^8 z0XCe~@lr^7sC+ysykkQGB_J_2^;-fy zK1G2JNUT<%MgUWeewM#HWhLUVjWfW)gx2`WA8UhsFCyafe!s7GSt5zAb1nT;GTYy~ z(Ih8?E_nTh+k!7yMKr=@h1%y3#gQMqx0O<8cl!D`!T@bF;>R89K}kDNGWc_RAcIe) z(Zn1|)VY;!G-znS0K$*70%>ZMbkM}L-dFtkOYT8|xg%XtqnjIdbtlaL{3!}!l%eHZ zH)Oes+E|u7Nsu2k3f+EE$Ig*~2;Uv57ZszXQrUZQ#~pYhhS{y@@?E*b^+%C!I0e{6 zae{qXjRD-o6-VK{vjw39`9&`oy!$-HmhV%Uyip>2;xA?sT4bqcG&!(yAdY428Cfxn zS9gkgLf7j~g=lB5S&s?IUl$mh;sK1cw_Ld0br?a}!2f2ZH#{Hntkpa<9|?N?v$D!j zxF&o&1A24rYkAG;Y-GBRw2HEH%^`hE=yz}4zulEr_u)|yU%u|W!2);d7;fZkJ?QN< zXByFeX(xmon2LzD{@EJ@M`i0)8+>tzc}`cwqR4g^M*Es~YQ++IPsXG#{Wy)Gu@fxC z_P_P%?px{Uk75xb(c*~5A=*DyRw&{qrkTb;EMTeVILy4iHD0IOGPY@y zlp)l=82R8}BO&yRZm>$ZH);MqwAD4>wXv&%5aZep_p!;*b0ocr?8V{Wk?W0=avI$-*(WTCf-6 znG1Y*Hhgxap^r!V8hueRC~oZF@2~09CRKoHt+W_Vr|NN2{j(UTVd{xw+*0fr%|hZ1 z^?vR_oKB0T!s%s0x?3&Sat(AEb3(FytsS*CJ2=z3w5}z!K%V*0{e@9u>FD6I)O>8Y zTiopv9DJo6tU$&f=j3bfW57WcJ z2U!sTp`3jOE7m9sbM}6+t%9Q`01)Fq7rlQE#6!$k-6&4}!%{2zV>h4w>%28rAQftv zSzv@#V}0|&W&;38M0sl-|IeOQDv$oU`PJa#s#(a|uB7w#%OC;5vo(;&_4g0o7;kPc zom{%-wRI5LRS95lwl39_)9W};>g~98Qd-(CE1FOd3Y)MOF+KuIu$2}DPC~NdSJh|yT6gs+JAE$kcc6@14akR&d9IQ z4y6`;J9_wa(Z}yqx7QjR%68BFv~on7q`uB*NJBDdoi8dQWDs%5MbipjpExB@%wU3> zgsbGyRH>iz_Q|LMO)4yPwUAyZ3^A^^rkk68EA@6^bYot@8ShcAwD9f&N6eev)pfIr zzHZux%ymtM8BjTz(USW8`@RFNMm(-Q)$RsMnp(#}&DWCclR)ZvvTjQS>jBV*sg={U z_*l_bA$R+)uDJ^e-xH zOi83PgJmCO*{o#Q|BR%ZYD2`dRw^RAJ*EY2ESvUIzoq$h?X)n^3volP|EY#sB`{h^ zt%wMob(}=qRC}awT^?5m7Lu<)0O{8yqlEMZA3izw@Z$XWWxIvOw|`Gh%r@x`+uF~o zH&|tcB;D7S_ReDrl|6ascNaQ1_5tfg-(Pe9Jfyk^N=CkH1g*0*gOkYPt_k$EDtg!U z_<{ks>9Q#5@PBMpgAbqn0}S5V885vov}c-VVuv+LDfzzNycD z#on|sE!{e}6dclIE^RL~80!GTu~c1O zjM6EGXig)qmwm+?eyD~!1+{dnwRGB;Ynvk$kdJIcgI8-xN~)yy=74J)hH2o(zsdtx zIQ`b~|6rZRGKGn-L%+z?(|0-kud;sNG9uVTFrq=WS8GZC6qMe0KF;VgUzXuET<_X5 z^DTatkvZsL%aYTJ%8NI)y_FzJ>$2PRvnENlEH<7XuQ2Ex+d0L~?si2KV0$5B8o#}} zFd_L3VfQt5n?tblm7a8NI?6|x$TJ_ntk|V7&uK9e^*rTkBf*YT=eMp@q+(qWQn0@w z($AR}AUjjQRmUD|Pbm*}LA^D@++I0vl$u|fx^{RPE$^{=(N1vdFn*Aw!lkosNa0hD zO5k5+Tggcm|A7)CV)f8_Xf7sQ?e)~vVR02}D}q0$I>=22iu@csyEd5WDMZaR9k-V( zWTe;j{@rs9`eF21~oIo930Kbfgl;#=yK*t2o}(_~I{N_A96K(U>%oNWBm zy1ly|6#wa_lAO1jNajhDZlCGGge{ScaBm>wMBOI$LwJs3{^p;3Rb(c4q=0Hv!obaK zqJxVc;0P}q()OYl8x#=g@R4fJi4{5Tr#^&7rk*u?ZP<;QKW!&T1WckaW%fbF(5JP} z0EYI{x_i0E(VB3Rd6k}D<`*=U*Y_%m-Yb{FI87%EdCcKP%Q98DcYQ&Xg;^#%1na(@ z^UcLuN@xq*txj;}e*lB%lg3)G%yKn1g0_V2s@gM$MQO=)HSmkfO|=KlejUe*@6zbKWB`xSvM1oLQ-k8{6_kIqd6*>|_7 zL%J)zI19;&ZS~8{9uB?Ze?ND4M7Ixeb!OYFC*U-OtcSI46MJP=XI?sqdWa3Pu)Qs{ z?L)Ilyl`GMs2;r9d0vZ5U4#e!}G^*B=eb z1Xw>3+uqfAva~|Zj-ms_?gOwl;p#;#o`2-LSogPi=QHW-qOTVJ=-PZUTT0kJ=v&Vp zDj|5e8YTUiVWfwbVOG~7Zl zo@EMCF+vCcU6GNT%gsh!#Dr*cH~{G~0{z0Z8gTLB0DEeiKH^`eVzQ;xnvpi=jI-aT zrYu-z#zV%6kK<|Vo&};!1Z{5SI0_GlGSje$zxK$tRi|>UNygr!lRDSA_~Wz=&^hr)lT2@zr( z_6mQ-V+}?(*H=)6c@;+B+o^|il*5&mq_Vi8aI3P+&n+OQ{XO_Iflogo!rs~4~x zpQYmIbm-_M?U6cB7q+RyL^h7gdManon4&s{rS^4jnHCcuB6RPpfM$|!CmQrKm=9v5 zo-LQ_cjxvm@G`$eYGYMH&9fSRC9J0t#N|25dVeB*a{V&r#cm~WciMsEHxFh<+dvDz z%=LwMt)IAcIz-d!`3OMV1E)$)#i;^}Jf^b%&Bom%}{ z)H#5Gyc1^pa`THQ$VUi*JEB^bxd(IDOO`5>2+S6BA2LJyV(*}?bFqQBa^V#GbU^d? z!BK{S*V4G{J3g{?T|f=z!M{9$4xB%B?%lg_cR@NH03Z_$aS$B{^PcBS?xg)r8&YvaLoY>DxawK!f z>3@pwo7dun^O}tx?dR&JpdNV-*64|g;kd9GGnNFc^M0QbZ2+0l{W=Tx8RE7&g{y^L zcXmHZZKZk7I1y{gi;vT20fY0utD_Jn5%r57CHUe>2!07oh~tIBq8_m!+>-*XZVf8=z7yFEvckKXDL=rW}%G~f3kQo=RXwkbpR372LeXd=j*RZ{54Ce z)uQ+7T)Nq_qhSEsK#{>>$zsh{lG>U&Ge+SXXrLE)EN$tCBitUIZmL$aT`-fjDJmB7 z@S|avs!5GrkBDu&+K(YGtNYR-7I|;9_B+wun(}iD#>feln*yEJ zct&$!kZg4&uDRA0UT+5jFEd)gbJ|QF1$wR|zOP5gZ}_ZTOO$n(_f%9l28F zm1+5&V!DC^Ev&zl-pVQ(Zi%RNLb1!mLP`P6hdEdjkXTuwE3Ry1kq_HA|Lxy^?${{)phFyO)9V3(gQR4wkMR7>vmA2~3I$B#bq6XQ9VGqd_U=9^_l45|l zZk#RXO1X*eEs%!(&q?*6x~5A&F9LHX%U?PYs5l3U2_T`jPc=lWu>@P?<5LzD(=T+J zWCwdynt!+5YvfgK5O!QsVDkx?Q5NBD$VJJJ#U<(Cn2D`i^X?VzdsPWLPmfnG?Wb9&3=GAw;651#ghU^^Kw|!>Q1_ zT&j3Ae8W1a^6@ZJ#+u&Fr&FQhu*#!-nf(do+D z80+lAHYW-|ZYl`!dYMnxr47jc?C*g{4RT2e=sx&qk@m(-#CU8JdRW_MjwCRx+@DCWAc6?<&eF>#i0iR(CtT85%bAUpp8wr4F;tw{n7HUrse@ zRCNBqsxoMJWpg`!N*X;yT%}Fy=i}7|w|>cxl(4gD2X5;Li70yJLH=(HtS$^kk~3YF z`Yl@&6X21~H2zw(GVL`yt%u~PhxnRG8WDp`SEbezsgutzHHI@@CS{Jcjz5_aBNM(Q zi&TI^=ry9#PNzK*f>)uKxbXaGAE#T|Wf|sL_Bq-Ybe;+7HN^P?7&o@6VgIVBTc!2Q z`@1RZ(bi07*T++IIcZc?x*;xKSdBvH);y7r+x@5bx_ftB05PDtiacsY6>Ql0DUd3~ zCC~TE=SsxImY&|39@5=(CONhtaCw>K7zTJPj7qJ;sV43`qt*z`n}xbOPzl2b`@2DW~&A=Hl>{J9;O%04tagNWxAe9y)^h2 z9TAnj|9Nlo+|ptG+sky0QYni<(9exkmxtN#rcQx|)2=qBLmz^GX`ckfiKC=ZuLfvi zZ2?%CTD&1|?0uW-qax6FEg#QxT{{2YggBjwxG*sz2La%?-fpenyw2NuD;rep+N`UF z8o1ifc%$b#I=X6E0?IZ9rdW8f6`~byScrx{Ystc=k@cat_!3)Z+Q&5UOPSHz`dNL~ zJy}oaO|o(QQwQ%tGS)Kq5*;S9l=ZBfct8o#c|dUht=jBS8$SQFj@P9+t$c@z2C|A* zi=g0}&(=XRUV#@j`S43d#C%4M^BBngnooq}KKY7pg*{ z^6lMaZ&+(p-j;^6NA<7)Z|!qbH0?8ercdcxL$hff1S4d5Eh2DLFSm`<*3p#4ra#+) z$1-FkW!WoeahC~6Xg=>WsbUZmYY@zvP=^(KbI8_JSDL@FbR4?5c91u$hJ6S6V3T6Z z#amwUby7)qu6sB*?OWmNcULti`nnZ$CCrWfF5%x!eYMp2&>ziqPzvg`85LIEnQw(^ zcHnhub|II!hgWlW+gqFOm^Z|utkg=11{ zdeTq4Rw{fwK^Eii#p(%zfe?enjXT&xrO4c(A2 z1?lAKm@M&&{8zvP%opiJ6g4(^$9U-}gZrOHW-GRRO2_@Obgko%t#v(&9TT!Wa{)Uc!i5{WQc474+190D7u3E6e(gu~ZRHY{dT3q}M_pTGrI@xs@NDnP{y`WHDP#L=kI%R4uS! zPNVu?!^(nuxuGZR4f+adLgDZyQdN$6opLeV-nnL1N7(w`a|5L25!#XE(ouJf1R^U$ z+rbGr7L{A3L~SZ0IzdVZ5Rf+$eeQz%DDw6&DT%TU)$x;1r0(Ih^c5 z61_1RXn;;tZpG1mfV}OE5mw5GZ+_o(p}O$^N<3bG9fIKom#_emC?QxFDo-1G?ds64 z*Ma_*8|rI2(S~n`0HpifS1I?y1BAta^Gc81bc_mW^2xTo!Xf%tP^j(vMVW*vPA@8S ztISR3OIoy}KO`q2GNB`|r>F=hA@ic=3#!*kmigHw8ui8*>p<1Q{$+p4$2-T||mF&*e7yX{{!M~ma)jo3BiTlHH zzV5tFpjHj+vJ}#%rCjm2%g+kxNUzndUY-;yHM7yJ%e6L$hbWpXxy;~gp>C+^Y4?8c z2ihepry@KJa?wC2Un2*q$GEpSm}afbMbdJj@|#k!L;MZijn==-chMd&+f{4Pu!F*Y zSQZ?_0MN@O2%2etg-=oje^0}_*6p$;XbFjgvu z*nVn5b=kU@2VWgctPl@i4RLtFo8F`8yhBGf>1xkn(rA1s3gbLKT08rs_*heUb)Mw} z61`L;9N7hdbyHRRc8VO{82g-tZ3$U5eiaF?ja^LcCDeC0N5L!kuIz8nIRTn^Kk)b;7|%`yM;cOw#Ap0I@YaS8%E{>>`?lRIZnyM2s$NA{64Y z8I7_&)vuU)QPT+q*g>^~T;e6(vFUz|Y)qc%2N#ZvQx|g!ZlHpoU|%2YA=jI$#yQQe zliBaCQ1o;y`HU(fNB~4Yb-d!(qmV15I^P;NHC7V6M~CtF--(@4wG-o35l zFL-m#bEqCdSy%U9co)aSQPv@Nt=x2S9Y0&ha0OVjU?>O!UuAY}S#>J>KBPuAKx?D) z@)1pMzvc1Y7M4zlt7z#t>ijdX2@cq4&DQhZa5>8TYI)e0#RuNF02q}nfKctT%>zPh zoFp(>haKT&>#&UBMvye-xIL3$o^L=Ekc_l85vQ*P+~RC~BLfzrk?8Z9Ew;eN#^5`* zuBdDW0rC1UYi4yO*t1V41TEaP_~$rI-!`3vu7}0Zg8euJaO>~=>AQ9IlGzfRIuWeb z!Aa*2lM>Azp1~LFV7J2FIqYMsN+I4#>%IyHngouod+NAU_aV%rR{S- z^ouB_kAKFP$N3=ENgHI!pYZ}+`O4aR%CACNru1#CF!%Pc>-eh*nK3a`0@Du+(NCq-A>WJDtcWi*G%s(x2Y8uK4H-93bsG~ z``2NZt8nRD>L8R>i)km>4M#1F)DTtIc7`k}F*OsG$Oe^9PA zKDA%jrRn9cwO5hZ3V#yQtMeyP3Q$k6g`!WVPEWgpd&o=Q3sNrgirMEL`mdZ@kQQZ) zepGV`3xVxLaZK2MI=4v=kBb%NOhju<{qoB$ULe=)k_Gu*-%}r$S2KV-dlm-0F047F zx8MrfCl~XG!@I0x^DZtf8RYAgmnxBU3fHxrDEKcTVug0j{a}rShk;DN5{Qg`)2BW@ zULI9NTqdmF`+%5$gh@$$UI$dY~KAgP4|hkz$F3D3xn zM(hrShEMzy*RAo;G+Qy>x`J$BCPg~nvkeg+Y03=iv^mjY+&FL4z7&+ry3rJp?{Ux6 zL)u5aia9f?Ov+>Izwwz%z9!3a8WZ&zS+9aa6mL~4IebWjaVyl4adQBRa`Hu;30+iH z3rFzTkivl&riqap=yeC=iEQHXwE0H)Vs`Ox_o=k zdvF@q*G>sB z0zfQI{m+93r^kC9WA$X&UZxcN@Hw8C8{A;2BdH#hc?~N5e*OZ$&kE_a+)o>Mf)jsI2 zeT1=}uE|!_@>+iQ`gq>o-n{AFT{cP}gO+sIfYi?M$rmr$c@-sAFr4GkNXL65f=W4a zSZBWBgfwr6v}ULG$?lPl&wtfeT^}?1kkQU&sBYOtKZ$eyAb&H(T*`@*{B0otGkaJ+ zb9)=Gn(5+9J44Rs>>E3SL&V|RQe`UVbTVAhP}l15$&Zzp_$CgURce9ZV^g^gjry|0 zE>8z$H0;vce}L4PZw`6v2v!R}eSpn(b4A2nE^;^g-(%#`D<4qx=+D}Yks*l75Nl43 z{5AKve>n)5&bDvjXXyu2sJr^!J!9qBuh-d~7!YFwxV)m0 zBRR0@Inv@%PuBheYz8HD5Hrx)2%Ayn)5q<8{h%xf9}q>h_(tv|T}e*Zy3*S_=LQOC z97!o)Wh#Gx3zhG&D2l%S?ZP4dK*hx!F(I5#HBhYBT;tSsv18FKda^ihNj@r)j%FS_ z5O@S+iVw(;A~)pwIOmvj&GUM0rUV{zq-SRP#2-{c=ymJM=id|N`d+BHnh2&fWZ>2r zEgEHv3oVP^szs;rq}|^odo9=!QTne$rc5~4juS zOXL3mo)I5Ee-ZePhKYd2G+RB-b#Y|_n*^*LQb(Ivh?AqDJ)jt>XGi5o)D@3mqmQ`H zer#)JCLq{%Pv9B&8-Q_c+y158Z5}BdoGez)S{L!`JfEydmvhcodxdMhOi!ws9&tR_ z-uSKxHL5rOQ3B(Gh9W(>HCDol3kBvY~v9E58gCYaD>w@hF;0PgY6qmDMBF~2YVaX zToZ+|I4PTtn`4s$U%y3g>mEh8C@I(5)5qxD^$6J)mHmm|B;WpAL&?r3qL7r0gTHpS zGE|}}2aF>%z+#k<-qIMQqlnA6$4d}nX<&4by~5h+PPkOrmBYUs9)^f)Wbsk&lp9Gg zM_Uj!Sc0KS>O2fx*V-O)ZH8(jI>uJdfSm@`<=_7JZGU|7u*tVd@9lH{Rnzcn$A}Ii zoLw*6K7rhnch(b@QMR-0n;mn;Jj?zI0RSE|L5F&=OH{4FC#*= z?h3Fgb0pSW*s$7Nntl+*AtOlGB5G8Du+^slZz@eLB4UlDmbPj^##RgSQKzT9pBE@@ zmqhu+?{jg8CDAfUY_#?k%)+16$s2=-OY5g|&s=~X{{u*VxvOxP69sz&Q^xxpNcLXY zraSM={Ohb`=6%Bd0i0&!=i^9Qh5a==WwNREO~OV><>0|2hBr zF4fH|zkxEd@*hBG{7Y6;fLY^4ed%X2B{g*42)-UdA%hvb$6Y-hm}Yx$Bi(}}EV+a@ z4@2EYbD!=>TxnIcTcoW?8K~vw9rkHW86-JzcpUA|OowB&W93mxPu53nibd|%vlrr& zq}3cR^OWx(C zh!5;PooqrBM6{RR{g~)qr%>c%qv0dgecuU21yhxt6uexWb$~^7*eZ)G@=8fxT^X?X zK&i^^Q&u*TH}SsC$}||^ZGXnsZ1s3sz);<-`OWmuaul6%EF2JNBjf7s4z_(9nJ!q+ zap1T8nqX&Ik``*DifLAXRJ_RFEGT+uDbQF4+&(Ua?e-1R_B`B!Gj11vP@leS4oG`B zJr@RBcgW&ZLM=M)n3?~(ef$|xkHZrZ0u^9a1k@;03gPM_HmeTxn=JnnwQ%SitNxrx zJ}?qsl#7hFtwnXU7{}Vi7_Sp6F#Rtr3<6!C-ES-{P!AsLrq4b8GP)fjf%O7tsGd#WC^Pm zdhu&2+D%LHm&jF3o8SOD&$k{{?dao_T~Wrgh364B5~pu@_F9Q{hwyaQ*EI${_Xn@= zt$z4Z_Bi@a3J09I=3MTY@nQG@=0J@~xLTd^N)8ofebZ17jyKSDZcUNSRB0ka-V_{L zV%TAx#?9NLFh_dP5fHn4wN9ptqJ}{?BmY{plk<;NTt*>|gPt~9`ztRrCF!Cs5pIEcImapQStq?-&#w6

&!CIz1Ns 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⩛TtXwH2N8q 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

Wf@+FD?DGvA4he?g#aN51zy(O$Xy! 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

Acrih5KDW9ugPSk3y-@mXy^-HnY=Jp77llly?k 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 literal 0 HcmV?d00001 diff --git a/shierfeng-fastapi-frontend/src/assets/logo/logo.png b/shierfeng-fastapi-frontend/src/assets/logo/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e26376026420542212ed58d90d0ed34f554fa4ae GIT binary patch literal 5663 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 div + > .el-submenu + > .el-submenu__title + .el-submenu__icon-arrow { + display: none; +} + +.el-dropdown .el-dropdown-link{ + color: var(--el-color-primary) !important; +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/assets/styles/index.scss b/shierfeng-fastapi-frontend/src/assets/styles/index.scss new file mode 100644 index 0000000..075ee09 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/index.scss @@ -0,0 +1,179 @@ +@use './mixin.scss'; +@use './transition.scss'; +@use './element-ui.scss'; +@use './sidebar.scss'; +@use './btn.scss'; +@use './shierfeng.scss'; + +body { + height: 100%; + margin: 0; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; +} + +label { + font-weight: 700; +} + +html { + height: 100%; + box-sizing: border-box; +} + +#app { + height: 100%; +} + +*, +*:before, +*:after { + box-sizing: inherit; +} + +.no-padding { + padding: 0px !important; +} + +.padding-content { + padding: 4px 0; +} + +a:focus, +a:active { + outline: none; +} + +a, +a:focus, +a:hover { + cursor: pointer; + color: inherit; + text-decoration: none; +} + +div:focus { + outline: none; +} + +.fr { + float: right; +} + +.fl { + float: left; +} + +.pr-5 { + padding-right: 5px; +} + +.pl-5 { + padding-left: 5px; +} + +.block { + display: block; +} + +.pointer { + cursor: pointer; +} + +.inlineBlock { + display: block; +} + +.clearfix { + &:after { + visibility: hidden; + display: block; + font-size: 0; + content: " "; + clear: both; + height: 0; + } +} + +aside { + background: #eef1f6; + padding: 8px 24px; + margin-bottom: 20px; + border-radius: 2px; + display: block; + line-height: 32px; + font-size: 16px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + color: #2c3e50; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + a { + color: #337ab7; + cursor: pointer; + + &:hover { + color: rgb(32, 160, 255); + } + } +} + +//main-container全局样式 +.app-container { + padding: 20px; +} + +.components-container { + margin: 30px 50px; + position: relative; +} + +.text-center { + text-align: center +} + +.sub-navbar { + height: 50px; + line-height: 50px; + position: relative; + width: 100%; + text-align: right; + padding-right: 20px; + transition: 600ms ease position; + background: linear-gradient(90deg, rgba(32, 182, 249, 1) 0%, rgba(32, 182, 249, 1) 0%, rgba(33, 120, 241, 1) 100%, rgba(33, 120, 241, 1) 100%); + + .subtitle { + font-size: 20px; + color: #fff; + } + + &.draft { + background: #d0d0d0; + } + + &.deleted { + background: #d0d0d0; + } +} + +.link-type, +.link-type:focus { + color: #337ab7; + cursor: pointer; + + &:hover { + color: rgb(32, 160, 255); + } +} + +.filter-container { + padding-bottom: 10px; + + .filter-item { + display: inline-block; + vertical-align: middle; + margin-bottom: 10px; + } +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/mixin.scss b/shierfeng-fastapi-frontend/src/assets/styles/mixin.scss new file mode 100644 index 0000000..06fa061 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/mixin.scss @@ -0,0 +1,66 @@ +@mixin clearfix { + &:after { + content: ""; + display: table; + clear: both; + } +} + +@mixin scrollBar { + &::-webkit-scrollbar-track-piece { + background: #d3dce6; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #99a9bf; + border-radius: 20px; + } +} + +@mixin relative { + position: relative; + width: 100%; + height: 100%; +} + +@mixin pct($pct) { + width: #{$pct}; + position: relative; + margin: 0 auto; +} + +@mixin triangle($width, $height, $color, $direction) { + $width: $width/2; + $color-border-style: $height solid $color; + $transparent-border-style: $width solid transparent; + height: 0; + width: 0; + + @if $direction==up { + border-bottom: $color-border-style; + border-left: $transparent-border-style; + border-right: $transparent-border-style; + } + + @else if $direction==right { + border-left: $color-border-style; + border-top: $transparent-border-style; + border-bottom: $transparent-border-style; + } + + @else if $direction==down { + border-top: $color-border-style; + border-left: $transparent-border-style; + border-right: $transparent-border-style; + } + + @else if $direction==left { + border-right: $color-border-style; + border-top: $transparent-border-style; + border-bottom: $transparent-border-style; + } +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/shierfeng.scss b/shierfeng-fastapi-frontend/src/assets/styles/shierfeng.scss new file mode 100755 index 0000000..a83c67d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/shierfeng.scss @@ -0,0 +1,308 @@ +/** + * 通用css样式布局处理 + * Copyright (c) 2019 ruoyi + */ + + /** 基础通用 **/ +.pt5 { + padding-top: 5px; +} +.pr5 { + padding-right: 5px; +} +.pb5 { + padding-bottom: 5px; +} +.mt5 { + margin-top: 5px; +} +.mr5 { + margin-right: 5px; +} +.mb5 { + margin-bottom: 5px; +} +.mb8 { + margin-bottom: 8px; +} +.ml5 { + margin-left: 5px; +} +.mt10 { + margin-top: 10px; +} +.mr10 { + margin-right: 10px; +} +.mb10 { + margin-bottom: 10px; +} +.ml10 { + margin-left: 10px; +} +.mt20 { + margin-top: 20px; +} +.mr20 { + margin-right: 20px; +} +.mb20 { + margin-bottom: 20px; +} +.ml20 { + margin-left: 20px; +} + +.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 { + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} + +.el-form--inline { + .el-form-item { + .el-input, .el-cascader, .el-select, .el-autocomplete { + width: 200px; + } + } +} + +.el-form .el-form-item__label { + font-weight: 700; +} +.el-dialog:not(.is-fullscreen) { + margin-top: 6vh !important; +} + +.el-dialog.scrollbar .el-dialog__body { + overflow: auto; + overflow-x: hidden; + max-height: 70vh; + padding: 10px 20px 0; +} + +.el-table { + .el-table__header-wrapper, .el-table__fixed-header-wrapper { + th { + word-break: break-word; + background-color: #f8f8f9 !important; + color: #515a6e; + height: 40px !important; + font-size: 13px; + } + } + .el-table__body-wrapper { + .el-button [class*="el-icon-"] + span { + margin-left: 1px; + } + } +} + +/** 表单布局 **/ +.form-header { + font-size:15px; + color:#6379bb; + border-bottom:1px solid #ddd; + margin:8px 10px 25px 10px; + padding-bottom:5px +} + +/** 表格布局 **/ +.pagination-container { + display: flex; + justify-content: flex-end; + margin-top: 20px; + background-color: transparent !important; +} + +/* 弹窗中的分页器 */ +.el-dialog .pagination-container { + position: static !important; + margin: 10px 0 0 0; + padding: 0 !important; + + .el-pagination { + position: static; + } +} + +/* 移动端适配 */ +@media (max-width: 768px) { + .pagination-container { + .el-pagination { + > .el-pagination__jump { + display: none !important; + } + > .el-pagination__sizes { + display: none !important; + } + } + } +} + +/* tree border */ +.tree-border { + margin-top: 5px; + border: 1px solid var(--el-border-color-light, #e5e6e7); + background: var(--el-bg-color, #FFFFFF) none; + border-radius:4px; + width: 100%; +} + +.el-table .fixed-width .el-button--small { + padding-left: 0; + padding-right: 0; + width: inherit; +} + +/* horizontal el menu */ +.el-menu--horizontal .el-menu-item .svg-icon + span, +.el-menu--horizontal .el-sub-menu__title .svg-icon + span { + margin-left: 3px; +} + +.el-menu--horizontal .el-menu--popup { + min-width: 120px !important; +} + +/** 表格更多操作下拉样式 */ +.el-table .el-dropdown-link { + cursor: pointer; + color: #409EFF; + margin-left: 10px; +} + +.el-table .el-dropdown, .el-icon-arrow-down { + font-size: 12px; +} + +.el-tree-node__content > .el-checkbox { + margin-right: 8px; +} + +.list-group-striped > .list-group-item { + border-left: 0; + border-right: 0; + border-radius: 0; + padding-left: 0; + padding-right: 0; +} + +.list-group { + padding-left: 0px; + list-style: none; +} + +.list-group-item { + border-bottom: 1px solid #e7eaec; + border-top: 1px solid #e7eaec; + margin-bottom: -1px; + padding: 11px 0px; + font-size: 13px; +} + +.pull-right { + float: right !important; +} + +.el-card__header { + padding: 14px 15px 7px !important; + min-height: 40px; +} + +.el-card__body { + padding: 15px 20px 20px 20px !important; +} + +.card-box { + margin-bottom: 10px; +} + +/* button color */ +.el-button--cyan.is-active, +.el-button--cyan:active { + background: #20B2AA; + border-color: #20B2AA; + color: #FFFFFF; +} + +.el-button--cyan:focus, +.el-button--cyan:hover { + background: #48D1CC; + border-color: #48D1CC; + color: #FFFFFF; +} + +.el-button--cyan { + background-color: #20B2AA; + border-color: #20B2AA; + color: #FFFFFF; +} + +/* text color */ +.text-navy { + color: #1ab394; +} + +.text-primary { + color: inherit; +} + +.text-success { + color: #1c84c6; +} + +.text-info { + color: #23c6c8; +} + +.text-warning { + color: #f8ac59; +} + +.text-danger { + color: #ed5565; +} + +.text-muted { + color: #888888; +} + +/* image */ +.img-circle { + border-radius: 50%; +} + +.img-lg { + width: 120px; + height: 120px; +} + +.avatar-upload-preview { + position: absolute; + top: 50%; + transform: translate(50%, -50%); + width: 200px; + height: 200px; + border-radius: 50%; + box-shadow: 0 0 4px #ccc; + overflow: hidden; +} + +/* 拖拽列样式 */ +.sortable-ghost{ + opacity: .8; + color: #fff!important; + background: #42b983!important; +} + +/* 表格右侧工具栏样式 */ +.top-right-btn { + margin-left: auto; +} + +/* 分割面板样式 */ +.splitpanes.default-theme .splitpanes__pane { + background-color: var(--splitpanes-default-bg) !important; +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss b/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss new file mode 100755 index 0000000..5ee4ac8 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss @@ -0,0 +1,238 @@ +@use './variables.module.scss' as vars; + +#app { + + .main-container { + min-height: 100%; + transition: margin-left .28s; + margin-left: vars.$base-sidebar-width; + position: relative; + } + + .sidebarHide { + margin-left: 0!important; + } + + .sidebar-container { + transition: width 0.28s; + width: vars.$base-sidebar-width !important; + height: 100%; + position: fixed; + font-size: 0px; + top: 0; + bottom: 0; + left: 0; + z-index: 1001; + overflow: hidden; + -webkit-box-shadow: 2px 0 6px rgba(0,21,41,.35); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + + // reset element-ui css + .horizontal-collapse-transition { + transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; + } + + .scrollbar-wrapper { + overflow-x: hidden !important; + } + + .el-scrollbar__bar.is-vertical { + right: 0px; + } + + .el-scrollbar { + height: 100%; + } + + &.has-logo { + .el-scrollbar { + height: calc(100% - 50px); + } + } + + .is-horizontal { + display: none; + } + + a { + display: inline-block; + width: 100%; + overflow: hidden; + } + + .svg-icon { + margin-right: 10px !important; + } + + .el-menu { + border: none; + height: 100%; + width: 100% !important; + } + + .el-menu-item, .menu-title { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } + + .el-menu-item .el-menu-tooltip__trigger { + display: inline-block !important; + } + + // menu hover + .sub-menu-title-noDropdown, + .el-sub-menu__title { + &:hover { + background-color: rgba(0, 0, 0, 0.06) !important; + } + } + + & .theme-dark .is-active > .el-sub-menu__title { + color: vars.$base-menu-color-active !important; + } + + & .nest-menu .el-sub-menu>.el-sub-menu__title, + & .el-sub-menu .el-menu-item { + min-width: vars.$base-sidebar-width !important; + + &:hover { + background-color: rgba(0, 0, 0, 0.06) !important; + } + } + + & .theme-dark .nest-menu .el-sub-menu>.el-sub-menu__title, + & .theme-dark .el-sub-menu .el-menu-item { + background-color: vars.$base-sub-menu-background; + + &:hover { + background-color: vars.$base-sub-menu-hover !important; + } + } + } + + .hideSidebar { + .sidebar-container { + width: 54px !important; + } + + .main-container { + margin-left: 54px; + } + + .sub-menu-title-noDropdown { + padding: 0 !important; + position: relative; + + .el-tooltip { + padding: 0 !important; + + .svg-icon { + margin-left: 20px; + } + } + } + + .el-sub-menu { + overflow: hidden; + + &>.el-sub-menu__title { + padding: 0 !important; + + .svg-icon { + margin-left: 20px; + } + + } + } + + .el-menu--collapse { + .el-sub-menu { + &>.el-sub-menu__title { + &>span { + height: 0; + width: 0; + overflow: hidden; + visibility: hidden; + display: inline-block; + } + &>i { + height: 0; + width: 0; + overflow: hidden; + visibility: hidden; + display: inline-block; + } + } + } + } + } + + .el-menu--collapse .el-menu .el-sub-menu { + min-width: vars.$base-sidebar-width !important; + } + + // mobile responsive + .mobile { + .main-container { + margin-left: 0px; + } + + .sidebar-container { + transition: transform .28s; + width: vars.$base-sidebar-width !important; + } + + &.hideSidebar { + .sidebar-container { + pointer-events: none; + transition-duration: 0.3s; + transform: translate3d(-(vars.$base-sidebar-width), 0, 0); + } + } + } + + .withoutAnimation { + + .main-container, + .sidebar-container { + transition: none; + } + } +} + +// when menu collapsed +.el-menu--vertical { + &>.el-menu { + .svg-icon { + margin-right: 16px; + } + } + + .nest-menu .el-sub-menu>.el-sub-menu__title, + .el-menu-item { + &:hover { + // you can use $sub-menuHover + background-color: rgba(0, 0, 0, 0.06) !important; + } + } + + // the scroll bar appears when the sub-menu is too long + >.el-menu--popup { + max-height: 100vh; + overflow-y: auto; + + &::-webkit-scrollbar-track-piece { + background: #d3dce6; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #99a9bf; + border-radius: 20px; + } + } +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/transition.scss b/shierfeng-fastapi-frontend/src/assets/styles/transition.scss new file mode 100644 index 0000000..1f74a7e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/transition.scss @@ -0,0 +1,80 @@ +// global transition css + +/* fade */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.28s; +} + +.fade-enter-from, +.fade-leave-active { + opacity: 0; +} + +/* fade-transform */ +.fade-transform--move, +.fade-transform-leave-active, +.fade-transform-enter-active { + transition: all .5s; +} + +.fade-transform-enter-from { + opacity: 0; + transform: translateX(-30px); +} + +.fade-transform-leave-to { + opacity: 0; + transform: translateX(30px); +} + +/* breadcrumb transition */ +.breadcrumb-enter-active, +.breadcrumb-leave-active { + transition: all .5s; +} + +.breadcrumb-enter-from, +.breadcrumb-leave-active { + opacity: 0; + transform: translateX(20px); +} + +.breadcrumb-move { + transition: all .5s; +} + +.breadcrumb-leave-active { + position: absolute; +} + +/* 黑暗模式下过渡效果 */ +::view-transition-new(root), ::view-transition-old(root) { + animation: none !important; + backface-visibility: hidden; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.dark::view-transition-old(root) { + z-index: 2147483646; + background: var(--bg-color-dark); +} + +.dark::view-transition-new(root) { + z-index: 1; + background: var(--bg-color); +} + +::view-transition-old(root) { + z-index: 1; + background: var(--bg-color); +} + +::view-transition-new(root) { + z-index: 2147483646; + background: var(--bg-color-dark); +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss b/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss new file mode 100755 index 0000000..b038f96 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss @@ -0,0 +1,271 @@ +// base color +$blue: #324157; +$light-blue: #333c46; +$red: #C03639; +$pink: #E65D6E; +$green: #30B08F; +$tiffany: #4AB7BD; +$yellow: #FEC171; +$panGreen: #30B08F; + +// 默认主题变量 +$menuText: #bfcbd9; +$menuActiveText: #409eff; +$menuBg: #304156; +$menuHover: #263445; + +// 浅色主题theme-light +$menuLightBg: #ffffff; +$menuLightHover: #f0f1f5; +$menuLightText: #303133; +$menuLightActiveText: #409EFF; + +// 基础变量 +$base-sidebar-width: 200px; +$sideBarWidth: 200px; + +// 菜单暗色变量 +$base-menu-color: #bfcbd9; +$base-menu-color-active: #f4f4f5; +$base-menu-background: #304156; +$base-sub-menu-background: #1f2d3d; +$base-sub-menu-hover: #001528; + +// 组件变量 +$--color-primary: #409EFF; +$--color-success: #67C23A; +$--color-warning: #E6A23C; +$--color-danger: #F56C6C; +$--color-info: #909399; + +:export { + menuText: $menuText; + menuActiveText: $menuActiveText; + menuBg: $menuBg; + menuHover: $menuHover; + menuLightBg: $menuLightBg; + menuLightHover: $menuLightHover; + menuLightText: $menuLightText; + menuLightActiveText: $menuLightActiveText; + sideBarWidth: $sideBarWidth; + // 导出基础颜色 + blue: $blue; + lightBlue: $light-blue; + red: $red; + pink: $pink; + green: $green; + tiffany: $tiffany; + yellow: $yellow; + panGreen: $panGreen; + // 导出组件颜色 + colorPrimary: $--color-primary; + colorSuccess: $--color-success; + colorWarning: $--color-warning; + colorDanger: $--color-danger; + colorInfo: $--color-info; +} + +// CSS变量定义 +:root { + /* 亮色模式变量 */ + --sidebar-bg: #{$menuBg}; + --sidebar-text: #{$menuText}; + --menu-hover: #{$menuHover}; + + --navbar-bg: #ffffff; + --navbar-text: #303133; + + /* splitpanes default-theme 变量 */ + --splitpanes-default-bg: #ffffff; + +} + +// 暗黑模式变量 +html.dark { + /* 默认通用 */ + --el-bg-color: #141414; + --el-bg-color-overlay: #1d1e1f; + --el-text-color-primary: #ffffff; + --el-text-color-regular: #d0d0d0; + --el-border-color: #434343; + --el-border-color-light: #434343; + + /* primary */ + --primary-bg: #18212b; + + /* 侧边栏 */ + --sidebar-bg: #141414; + --sidebar-text: #ffffff; + --menu-hover: #2d2d2d; + --menu-active-text: #{$menuActiveText}; + + /* 顶部导航栏 */ + --navbar-bg: #141414; + --navbar-text: #ffffff; + --navbar-hover: #141414; + + /* 标签栏 */ + --tags-bg: #141414; + --tags-item-bg: #1d1e1f; + --tags-item-border: #303030; + --tags-item-text: #d0d0d0; + --tags-item-hover: #2d2d2d; + --tags-close-hover: #64666a; + + /* splitpanes 组件暗黑模式变量 */ + --splitpanes-bg: #141414; + --splitpanes-border: #303030; + --splitpanes-splitter-bg: #1d1e1f; + --splitpanes-splitter-hover-bg: #2d2d2d; + + /* blockquote 暗黑模式变量 */ + --blockquote-bg: #1d1e1f; + --blockquote-border: #303030; + --blockquote-text: #d0d0d0; + + /* Cron 时间表达式 模式变量 */ + --cron-border: #303030; + + /* splitpanes default-theme 暗黑模式变量 */ + --splitpanes-default-bg: #141414; + + /* 侧边栏菜单覆盖 */ + .sidebar-container { + .el-menu-item:not(.is-active), .menu-title { + color: var(--el-text-color-regular); + } + & .theme-dark .nest-menu .el-sub-menu>.el-sub-menu__title, + & .theme-dark .el-sub-menu .el-menu-item { + background-color: var(--el-bg-color) !important; + } + } + + .topmenu-container { + .el-menu-item, + .el-sub-menu .el-sub-menu__title { + color: var(--el-text-color-regular) !important; + } + } + + .topbar-menu.el-menu--horizontal > .el-sub-menu .el-sub-menu__title{ + color: var(--el-text-color-regular) !important; + } + + /* 顶部栏栏菜单覆盖 */ + .el-menu--horizontal { + .el-menu-item, .el-sub-menu { + &:not(.is-disabled) { + &:hover, + &:focus { + background-color: var(--navbar-hover) !important; + .el-sub-menu__title { + background-color: var(--navbar-hover) !important; + } + } + } + } + } + + /* 分割窗格覆盖 */ + .splitpanes { + background-color: var(--splitpanes-bg); + + .splitpanes__pane { + background-color: var(--splitpanes-bg); + border-color: var(--splitpanes-border); + } + + .splitpanes__splitter { + background-color: var(--splitpanes-splitter-bg); + border-color: var(--splitpanes-border); + + &:hover { + background-color: var(--splitpanes-splitter-hover-bg); + } + + &:before, + &:after { + background-color: var(--splitpanes-border); + } + } + } + + /* 按钮样式覆盖 */ + .el-button--primary.is-plain { + background-color: var(--primary-bg); + border: 1px solid var(--el-color-primary-light-2); + color: var(--el-color-primary-light-2); + + &:hover { + background-color: var(--el-button-hover-bg-color); + border-color: var(--el-button-hover-border-color); + color: var(--el-button-hover-text-color); + } + + &.is-disabled { + background-color: var(--link-active-bg-color); + border-color: var(--el-color-primary-light-3); + color: var(--el-color-primary-light-3); + opacity: 0.5; + } + } + + /* primary tag 样式覆盖 */ + .el-tag--primary { + background-color: var(--primary-bg); + border: 1px solid var(--el-border-color-light); + color: var(--el-color-primary); + } + + /* 表格样式覆盖 */ + .el-table { + --el-table-header-bg-color: var(--el-bg-color-overlay) !important; + --el-table-header-text-color: var(--el-text-color-regular) !important; + --el-table-border-color: var(--el-border-color-light) !important; + --el-table-row-hover-bg-color: var(--el-bg-color-overlay) !important; + + .el-table__header-wrapper, .el-table__fixed-header-wrapper { + th { + background-color: var(--el-bg-color-overlay, #f8f8f9) !important; + color: var(--el-text-color-regular, #515a6e); + } + } + } + + /* 树组件高亮样式覆盖 */ + .el-tree { + .el-tree-node.is-current > .el-tree-node__content { + background-color: var(--el-bg-color-overlay) !important; + color: var(--el-color-primary); + } + + .el-tree-node__content:hover { + background-color: var(--el-bg-color-overlay); + } + } + + /* 下拉菜单样式覆盖 */ + .el-dropdown-menu__item:not(.is-disabled):focus, .el-dropdown-menu__item:not(.is-disabled):hover{ + background-color: var(--navbar-hover) !important; + } + + /* blockquote样式覆盖 */ + blockquote { + background-color: var(--blockquote-bg) !important; + border-left-color: var(--blockquote-border) !important; + color: var(--blockquote-text) !important; + } + + /* 时间表达式标题样式覆盖 */ + .popup-result .title { + background: var(--cron-border); + } + + /* 底部版权样式覆盖 */ + .copyright { + background-color: var(--el-bg-color) !important; + color: var(--el-text-color-regular) !important; + border-top: 1px solid var(--el-bg-color) !important; + } +} + diff --git a/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue b/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue new file mode 100644 index 0000000..059076f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue @@ -0,0 +1,97 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/day.vue b/shierfeng-fastapi-frontend/src/components/Crontab/day.vue new file mode 100644 index 0000000..39263f5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/day.vue @@ -0,0 +1,174 @@ + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/hour.vue b/shierfeng-fastapi-frontend/src/components/Crontab/hour.vue new file mode 100644 index 0000000..8e7c21c --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/hour.vue @@ -0,0 +1,133 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/index.vue b/shierfeng-fastapi-frontend/src/components/Crontab/index.vue new file mode 100644 index 0000000..cbb41ab --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/index.vue @@ -0,0 +1,313 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/min.vue b/shierfeng-fastapi-frontend/src/components/Crontab/min.vue new file mode 100644 index 0000000..65f0946 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/min.vue @@ -0,0 +1,126 @@ + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/month.vue b/shierfeng-fastapi-frontend/src/components/Crontab/month.vue new file mode 100644 index 0000000..e561ba4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/month.vue @@ -0,0 +1,141 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/result.vue b/shierfeng-fastapi-frontend/src/components/Crontab/result.vue new file mode 100644 index 0000000..5a812ee --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/result.vue @@ -0,0 +1,540 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/second.vue b/shierfeng-fastapi-frontend/src/components/Crontab/second.vue new file mode 100644 index 0000000..15e47a0 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/second.vue @@ -0,0 +1,128 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/week.vue b/shierfeng-fastapi-frontend/src/components/Crontab/week.vue new file mode 100644 index 0000000..e59da22 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/week.vue @@ -0,0 +1,197 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Crontab/year.vue b/shierfeng-fastapi-frontend/src/components/Crontab/year.vue new file mode 100644 index 0000000..14758ba --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Crontab/year.vue @@ -0,0 +1,143 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/DictTag/index.vue b/shierfeng-fastapi-frontend/src/components/DictTag/index.vue new file mode 100644 index 0000000..41858f6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/DictTag/index.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/Editor/index.vue b/shierfeng-fastapi-frontend/src/components/Editor/index.vue new file mode 100644 index 0000000..c5e2185 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Editor/index.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/FileUpload/index.vue b/shierfeng-fastapi-frontend/src/components/FileUpload/index.vue new file mode 100644 index 0000000..0cfe4dc --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/FileUpload/index.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/Hamburger/index.vue b/shierfeng-fastapi-frontend/src/components/Hamburger/index.vue new file mode 100644 index 0000000..02d58e9 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Hamburger/index.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/HeaderSearch/index.vue b/shierfeng-fastapi-frontend/src/components/HeaderSearch/index.vue new file mode 100644 index 0000000..f364a6d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/HeaderSearch/index.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/IconSelect/index.vue b/shierfeng-fastapi-frontend/src/components/IconSelect/index.vue new file mode 100644 index 0000000..517a4af --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/IconSelect/index.vue @@ -0,0 +1,111 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/IconSelect/requireIcons.js b/shierfeng-fastapi-frontend/src/components/IconSelect/requireIcons.js new file mode 100644 index 0000000..ac22fd7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/IconSelect/requireIcons.js @@ -0,0 +1,8 @@ +let icons = [] +const modules = import.meta.glob('./../../assets/icons/svg/*.svg'); +for (const path in modules) { + const p = path.split('assets/icons/svg/')[1].split('.svg')[0]; + icons.push(p); +} + +export default icons \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/ImagePreview/index.vue b/shierfeng-fastapi-frontend/src/components/ImagePreview/index.vue new file mode 100644 index 0000000..7e3d2b6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/ImagePreview/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/ImageUpload/index.vue b/shierfeng-fastapi-frontend/src/components/ImageUpload/index.vue new file mode 100644 index 0000000..d984a09 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/ImageUpload/index.vue @@ -0,0 +1,258 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/Pagination/index.vue b/shierfeng-fastapi-frontend/src/components/Pagination/index.vue new file mode 100644 index 0000000..11d4caf --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Pagination/index.vue @@ -0,0 +1,104 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/ParentView/index.vue b/shierfeng-fastapi-frontend/src/components/ParentView/index.vue new file mode 100644 index 0000000..7bf6148 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/ParentView/index.vue @@ -0,0 +1,3 @@ + diff --git a/shierfeng-fastapi-frontend/src/components/RightToolbar/index.vue b/shierfeng-fastapi-frontend/src/components/RightToolbar/index.vue new file mode 100644 index 0000000..32fc644 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/RightToolbar/index.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/Screenfull/index.vue b/shierfeng-fastapi-frontend/src/components/Screenfull/index.vue new file mode 100644 index 0000000..7ad28ea --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/Screenfull/index.vue @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/ShiErFeng/Doc/index.vue b/shierfeng-fastapi-frontend/src/components/ShiErFeng/Doc/index.vue new file mode 100644 index 0000000..27a9c65 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/ShiErFeng/Doc/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/components/ShiErFeng/Git/index.vue b/shierfeng-fastapi-frontend/src/components/ShiErFeng/Git/index.vue new file mode 100644 index 0000000..199c987 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/ShiErFeng/Git/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/components/SizeSelect/index.vue b/shierfeng-fastapi-frontend/src/components/SizeSelect/index.vue new file mode 100644 index 0000000..4c2e7e9 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/SizeSelect/index.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/components/SvgIcon/index.vue b/shierfeng-fastapi-frontend/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..8c101f6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/SvgIcon/index.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/SvgIcon/svgicon.js b/shierfeng-fastapi-frontend/src/components/SvgIcon/svgicon.js new file mode 100644 index 0000000..4431719 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/SvgIcon/svgicon.js @@ -0,0 +1,10 @@ +import * as components from '@element-plus/icons-vue' + +export default { + install: (app) => { + for (const key in components) { + const componentConfig = components[key]; + app.component(componentConfig.name, componentConfig); + } + }, +}; diff --git a/shierfeng-fastapi-frontend/src/components/TopNav/index.vue b/shierfeng-fastapi-frontend/src/components/TopNav/index.vue new file mode 100644 index 0000000..40f424d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/TopNav/index.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/iFrame/index.vue b/shierfeng-fastapi-frontend/src/components/iFrame/index.vue new file mode 100644 index 0000000..091b1a2 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/iFrame/index.vue @@ -0,0 +1,31 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue new file mode 100644 index 0000000..c7dd3a7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/Settings/index.vue b/shierfeng-fastapi-frontend/src/layout/components/Settings/index.vue new file mode 100644 index 0000000..0c802dd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/Settings/index.vue @@ -0,0 +1,315 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/layout/components/Sidebar/Link.vue b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/Link.vue new file mode 100644 index 0000000..8011431 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/Link.vue @@ -0,0 +1,40 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/Sidebar/Logo.vue b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/Logo.vue new file mode 100644 index 0000000..e69b75a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/Logo.vue @@ -0,0 +1,102 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/layout/components/Sidebar/SidebarItem.vue b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/SidebarItem.vue new file mode 100644 index 0000000..66fed4a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/SidebarItem.vue @@ -0,0 +1,100 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/Sidebar/index.vue b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/index.vue new file mode 100644 index 0000000..f0fdf88 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/Sidebar/index.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/TagsView/ScrollPane.vue b/shierfeng-fastapi-frontend/src/layout/components/TagsView/ScrollPane.vue new file mode 100644 index 0000000..516b5d2 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/TagsView/ScrollPane.vue @@ -0,0 +1,105 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/layout/components/TagsView/index.vue b/shierfeng-fastapi-frontend/src/layout/components/TagsView/index.vue new file mode 100644 index 0000000..68c605b --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/TagsView/index.vue @@ -0,0 +1,350 @@ + + + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/layout/components/TopBar/index.vue b/shierfeng-fastapi-frontend/src/layout/components/TopBar/index.vue new file mode 100644 index 0000000..1a2d2f7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/TopBar/index.vue @@ -0,0 +1,99 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/layout/components/index.js b/shierfeng-fastapi-frontend/src/layout/components/index.js new file mode 100644 index 0000000..fd57731 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/index.js @@ -0,0 +1,4 @@ +export { default as AppMain } from './AppMain' +export { default as Navbar } from './Navbar' +export { default as Settings } from './Settings' +export { default as TagsView } from './TagsView/index.vue' diff --git a/shierfeng-fastapi-frontend/src/layout/index.vue b/shierfeng-fastapi-frontend/src/layout/index.vue new file mode 100644 index 0000000..c9a7e61 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/index.vue @@ -0,0 +1,117 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/main.js b/shierfeng-fastapi-frontend/src/main.js new file mode 100644 index 0000000..aee437f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/main.js @@ -0,0 +1,84 @@ +import { createApp } from 'vue' + +import Cookies from 'js-cookie' + +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import locale from 'element-plus/es/locale/lang/zh-cn' + +import '@/assets/styles/index.scss' // global css + +import App from './App' +import store from './store' +import router from './router' +import directive from './directive' // directive + +// 注册指令 +import plugins from './plugins' // plugins +import { download } from '@/utils/request' + +// svg图标 +import 'virtual:svg-icons-register' +import SvgIcon from '@/components/SvgIcon' +import elementIcons from '@/components/SvgIcon/svgicon' + +import './permission' // permission control + +import { useDict } from '@/utils/dict' +import { getConfigKey } from "@/api/system/config" +import { parseTime, resetForm, addDateRange, handleTree, selectDictLabel, selectDictLabels } from '@/utils/shierfeng' + +// 分页组件 +import Pagination from '@/components/Pagination' +// 自定义表格工具组件 +import RightToolbar from '@/components/RightToolbar' +// 富文本组件 +import Editor from "@/components/Editor" +// 文件上传组件 +import FileUpload from "@/components/FileUpload" +// 图片上传组件 +import ImageUpload from "@/components/ImageUpload" +// 图片预览组件 +import ImagePreview from "@/components/ImagePreview" +// 字典标签组件 +import DictTag from '@/components/DictTag' + +const app = createApp(App) + +// 全局方法挂载 +app.config.globalProperties.useDict = useDict +app.config.globalProperties.download = download +app.config.globalProperties.parseTime = parseTime +app.config.globalProperties.resetForm = resetForm +app.config.globalProperties.handleTree = handleTree +app.config.globalProperties.addDateRange = addDateRange +app.config.globalProperties.getConfigKey = getConfigKey +app.config.globalProperties.selectDictLabel = selectDictLabel +app.config.globalProperties.selectDictLabels = selectDictLabels + +// 全局组件挂载 +app.component('DictTag', DictTag) +app.component('Pagination', Pagination) +app.component('FileUpload', FileUpload) +app.component('ImageUpload', ImageUpload) +app.component('ImagePreview', ImagePreview) +app.component('RightToolbar', RightToolbar) +app.component('Editor', Editor) + +app.use(router) +app.use(store) +app.use(plugins) +app.use(elementIcons) +app.component('svg-icon', SvgIcon) + +directive(app) + +// 使用element-plus 并且设置全局的大小 +app.use(ElementPlus, { + locale: locale, + // 支持 large、default、small + size: Cookies.get('size') || 'default' +}) + +app.mount('#app') diff --git a/shierfeng-fastapi-frontend/src/permission.js b/shierfeng-fastapi-frontend/src/permission.js new file mode 100644 index 0000000..7e3b5bf --- /dev/null +++ b/shierfeng-fastapi-frontend/src/permission.js @@ -0,0 +1,69 @@ +import router from './router' +import { ElMessage } from 'element-plus' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' +import { getToken } from '@/utils/auth' +import { isHttp, isPathMatch } from '@/utils/validate' +import { isRelogin } from '@/utils/request' +import useUserStore from '@/store/modules/user' +import useSettingsStore from '@/store/modules/settings' +import usePermissionStore from '@/store/modules/permission' + +NProgress.configure({ showSpinner: false }) + +const whiteList = ['/login', '/register'] + +const isWhiteList = (path) => { + return whiteList.some(pattern => isPathMatch(pattern, path)) +} + +router.beforeEach((to, from, next) => { + NProgress.start() + if (getToken()) { + to.meta.title && useSettingsStore().setTitle(to.meta.title) + /* has token*/ + if (to.path === '/login') { + next({ path: '/' }) + NProgress.done() + } else if (isWhiteList(to.path)) { + next() + } else { + if (useUserStore().roles.length === 0) { + isRelogin.show = true + // 判断当前用户是否已拉取完user_info信息 + useUserStore().getInfo().then(() => { + isRelogin.show = false + usePermissionStore().generateRoutes().then(accessRoutes => { + // 根据roles权限生成可访问的路由表 + accessRoutes.forEach(route => { + if (!isHttp(route.path)) { + router.addRoute(route) // 动态添加可访问路由表 + } + }) + next({ ...to, replace: true }) // hack方法 确保addRoutes已完成 + }) + }).catch(err => { + useUserStore().logOut().then(() => { + ElMessage.error(err) + next({ path: '/' }) + }) + }) + } else { + next() + } + } + } else { + // 没有token + if (isWhiteList(to.path)) { + // 在免登录白名单,直接进入 + next() + } else { + next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页 + NProgress.done() + } + } +}) + +router.afterEach(() => { + NProgress.done() +}) diff --git a/shierfeng-fastapi-frontend/src/plugins/auth.js b/shierfeng-fastapi-frontend/src/plugins/auth.js new file mode 100644 index 0000000..5e8c28d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/plugins/auth.js @@ -0,0 +1,60 @@ +import useUserStore from '@/store/modules/user' + +function authPermission(permission) { + const all_permission = "*:*:*"; + const permissions = useUserStore().permissions + if (permission && permission.length > 0) { + return permissions.some(v => { + return all_permission === v || v === permission + }) + } else { + return false + } +} + +function authRole(role) { + const super_admin = "admin"; + const roles = useUserStore().roles + if (role && role.length > 0) { + return roles.some(v => { + return super_admin === v || v === role + }) + } else { + return false + } +} + +export default { + // 验证用户是否具备某权限 + hasPermi(permission) { + return authPermission(permission); + }, + // 验证用户是否含有指定权限,只需包含其中一个 + hasPermiOr(permissions) { + return permissions.some(item => { + return authPermission(item) + }) + }, + // 验证用户是否含有指定权限,必须全部拥有 + hasPermiAnd(permissions) { + return permissions.every(item => { + return authPermission(item) + }) + }, + // 验证用户是否具备某角色 + hasRole(role) { + return authRole(role); + }, + // 验证用户是否含有指定角色,只需包含其中一个 + hasRoleOr(roles) { + return roles.some(item => { + return authRole(item) + }) + }, + // 验证用户是否含有指定角色,必须全部拥有 + hasRoleAnd(roles) { + return roles.every(item => { + return authRole(item) + }) + } +} diff --git a/shierfeng-fastapi-frontend/src/plugins/cache.js b/shierfeng-fastapi-frontend/src/plugins/cache.js new file mode 100644 index 0000000..b67d453 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/plugins/cache.js @@ -0,0 +1,79 @@ +const sessionCache = { + set (key, value) { + if (!sessionStorage) { + return + } + if (key != null && value != null) { + sessionStorage.setItem(key, value) + } + }, + get (key) { + if (!sessionStorage) { + return null + } + if (key == null) { + return null + } + return sessionStorage.getItem(key) + }, + setJSON (key, jsonValue) { + if (jsonValue != null) { + this.set(key, JSON.stringify(jsonValue)) + } + }, + getJSON (key) { + const value = this.get(key) + if (value != null) { + return JSON.parse(value) + } + return null + }, + remove (key) { + sessionStorage.removeItem(key); + } +} +const localCache = { + set (key, value) { + if (!localStorage) { + return + } + if (key != null && value != null) { + localStorage.setItem(key, value) + } + }, + get (key) { + if (!localStorage) { + return null + } + if (key == null) { + return null + } + return localStorage.getItem(key) + }, + setJSON (key, jsonValue) { + if (jsonValue != null) { + this.set(key, JSON.stringify(jsonValue)) + } + }, + getJSON (key) { + const value = this.get(key) + if (value != null) { + return JSON.parse(value) + } + return null + }, + remove (key) { + localStorage.removeItem(key); + } +} + +export default { + /** + * 会话级缓存 + */ + session: sessionCache, + /** + * 本地缓存 + */ + local: localCache +} diff --git a/shierfeng-fastapi-frontend/src/plugins/download.js b/shierfeng-fastapi-frontend/src/plugins/download.js new file mode 100644 index 0000000..547641b --- /dev/null +++ b/shierfeng-fastapi-frontend/src/plugins/download.js @@ -0,0 +1,79 @@ +import axios from 'axios' +import { ElLoading, ElMessage } from 'element-plus' +import { saveAs } from 'file-saver' +import { getToken } from '@/utils/auth' +import errorCode from '@/utils/errorCode' +import { blobValidate } from '@/utils/shierfeng' + +const baseURL = import.meta.env.VITE_APP_BASE_API +let downloadLoadingInstance; + +export default { + name(name, isDelete = true) { + var url = baseURL + "/common/download?fileName=" + encodeURIComponent(name) + "&delete=" + isDelete + axios({ + method: 'get', + url: url, + responseType: 'blob', + headers: { 'Authorization': 'Bearer ' + getToken() } + }).then((res) => { + const isBlob = blobValidate(res.data); + if (isBlob) { + const blob = new Blob([res.data]) + this.saveAs(blob, decodeURIComponent(res.headers['download-filename'])) + } else { + this.printErrMsg(res.data); + } + }) + }, + resource(resource) { + var url = baseURL + "/common/download/resource?resource=" + encodeURIComponent(resource); + axios({ + method: 'get', + url: url, + responseType: 'blob', + headers: { 'Authorization': 'Bearer ' + getToken() } + }).then((res) => { + const isBlob = blobValidate(res.data); + if (isBlob) { + const blob = new Blob([res.data]) + this.saveAs(blob, decodeURIComponent(res.headers['download-filename'])) + } else { + this.printErrMsg(res.data); + } + }) + }, + zip(url, name) { + var url = baseURL + url + downloadLoadingInstance = ElLoading.service({ text: "正在下载数据,请稍候", background: "rgba(0, 0, 0, 0.7)", }) + axios({ + method: 'get', + url: url, + responseType: 'blob', + headers: { 'Authorization': 'Bearer ' + getToken() } + }).then((res) => { + const isBlob = blobValidate(res.data); + if (isBlob) { + const blob = new Blob([res.data], { type: 'application/zip' }) + this.saveAs(blob, name) + } else { + this.printErrMsg(res.data); + } + downloadLoadingInstance.close(); + }).catch((r) => { + console.error(r) + ElMessage.error('下载文件出现错误,请联系管理员!') + downloadLoadingInstance.close(); + }) + }, + saveAs(text, name, opts) { + saveAs(text, name, opts); + }, + async printErrMsg(data) { + const resText = await data.text(); + const rspObj = JSON.parse(resText); + const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'] + ElMessage.error(errMsg); + } +} + diff --git a/shierfeng-fastapi-frontend/src/plugins/index.js b/shierfeng-fastapi-frontend/src/plugins/index.js new file mode 100644 index 0000000..47d1b41 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/plugins/index.js @@ -0,0 +1,18 @@ +import tab from './tab' +import auth from './auth' +import cache from './cache' +import modal from './modal' +import download from './download' + +export default function installPlugins(app){ + // 页签操作 + app.config.globalProperties.$tab = tab + // 认证对象 + app.config.globalProperties.$auth = auth + // 缓存对象 + app.config.globalProperties.$cache = cache + // 模态框对象 + app.config.globalProperties.$modal = modal + // 下载文件 + app.config.globalProperties.$download = download +} diff --git a/shierfeng-fastapi-frontend/src/plugins/modal.js b/shierfeng-fastapi-frontend/src/plugins/modal.js new file mode 100644 index 0000000..b59e14d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/plugins/modal.js @@ -0,0 +1,82 @@ +import { ElMessage, ElMessageBox, ElNotification, ElLoading } from 'element-plus' + +let loadingInstance; + +export default { + // 消息提示 + msg(content) { + ElMessage.info(content) + }, + // 错误消息 + msgError(content) { + ElMessage.error(content) + }, + // 成功消息 + msgSuccess(content) { + ElMessage.success(content) + }, + // 警告消息 + msgWarning(content) { + ElMessage.warning(content) + }, + // 弹出提示 + alert(content) { + ElMessageBox.alert(content, "系统提示") + }, + // 错误提示 + alertError(content) { + ElMessageBox.alert(content, "系统提示", { type: 'error' }) + }, + // 成功提示 + alertSuccess(content) { + ElMessageBox.alert(content, "系统提示", { type: 'success' }) + }, + // 警告提示 + alertWarning(content) { + ElMessageBox.alert(content, "系统提示", { type: 'warning' }) + }, + // 通知提示 + notify(content) { + ElNotification.info(content) + }, + // 错误通知 + notifyError(content) { + ElNotification.error(content); + }, + // 成功通知 + notifySuccess(content) { + ElNotification.success(content) + }, + // 警告通知 + notifyWarning(content) { + ElNotification.warning(content) + }, + // 确认窗体 + confirm(content) { + return ElMessageBox.confirm(content, "系统提示", { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: "warning", + }) + }, + // 提交内容 + prompt(content) { + return ElMessageBox.prompt(content, "系统提示", { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: "warning", + }) + }, + // 打开遮罩层 + loading(content) { + loadingInstance = ElLoading.service({ + lock: true, + text: content, + background: "rgba(0, 0, 0, 0.7)", + }) + }, + // 关闭遮罩层 + closeLoading() { + loadingInstance.close(); + } +} diff --git a/shierfeng-fastapi-frontend/src/plugins/tab.js b/shierfeng-fastapi-frontend/src/plugins/tab.js new file mode 100644 index 0000000..f95aee6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/plugins/tab.js @@ -0,0 +1,71 @@ +import useTagsViewStore from '@/store/modules/tagsView' +import router from '@/router' + +export default { + // 刷新当前tab页签 + refreshPage(obj) { + const { path, query, matched } = router.currentRoute.value; + if (obj === undefined) { + matched.forEach((m) => { + if (m.components && m.components.default && m.components.default.name) { + if (!['Layout', 'ParentView'].includes(m.components.default.name)) { + obj = { name: m.components.default.name, path: path, query: query }; + } + } + }); + } + return useTagsViewStore().delCachedView(obj).then(() => { + const { path, query } = obj + router.replace({ + path: '/redirect' + path, + query: query + }) + }) + }, + // 关闭当前tab页签,打开新页签 + closeOpenPage(obj) { + useTagsViewStore().delView(router.currentRoute.value); + if (obj !== undefined) { + return router.push(obj); + } + }, + // 关闭指定tab页签 + closePage(obj) { + if (obj === undefined) { + return useTagsViewStore().delView(router.currentRoute.value).then(({ visitedViews }) => { + const latestView = visitedViews.slice(-1)[0] + if (latestView) { + return router.push(latestView.fullPath) + } + return router.push('/'); + }); + } + return useTagsViewStore().delView(obj); + }, + // 关闭所有tab页签 + closeAllPage() { + return useTagsViewStore().delAllViews(); + }, + // 关闭左侧tab页签 + closeLeftPage(obj) { + return useTagsViewStore().delLeftTags(obj || router.currentRoute.value); + }, + // 关闭右侧tab页签 + closeRightPage(obj) { + return useTagsViewStore().delRightTags(obj || router.currentRoute.value); + }, + // 关闭其他tab页签 + closeOtherPage(obj) { + return useTagsViewStore().delOthersViews(obj || router.currentRoute.value); + }, + // 打开tab页签 + openPage(title, url, params) { + const obj = { path: url, meta: { title: title } } + useTagsViewStore().addView(obj) + return router.push({ path: url, query: params }) + }, + // 修改tab页签 + updatePage(obj) { + return useTagsViewStore().updateVisitedView(obj); + } +} diff --git a/shierfeng-fastapi-frontend/src/router/index.js b/shierfeng-fastapi-frontend/src/router/index.js new file mode 100644 index 0000000..f31c43f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/router/index.js @@ -0,0 +1,174 @@ +import { createWebHistory, createRouter } from 'vue-router' +/* Layout */ +import Layout from '@/layout' + +/** + * Note: 路由配置项 + * + * hidden: true // 当设置 true 的时候该路由不会再侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1 + * alwaysShow: true // 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面 + * // 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面 + * // 若你想不管路由下面的 children 声明的个数都显示你的根路由 + * // 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由 + * redirect: noRedirect // 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击 + * name:'router-name' // 设定路由的名字,一定要填写不然使用时会出现各种问题 + * query: '{"id": 1, "name": "ry"}' // 访问路由的默认传递参数 + * roles: ['admin', 'common'] // 访问路由的角色权限 + * permissions: ['a:a:a', 'b:b:b'] // 访问路由的菜单权限 + * meta : { + noCache: true // 如果设置为true,则不会被 缓存(默认 false) + title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字 + icon: 'svg-name' // 设置该路由的图标,对应路径src/assets/icons/svg + breadcrumb: false // 如果设置为false,则不会在breadcrumb面包屑中显示 + activeMenu: '/system/user' // 当路由设置了该属性,则会高亮相对应的侧边栏。 + } + */ + +// 公共路由 +export const constantRoutes = [ + { + path: '/redirect', + component: Layout, + hidden: true, + children: [ + { + path: '/redirect/:path(.*)', + component: () => import('@/views/redirect/index.vue') + } + ] + }, + { + path: '/login', + component: () => import('@/views/login'), + hidden: true + }, + { + path: '/register', + component: () => import('@/views/register'), + hidden: true + }, + { + path: "/:pathMatch(.*)*", + component: () => import('@/views/error/404'), + hidden: true + }, + { + path: '/401', + component: () => import('@/views/error/401'), + hidden: true + }, + { + path: '', + component: Layout, + redirect: '/index', + children: [ + { + path: '/index', + component: () => import('@/views/dashboard/index'), + name: 'Index', + meta: { title: '首页', icon: 'dashboard', affix: true } + } + ] + }, + { + path: '/user', + component: Layout, + hidden: true, + redirect: 'noredirect', + children: [ + { + path: 'profile/:activeTab?', + component: () => import('@/views/system/user/profile/index'), + name: 'Profile', + meta: { title: '个人中心', icon: 'user' } + } + ] + } +] + +// 动态路由,基于用户权限动态去加载 +export const dynamicRoutes = [ + { + path: '/system/user-auth', + component: Layout, + hidden: true, + permissions: ['system:user:edit'], + children: [ + { + path: 'role/:userId(\\d+)', + component: () => import('@/views/system/user/authRole'), + name: 'AuthRole', + meta: { title: '分配角色', activeMenu: '/system/user' } + } + ] + }, + { + path: '/system/role-auth', + component: Layout, + hidden: true, + permissions: ['system:role:edit'], + children: [ + { + path: 'user/:roleId(\\d+)', + component: () => import('@/views/system/role/authUser'), + name: 'AuthUser', + meta: { title: '分配用户', activeMenu: '/system/role' } + } + ] + }, + { + path: '/system/dict-data', + component: Layout, + hidden: true, + permissions: ['system:dict:list'], + children: [ + { + path: 'index/:dictId(\\d+)', + component: () => import('@/views/system/dict/data'), + name: 'Data', + meta: { title: '字典数据', activeMenu: '/system/dict' } + } + ] + }, + { + path: '/monitor/job-log', + component: Layout, + hidden: true, + permissions: ['monitor:job:list'], + children: [ + { + path: 'index/:jobId(\\d+)', + component: () => import('@/views/monitor/job/log'), + name: 'JobLog', + meta: { title: '调度日志', activeMenu: '/monitor/job' } + } + ] + }, + { + path: '/tool/gen-edit', + component: Layout, + hidden: true, + permissions: ['tool:gen:edit'], + children: [ + { + path: 'index/:tableId(\\d+)', + component: () => import('@/views/tool/gen/editTable'), + name: 'GenEdit', + meta: { title: '修改生成配置', activeMenu: '/tool/gen' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes: constantRoutes, + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition + } + return { top: 0 } + }, +}); + +export default router; diff --git a/shierfeng-fastapi-frontend/src/settings.js b/shierfeng-fastapi-frontend/src/settings.js new file mode 100644 index 0000000..1b573dd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/settings.js @@ -0,0 +1,56 @@ +export default { + /** + * 网页标题 + */ + title: import.meta.env.VITE_APP_TITLE, + + /** + * 侧边栏主题 深色主题theme-dark,浅色主题theme-light + */ + sideTheme: 'theme-dark', + + /** + * 是否系统布局配置 + */ + showSettings: true, + + /** + * 菜单导航模式 1、纯左侧 2、混合(左侧+顶部) 3、纯顶部 + */ + navType: 1, + + /** + * 是否显示 tagsView + */ + tagsView: true, + + /** + * 显示页签图标 + */ + tagsIcon: false, + + /** + * 是否固定头部 + */ + fixedHeader: true, + + /** + * 是否显示logo + */ + sidebarLogo: true, + + /** + * 是否显示动态标题 + */ + dynamicTitle: false, + + /** + * 是否显示底部版权 + */ + footerVisible: false, + + /** + * 底部版权文本内容 + */ + footerContent: 'Copyright © 2024-2026 insistence.tech All Rights Reserved.' +} diff --git a/shierfeng-fastapi-frontend/src/store/index.js b/shierfeng-fastapi-frontend/src/store/index.js new file mode 100644 index 0000000..f10f389 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/index.js @@ -0,0 +1,3 @@ +const store = createPinia() + +export default store \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/store/modules/app.js b/shierfeng-fastapi-frontend/src/store/modules/app.js new file mode 100644 index 0000000..0b57159 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/modules/app.js @@ -0,0 +1,46 @@ +import Cookies from 'js-cookie' + +const useAppStore = defineStore( + 'app', + { + state: () => ({ + sidebar: { + opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true, + withoutAnimation: false, + hide: false + }, + device: 'desktop', + size: Cookies.get('size') || 'default' + }), + actions: { + toggleSideBar(withoutAnimation) { + if (this.sidebar.hide) { + return false; + } + this.sidebar.opened = !this.sidebar.opened + this.sidebar.withoutAnimation = withoutAnimation + if (this.sidebar.opened) { + Cookies.set('sidebarStatus', 1) + } else { + Cookies.set('sidebarStatus', 0) + } + }, + closeSideBar({ withoutAnimation }) { + Cookies.set('sidebarStatus', 0) + this.sidebar.opened = false + this.sidebar.withoutAnimation = withoutAnimation + }, + toggleDevice(device) { + this.device = device + }, + setSize(size) { + this.size = size; + Cookies.set('size', size) + }, + toggleSideBarHide(status) { + this.sidebar.hide = status + } + } + }) + +export default useAppStore diff --git a/shierfeng-fastapi-frontend/src/store/modules/dict.js b/shierfeng-fastapi-frontend/src/store/modules/dict.js new file mode 100644 index 0000000..27fc308 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/modules/dict.js @@ -0,0 +1,57 @@ +const useDictStore = defineStore( + 'dict', + { + state: () => ({ + dict: new Array() + }), + actions: { + // 获取字典 + getDict(_key) { + if (_key == null && _key == "") { + return null; + } + try { + for (let i = 0; i < this.dict.length; i++) { + if (this.dict[i].key == _key) { + return this.dict[i].value; + } + } + } catch (e) { + return null; + } + }, + // 设置字典 + setDict(_key, value) { + if (_key !== null && _key !== "") { + this.dict.push({ + key: _key, + value: value + }); + } + }, + // 删除字典 + removeDict(_key) { + var bln = false; + try { + for (let i = 0; i < this.dict.length; i++) { + if (this.dict[i].key == _key) { + this.dict.splice(i, 1); + return true; + } + } + } catch (e) { + bln = false; + } + return bln; + }, + // 清空字典 + cleanDict() { + this.dict = new Array(); + }, + // 初始字典 + initDict() { + } + } + }) + +export default useDictStore diff --git a/shierfeng-fastapi-frontend/src/store/modules/permission.js b/shierfeng-fastapi-frontend/src/store/modules/permission.js new file mode 100644 index 0000000..496a995 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/modules/permission.js @@ -0,0 +1,127 @@ +import auth from '@/plugins/auth' +import router, { constantRoutes, dynamicRoutes } from '@/router' +import { getRouters } from '@/api/menu' +import Layout from '@/layout/index' +import ParentView from '@/components/ParentView' +import InnerLink from '@/layout/components/InnerLink' + +// 匹配views里面所有的.vue文件 +const modules = import.meta.glob('./../../views/**/*.vue') + +const usePermissionStore = defineStore( + 'permission', + { + state: () => ({ + routes: [], + addRoutes: [], + defaultRoutes: [], + topbarRouters: [], + sidebarRouters: [] + }), + actions: { + setRoutes(routes) { + this.addRoutes = routes + this.routes = constantRoutes.concat(routes) + }, + setDefaultRoutes(routes) { + this.defaultRoutes = constantRoutes.concat(routes) + }, + setTopbarRoutes(routes) { + this.topbarRouters = routes + }, + setSidebarRouters(routes) { + this.sidebarRouters = routes + }, + generateRoutes(roles) { + return new Promise(resolve => { + // 向后端请求路由数据 + getRouters().then(res => { + const sdata = JSON.parse(JSON.stringify(res.data)) + const rdata = JSON.parse(JSON.stringify(res.data)) + const defaultData = JSON.parse(JSON.stringify(res.data)) + const sidebarRoutes = filterAsyncRouter(sdata) + const rewriteRoutes = filterAsyncRouter(rdata, false, true) + const defaultRoutes = filterAsyncRouter(defaultData) + const asyncRoutes = filterDynamicRoutes(dynamicRoutes) + asyncRoutes.forEach(route => { router.addRoute(route) }) + this.setRoutes(rewriteRoutes) + this.setSidebarRouters(constantRoutes.concat(sidebarRoutes)) + this.setDefaultRoutes(sidebarRoutes) + this.setTopbarRoutes(defaultRoutes) + resolve(rewriteRoutes) + }) + }) + } + } + }) + +// 遍历后台传来的路由字符串,转换为组件对象 +function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) { + return asyncRouterMap.filter(route => { + if (type && route.children) { + route.children = filterChildren(route.children) + } + if (route.component) { + // Layout ParentView 组件特殊处理 + if (route.component === 'Layout') { + route.component = Layout + } else if (route.component === 'ParentView') { + route.component = ParentView + } else if (route.component === 'InnerLink') { + route.component = InnerLink + } else { + route.component = loadView(route.component) + } + } + if (route.children != null && route.children && route.children.length) { + route.children = filterAsyncRouter(route.children, route, type) + } else { + delete route['children'] + delete route['redirect'] + } + return true + }) +} + +function filterChildren(childrenMap, lastRouter = false) { + var children = [] + childrenMap.forEach(el => { + el.path = lastRouter ? lastRouter.path + '/' + el.path : el.path + if (el.children && el.children.length && el.component === 'ParentView') { + children = children.concat(filterChildren(el.children, el)) + } else { + children.push(el) + } + }) + return children +} + +// 动态路由遍历,验证是否具备权限 +export function filterDynamicRoutes(routes) { + const res = [] + routes.forEach(route => { + if (route.permissions) { + if (auth.hasPermiOr(route.permissions)) { + res.push(route) + } + } else if (route.roles) { + if (auth.hasRoleOr(route.roles)) { + res.push(route) + } + } + }) + return res +} + +export const loadView = (view) => { + let res; + for (const path in modules) { + const dir = path.split('views/')[1].split('.vue')[0]; + if (dir === view) { + res = () => modules[path](); + } + } + return res +} + +export default usePermissionStore diff --git a/shierfeng-fastapi-frontend/src/store/modules/settings.js b/shierfeng-fastapi-frontend/src/store/modules/settings.js new file mode 100644 index 0000000..eba4df7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/modules/settings.js @@ -0,0 +1,51 @@ +import defaultSettings from '@/settings' +import { useDark, useToggle } from '@vueuse/core' +import { useDynamicTitle } from '@/utils/dynamicTitle' + +const isDark = useDark() +const toggleDark = useToggle(isDark) + +const { sideTheme, showSettings, navType, tagsView, tagsIcon, fixedHeader, sidebarLogo, dynamicTitle, footerVisible, footerContent } = defaultSettings + +const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || '' + +const useSettingsStore = defineStore( + 'settings', + { + state: () => ({ + title: '', + theme: storageSetting.theme || '#409EFF', + sideTheme: storageSetting.sideTheme || sideTheme, + showSettings: showSettings, + navType: storageSetting.navType === undefined ? navType : storageSetting.navType, + tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView, + tagsIcon: storageSetting.tagsIcon === undefined ? tagsIcon : storageSetting.tagsIcon, + fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader, + sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo, + dynamicTitle: storageSetting.dynamicTitle === undefined ? dynamicTitle : storageSetting.dynamicTitle, + footerVisible: storageSetting.footerVisible === undefined ? footerVisible : storageSetting.footerVisible, + footerContent: footerContent, + isDark: isDark.value + }), + actions: { + // 修改布局设置 + changeSetting(data) { + const { key, value } = data + if (this.hasOwnProperty(key)) { + this[key] = value + } + }, + // 设置网页标题 + setTitle(title) { + this.title = title + useDynamicTitle() + }, + // 切换暗黑模式 + toggleTheme() { + this.isDark = !this.isDark + toggleDark() + } + } + }) + +export default useSettingsStore diff --git a/shierfeng-fastapi-frontend/src/store/modules/tagsView.js b/shierfeng-fastapi-frontend/src/store/modules/tagsView.js new file mode 100644 index 0000000..9d07f33 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/modules/tagsView.js @@ -0,0 +1,182 @@ +const useTagsViewStore = defineStore( + 'tags-view', + { + state: () => ({ + visitedViews: [], + cachedViews: [], + iframeViews: [] + }), + actions: { + addView(view) { + this.addVisitedView(view) + this.addCachedView(view) + }, + addIframeView(view) { + if (this.iframeViews.some(v => v.path === view.path)) return + this.iframeViews.push( + Object.assign({}, view, { + title: view.meta.title || 'no-name' + }) + ) + }, + addVisitedView(view) { + if (this.visitedViews.some(v => v.path === view.path)) return + this.visitedViews.push( + Object.assign({}, view, { + title: view.meta.title || 'no-name' + }) + ) + }, + addCachedView(view) { + if (this.cachedViews.includes(view.name)) return + if (!view.meta.noCache) { + this.cachedViews.push(view.name) + } + }, + delView(view) { + return new Promise(resolve => { + this.delVisitedView(view) + this.delCachedView(view) + resolve({ + visitedViews: [...this.visitedViews], + cachedViews: [...this.cachedViews] + }) + }) + }, + delVisitedView(view) { + return new Promise(resolve => { + for (const [i, v] of this.visitedViews.entries()) { + if (v.path === view.path) { + this.visitedViews.splice(i, 1) + break + } + } + this.iframeViews = this.iframeViews.filter(item => item.path !== view.path) + resolve([...this.visitedViews]) + }) + }, + delIframeView(view) { + return new Promise(resolve => { + this.iframeViews = this.iframeViews.filter(item => item.path !== view.path) + resolve([...this.iframeViews]) + }) + }, + delCachedView(view) { + return new Promise(resolve => { + const index = this.cachedViews.indexOf(view.name) + index > -1 && this.cachedViews.splice(index, 1) + resolve([...this.cachedViews]) + }) + }, + delOthersViews(view) { + return new Promise(resolve => { + this.delOthersVisitedViews(view) + this.delOthersCachedViews(view) + resolve({ + visitedViews: [...this.visitedViews], + cachedViews: [...this.cachedViews] + }) + }) + }, + delOthersVisitedViews(view) { + return new Promise(resolve => { + this.visitedViews = this.visitedViews.filter(v => { + return v.meta.affix || v.path === view.path + }) + this.iframeViews = this.iframeViews.filter(item => item.path === view.path) + resolve([...this.visitedViews]) + }) + }, + delOthersCachedViews(view) { + return new Promise(resolve => { + const index = this.cachedViews.indexOf(view.name) + if (index > -1) { + this.cachedViews = this.cachedViews.slice(index, index + 1) + } else { + this.cachedViews = [] + } + resolve([...this.cachedViews]) + }) + }, + delAllViews(view) { + return new Promise(resolve => { + this.delAllVisitedViews(view) + this.delAllCachedViews(view) + resolve({ + visitedViews: [...this.visitedViews], + cachedViews: [...this.cachedViews] + }) + }) + }, + delAllVisitedViews(view) { + return new Promise(resolve => { + const affixTags = this.visitedViews.filter(tag => tag.meta.affix) + this.visitedViews = affixTags + this.iframeViews = [] + resolve([...this.visitedViews]) + }) + }, + delAllCachedViews(view) { + return new Promise(resolve => { + this.cachedViews = [] + resolve([...this.cachedViews]) + }) + }, + updateVisitedView(view) { + for (let v of this.visitedViews) { + if (v.path === view.path) { + v = Object.assign(v, view) + break + } + } + }, + delRightTags(view) { + return new Promise(resolve => { + const index = this.visitedViews.findIndex(v => v.path === view.path) + if (index === -1) { + return + } + this.visitedViews = this.visitedViews.filter((item, idx) => { + if (idx <= index || (item.meta && item.meta.affix)) { + return true + } + const i = this.cachedViews.indexOf(item.name) + if (i > -1) { + this.cachedViews.splice(i, 1) + } + if(item.meta.link) { + const fi = this.iframeViews.findIndex(v => v.path === item.path) + this.iframeViews.splice(fi, 1) + } + return false + }) + resolve([...this.visitedViews]) + }) + }, + delLeftTags(view) { + return new Promise(resolve => { + const index = this.visitedViews.findIndex(v => v.path === view.path) + if (index === -1) { + return + } + this.visitedViews = this.visitedViews.filter((item, idx) => { + if (idx >= index || (item.meta && item.meta.affix)) { + return true + } + const i = this.cachedViews.indexOf(item.name) + if (i > -1) { + this.cachedViews.splice(i, 1) + } + if(item.meta.link) { + const fi = this.iframeViews.findIndex(v => v.path === item.path) + this.iframeViews.splice(fi, 1) + } + return false + }) + resolve([...this.visitedViews]) + }) + } + } + }) + +export default useTagsViewStore diff --git a/shierfeng-fastapi-frontend/src/store/modules/user.js b/shierfeng-fastapi-frontend/src/store/modules/user.js new file mode 100644 index 0000000..bba5dd6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/store/modules/user.js @@ -0,0 +1,91 @@ +import router from '@/router' +import { ElMessageBox } from 'element-plus' +import { login, logout, getInfo } from '@/api/login' +import { getToken, setToken, removeToken } from '@/utils/auth' +import { isHttp, isEmpty } from "@/utils/validate" +import defAva from '@/assets/images/profile.jpg' + +const useUserStore = defineStore( + 'user', + { + state: () => ({ + token: getToken(), + id: '', + name: '', + nickName: '', + avatar: '', + roles: [], + permissions: [] + }), + actions: { + // 登录 + login(userInfo) { + const username = userInfo.username.trim() + const password = userInfo.password + const code = userInfo.code + const uuid = userInfo.uuid + return new Promise((resolve, reject) => { + login(username, password, code, uuid).then(res => { + setToken(res.token) + this.token = res.token + resolve() + }).catch(error => { + reject(error) + }) + }) + }, + // 获取用户信息 + getInfo() { + return new Promise((resolve, reject) => { + getInfo().then(res => { + const user = res.user + let avatar = user.avatar || "" + if (!isHttp(avatar)) { + avatar = (isEmpty(avatar)) ? defAva : import.meta.env.VITE_APP_BASE_API + avatar + } + if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组 + this.roles = res.roles + this.permissions = res.permissions + } else { + this.roles = ['ROLE_DEFAULT'] + } + this.id = user.userId + this.name = user.userName + this.nickName = user.nickName + this.avatar = avatar + /* 初始密码提示 */ + if(res.isDefaultModifyPwd) { + ElMessageBox.confirm('您的密码还是初始密码,请修改密码!', '安全提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { + router.push({ name: 'Profile', params: { activeTab: 'resetPwd' } }) + }).catch(() => {}) + } + /* 过期密码提示 */ + if(!res.isDefaultModifyPwd && res.isPasswordExpired) { + ElMessageBox.confirm('您的密码已过期,请尽快修改密码!', '安全提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { + router.push({ name: 'Profile', params: { activeTab: 'resetPwd' } }) + }).catch(() => {}) + } + resolve(res) + }).catch(error => { + reject(error) + }) + }) + }, + // 退出系统 + logOut() { + return new Promise((resolve, reject) => { + logout(this.token).then(() => { + this.token = '' + this.roles = [] + this.permissions = [] + removeToken() + resolve() + }).catch(error => { + reject(error) + }) + }) + } + } + }) + +export default useUserStore diff --git a/shierfeng-fastapi-frontend/src/utils/auth.js b/shierfeng-fastapi-frontend/src/utils/auth.js new file mode 100644 index 0000000..08a43d6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/auth.js @@ -0,0 +1,15 @@ +import Cookies from 'js-cookie' + +const TokenKey = 'Admin-Token' + +export function getToken() { + return Cookies.get(TokenKey) +} + +export function setToken(token) { + return Cookies.set(TokenKey, token) +} + +export function removeToken() { + return Cookies.remove(TokenKey) +} diff --git a/shierfeng-fastapi-frontend/src/utils/dict.js b/shierfeng-fastapi-frontend/src/utils/dict.js new file mode 100644 index 0000000..9648f14 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/dict.js @@ -0,0 +1,24 @@ +import useDictStore from '@/store/modules/dict' +import { getDicts } from '@/api/system/dict/data' + +/** + * 获取字典数据 + */ +export function useDict(...args) { + const res = ref({}); + return (() => { + args.forEach((dictType, index) => { + res.value[dictType] = []; + const dicts = useDictStore().getDict(dictType); + if (dicts) { + res.value[dictType] = dicts; + } else { + getDicts(dictType).then(resp => { + res.value[dictType] = resp.data.map(p => ({ label: p.dictLabel, value: p.dictValue, elTagType: p.listClass, elTagClass: p.cssClass })) + useDictStore().setDict(dictType, res.value[dictType]); + }) + } + }) + return toRefs(res.value); + })() +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/utils/dynamicTitle.js b/shierfeng-fastapi-frontend/src/utils/dynamicTitle.js new file mode 100644 index 0000000..7e6ab55 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/dynamicTitle.js @@ -0,0 +1,14 @@ +import defaultSettings from '@/settings' +import useSettingsStore from '@/store/modules/settings' + +/** + * 动态修改标题 + */ +export function useDynamicTitle() { + const settingsStore = useSettingsStore(); + if (settingsStore.dynamicTitle) { + document.title = settingsStore.title + ' - ' + defaultSettings.title; + } else { + document.title = defaultSettings.title; + } +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/utils/errorCode.js b/shierfeng-fastapi-frontend/src/utils/errorCode.js new file mode 100644 index 0000000..d2111ee --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/errorCode.js @@ -0,0 +1,6 @@ +export default { + '401': '认证失败,无法访问系统资源', + '403': '当前操作没有权限', + '404': '访问资源不存在', + 'default': '系统未知错误,请反馈给管理员' +} diff --git a/shierfeng-fastapi-frontend/src/utils/generator/config.js b/shierfeng-fastapi-frontend/src/utils/generator/config.js new file mode 100755 index 0000000..449715f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/config.js @@ -0,0 +1,452 @@ +export const formConf = { + formRef: 'formRef', + formModel: 'formData', + size: 'default', + labelPosition: 'right', + labelWidth: 100, + formRules: 'rules', + gutter: 15, + disabled: false, + span: 24, + formBtns: true, +} + +export const inputComponents = [ + { + label: '单行文本', + tag: 'el-input', + tagIcon: 'input', + type: 'text', + placeholder: '请输入', + defaultValue: undefined, + span: 24, + labelWidth: null, + style: { width: '100%' }, + clearable: true, + prepend: '', + append: '', + 'prefix-icon': '', + 'suffix-icon': '', + maxlength: null, + 'show-word-limit': false, + readonly: false, + disabled: false, + required: true, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/input', + }, + { + label: '多行文本', + tag: 'el-input', + tagIcon: 'textarea', + type: 'textarea', + placeholder: '请输入', + defaultValue: undefined, + span: 24, + labelWidth: null, + autosize: { + minRows: 4, + maxRows: 4, + }, + style: { width: '100%' }, + maxlength: null, + 'show-word-limit': false, + readonly: false, + disabled: false, + required: true, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/input', + }, + { + label: '密码', + tag: 'el-input', + tagIcon: 'password', + type: 'password', + placeholder: '请输入', + defaultValue: undefined, + span: 24, + 'show-password': true, + labelWidth: null, + style: { width: '100%' }, + clearable: true, + prepend: '', + append: '', + 'prefix-icon': '', + 'suffix-icon': '', + maxlength: null, + 'show-word-limit': false, + readonly: false, + disabled: false, + required: true, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/input', + }, + { + label: '计数器', + tag: 'el-input-number', + tagIcon: 'number', + placeholder: '', + defaultValue: undefined, + span: 24, + labelWidth: null, + min: undefined, + max: undefined, + step: undefined, + 'step-strictly': false, + precision: undefined, + 'controls-position': '', + disabled: false, + required: true, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/input-number', + }, +] + +export const selectComponents = [ + { + label: '下拉选择', + tag: 'el-select', + tagIcon: 'select', + placeholder: '请选择', + defaultValue: undefined, + span: 24, + labelWidth: null, + style: { width: '100%' }, + clearable: true, + disabled: false, + required: true, + filterable: false, + multiple: false, + options: [ + { + label: '选项一', + value: 1, + }, + { + label: '选项二', + value: 2, + }, + ], + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/select', + }, + { + label: '级联选择', + tag: 'el-cascader', + tagIcon: 'cascader', + placeholder: '请选择', + defaultValue: [], + span: 24, + labelWidth: null, + style: { width: '100%' }, + props: { + props: { + multiple: false, + }, + }, + 'show-all-levels': true, + disabled: false, + clearable: true, + filterable: false, + required: true, + options: [ + { + id: 1, + value: 1, + label: '选项1', + children: [ + { + id: 2, + value: 2, + label: '选项1-1', + }, + ], + }, + ], + dataType: 'dynamic', + labelKey: 'label', + valueKey: 'value', + childrenKey: 'children', + separator: '/', + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/cascader', + }, + { + label: '单选框组', + tag: 'el-radio-group', + tagIcon: 'radio', + defaultValue: 0, + span: 24, + labelWidth: null, + style: {}, + optionType: 'default', + border: false, + size: 'default', + disabled: false, + required: true, + options: [ + { + label: '选项一', + value: 1, + }, + { + label: '选项二', + value: 2, + }, + ], + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/radio', + }, + { + label: '多选框组', + tag: 'el-checkbox-group', + tagIcon: 'checkbox', + defaultValue: [], + span: 24, + labelWidth: null, + style: {}, + optionType: 'default', + border: false, + size: 'default', + disabled: false, + required: true, + options: [ + { + label: '选项一', + value: 1, + }, + { + label: '选项二', + value: 2, + }, + ], + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/checkbox', + }, + { + label: '开关', + tag: 'el-switch', + tagIcon: 'switch', + defaultValue: false, + span: 24, + labelWidth: null, + style: {}, + disabled: false, + required: true, + 'active-text': '', + 'inactive-text': '', + 'active-color': null, + 'inactive-color': null, + 'active-value': true, + 'inactive-value': false, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/switch', + }, + { + label: '滑块', + tag: 'el-slider', + tagIcon: 'slider', + defaultValue: null, + span: 24, + labelWidth: null, + disabled: false, + required: true, + min: 0, + max: 100, + step: 1, + 'show-stops': false, + range: false, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/slider', + }, + { + label: '时间选择', + tag: 'el-time-picker', + tagIcon: 'time', + placeholder: '请选择', + defaultValue: '', + span: 24, + labelWidth: null, + style: { width: '100%' }, + disabled: false, + clearable: true, + required: true, + format: 'HH:mm:ss', + 'value-format': 'HH:mm:ss', + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/time-picker', + }, + { + label: '时间范围', + tag: 'el-time-picker', + tagIcon: 'time-range', + defaultValue: null, + span: 24, + labelWidth: null, + style: { width: '100%' }, + disabled: false, + clearable: true, + required: true, + 'is-range': true, + 'range-separator': '至', + 'start-placeholder': '开始时间', + 'end-placeholder': '结束时间', + format: 'HH:mm:ss', + 'value-format': 'HH:mm:ss', + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/time-picker', + }, + { + label: '日期选择', + tag: 'el-date-picker', + tagIcon: 'date', + placeholder: '请选择', + defaultValue: null, + type: 'date', + span: 24, + labelWidth: null, + style: { width: '100%' }, + disabled: false, + clearable: true, + required: true, + format: 'YYYY-MM-DD', + 'value-format': 'YYYY-MM-DD', + readonly: false, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/date-picker', + }, + { + label: '日期范围', + tag: 'el-date-picker', + tagIcon: 'date-range', + defaultValue: null, + span: 24, + labelWidth: null, + style: { width: '100%' }, + type: 'daterange', + 'range-separator': '至', + 'start-placeholder': '开始日期', + 'end-placeholder': '结束日期', + disabled: false, + clearable: true, + required: true, + format: 'YYYY-MM-DD', + 'value-format': 'YYYY-MM-DD', + readonly: false, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/date-picker', + }, + { + label: '评分', + tag: 'el-rate', + tagIcon: 'rate', + defaultValue: 0, + span: 24, + labelWidth: null, + style: {}, + max: 5, + 'allow-half': false, + 'show-text': false, + 'show-score': false, + disabled: false, + required: true, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/rate', + }, + { + label: '颜色选择', + tag: 'el-color-picker', + tagIcon: 'color', + defaultValue: null, + labelWidth: null, + 'show-alpha': false, + 'color-format': '', + disabled: false, + required: true, + size: 'default', + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/color-picker', + }, + { + label: '上传', + tag: 'el-upload', + tagIcon: 'upload', + action: 'https://jsonplaceholder.typicode.com/posts/', + defaultValue: null, + labelWidth: null, + disabled: false, + required: true, + accept: '', + name: 'file', + 'auto-upload': true, + showTip: false, + buttonText: '点击上传', + fileSize: 2, + sizeUnit: 'MB', + 'list-type': 'text', + multiple: false, + regList: [], + changeTag: true, + document: 'https://element-plus.org/zh-CN/component/upload', + tip: '只能上传不超过 2MB 的文件', + style: { width: '100%' }, + }, +] + +export const layoutComponents = [ + { + layout: 'rowFormItem', + tagIcon: 'row', + type: 'default', + justify: 'start', + align: 'top', + label: '行容器', + layoutTree: true, + children: [], + document: 'https://element-plus.org/zh-CN/component/layout', + }, + { + layout: 'colFormItem', + label: '按钮', + changeTag: true, + labelWidth: null, + tag: 'el-button', + tagIcon: 'button', + span: 24, + default: '主要按钮', + type: 'primary', + icon: 'Search', + size: 'default', + disabled: false, + document: 'https://element-plus.org/zh-CN/component/button', + }, +] + +// 组件rule的触发方式,无触发方式的组件不生成rule +export const trigger = { + 'el-input': 'blur', + 'el-input-number': 'blur', + 'el-select': 'change', + 'el-radio-group': 'change', + 'el-checkbox-group': 'change', + 'el-cascader': 'change', + 'el-time-picker': 'change', + 'el-date-picker': 'change', + 'el-rate': 'change', +} diff --git a/shierfeng-fastapi-frontend/src/utils/generator/css.js b/shierfeng-fastapi-frontend/src/utils/generator/css.js new file mode 100755 index 0000000..c1c62e6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/css.js @@ -0,0 +1,18 @@ +const styles = { + 'el-rate': '.el-rate{display: inline-block; vertical-align: text-top;}', + 'el-upload': '.el-upload__tip{line-height: 1.2;}' +} + +function addCss(cssList, el) { + const css = styles[el.tag] + css && cssList.indexOf(css) === -1 && cssList.push(css) + if (el.children) { + el.children.forEach(el2 => addCss(cssList, el2)) + } +} + +export function makeUpCss(conf) { + const cssList = [] + conf.fields.forEach(el => addCss(cssList, el)) + return cssList.join('\n') +} diff --git a/shierfeng-fastapi-frontend/src/utils/generator/drawingDefault.js b/shierfeng-fastapi-frontend/src/utils/generator/drawingDefault.js new file mode 100755 index 0000000..7d5a29d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/drawingDefault.js @@ -0,0 +1,37 @@ +export const drawingDefaultValue = [] + +export function initDrawingDefaultValue() { + if (drawingDefaultValue.length === 0) { + drawingDefaultValue.push({ + layout: 'colFormItem', + tagIcon: 'input', + label: '手机号', + vModel: 'mobile', + formId: 6, + tag: 'el-input', + placeholder: '请输入手机号', + defaultValue: '', + span: 24, + style: {width: '100%'}, + clearable: true, + prepend: '', + append: '', + 'prefix-icon': 'Cellphone', + 'suffix-icon': '', + maxlength: 11, + 'show-word-limit': true, + readonly: false, + disabled: false, + required: true, + changeTag: true, + regList: [{ + pattern: '/^1(3|4|5|7|8|9)\\d{9}$/', + message: '手机号格式错误' + }] + }) + } +} + +export function cleanDrawingDefaultValue() { + drawingDefaultValue.splice(0, drawingDefaultValue.length) +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/utils/generator/html.js b/shierfeng-fastapi-frontend/src/utils/generator/html.js new file mode 100755 index 0000000..4b29841 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/html.js @@ -0,0 +1,359 @@ +/* eslint-disable max-len */ +import { trigger } from './config' + +let confGlobal +let someSpanIsNot24 + +export function dialogWrapper(str) { + return ` + ${str} + + ` +} + +export function vueTemplate(str) { + return `` +} + +export function vueScript(str) { + return `` +} + +export function cssStyle(cssStr) { + return `` +} + +function buildFormTemplate(conf, child, type) { + let labelPosition = '' + if (conf.labelPosition !== 'right') { + labelPosition = `label-position="${conf.labelPosition}"` + } + const disabled = conf.disabled ? `:disabled="${conf.disabled}"` : '' + let str = ` + ${child} + ${buildFromBtns(conf, type)} + ` + if (someSpanIsNot24) { + str = ` + ${str} + ` + } + return str +} + +function buildFromBtns(conf, type) { + let str = '' + if (conf.formBtns && type === 'file') { + str = ` + 提交 + 重置 + ` + if (someSpanIsNot24) { + str = ` + ${str} + ` + } + } + return str +} + +// span不为24的用el-col包裹 +function colWrapper(element, str) { + if (someSpanIsNot24 || element.span !== 24) { + return ` + ${str} + ` + } + return str +} + +const layouts = { + colFormItem(element) { + let labelWidth = '' + if (element.labelWidth && element.labelWidth !== confGlobal.labelWidth) { + labelWidth = `label-width="${element.labelWidth}px"` + } + const required = !trigger[element.tag] && element.required ? 'required' : '' + const tagDom = tags[element.tag] ? tags[element.tag](element) : null + let str = ` + ${tagDom} + ` + str = colWrapper(element, str) + return str + }, + rowFormItem(element) { + const type = element.type === 'default' ? '' : `type="${element.type}"` + const justify = element.type === 'default' ? '' : `justify="${element.justify}"` + const align = element.type === 'default' ? '' : `align="${element.align}"` + const gutter = element.gutter ? `gutter="${element.gutter}"` : '' + const children = element.children.map(el => layouts[el.layout](el)) + let str = ` + ${children.join('\n')} + ` + str = colWrapper(element, str) + return str + } +} + +const tags = { + 'el-button': el => { + const { + tag, disabled + } = attrBuilder(el) + const type = el.type ? `type="${el.type}"` : '' + const icon = el.icon ? `icon="${el.icon}"` : '' + const size = el.size ? `size="${el.size}"` : '' + let child = buildElButtonChild(el) + + if (child) child = `\n${child}\n` // 换行 + return `<${el.tag} ${type} ${icon} ${size} ${disabled}>${child}` + }, + 'el-input': el => { + const { + disabled, vModel, clearable, placeholder, width + } = attrBuilder(el) + const maxlength = el.maxlength ? `:maxlength="${el.maxlength}"` : '' + const showWordLimit = el['show-word-limit'] ? 'show-word-limit' : '' + const readonly = el.readonly ? 'readonly' : '' + const prefixIcon = el['prefix-icon'] ? `prefix-icon='${el['prefix-icon']}'` : '' + const suffixIcon = el['suffix-icon'] ? `suffix-icon='${el['suffix-icon']}'` : '' + const showPassword = el['show-password'] ? 'show-password' : '' + const type = el.type ? `type="${el.type}"` : '' + const autosize = el.autosize && el.autosize.minRows + ? `:autosize="{minRows: ${el.autosize.minRows}, maxRows: ${el.autosize.maxRows}}"` + : '' + let child = buildElInputChild(el) + + if (child) child = `\n${child}\n` // 换行 + return `<${el.tag} ${vModel} ${type} ${placeholder} ${maxlength} ${showWordLimit} ${readonly} ${disabled} ${clearable} ${prefixIcon} ${suffixIcon} ${showPassword} ${autosize} ${width}>${child}` + }, + 'el-input-number': el => { + const { disabled, vModel, placeholder } = attrBuilder(el) + const controlsPosition = el['controls-position'] ? `controls-position=${el['controls-position']}` : '' + const min = el.min ? `:min='${el.min}'` : '' + const max = el.max ? `:max='${el.max}'` : '' + const step = el.step ? `:step='${el.step}'` : '' + const stepStrictly = el['step-strictly'] ? 'step-strictly' : '' + const precision = el.precision ? `:precision='${el.precision}'` : '' + + return `<${el.tag} ${vModel} ${placeholder} ${step} ${stepStrictly} ${precision} ${controlsPosition} ${min} ${max} ${disabled}>` + }, + 'el-select': el => { + const { + disabled, vModel, clearable, placeholder, width + } = attrBuilder(el) + const filterable = el.filterable ? 'filterable' : '' + const multiple = el.multiple ? 'multiple' : '' + let child = buildElSelectChild(el) + + if (child) child = `\n${child}\n` // 换行 + return `<${el.tag} ${vModel} ${placeholder} ${disabled} ${multiple} ${filterable} ${clearable} ${width}>${child}` + }, + 'el-radio-group': el => { + const { disabled, vModel } = attrBuilder(el) + const size = `size="${el.size}"` + let child = buildElRadioGroupChild(el) + + if (child) child = `\n${child}\n` // 换行 + return `<${el.tag} ${vModel} ${size} ${disabled}>${child}` + }, + 'el-checkbox-group': el => { + const { disabled, vModel } = attrBuilder(el) + const size = `size="${el.size}"` + const min = el.min ? `:min="${el.min}"` : '' + const max = el.max ? `:max="${el.max}"` : '' + let child = buildElCheckboxGroupChild(el) + + if (child) child = `\n${child}\n` // 换行 + return `<${el.tag} ${vModel} ${min} ${max} ${size} ${disabled}>${child}` + }, + 'el-switch': el => { + const { disabled, vModel } = attrBuilder(el) + const activeText = el['active-text'] ? `active-text="${el['active-text']}"` : '' + const inactiveText = el['inactive-text'] ? `inactive-text="${el['inactive-text']}"` : '' + const activeColor = el['active-color'] ? `active-color="${el['active-color']}"` : '' + const inactiveColor = el['inactive-color'] ? `inactive-color="${el['inactive-color']}"` : '' + const activeValue = el['active-value'] !== true ? `:active-value='${JSON.stringify(el['active-value'])}'` : '' + const inactiveValue = el['inactive-value'] !== false ? `:inactive-value='${JSON.stringify(el['inactive-value'])}'` : '' + + return `<${el.tag} ${vModel} ${activeText} ${inactiveText} ${activeColor} ${inactiveColor} ${activeValue} ${inactiveValue} ${disabled}>` + }, + 'el-cascader': el => { + const { + disabled, vModel, clearable, placeholder, width + } = attrBuilder(el) + const options = el.options ? `:options="${el.vModel}Options"` : '' + const props = el.props ? `:props="${el.vModel}Props"` : '' + const showAllLevels = el['show-all-levels'] ? '' : ':show-all-levels="false"' + const filterable = el.filterable ? 'filterable' : '' + const separator = el.separator === '/' ? '' : `separator="${el.separator}"` + + return `<${el.tag} ${vModel} ${options} ${props} ${width} ${showAllLevels} ${placeholder} ${separator} ${filterable} ${clearable} ${disabled}>` + }, + 'el-slider': el => { + const { disabled, vModel } = attrBuilder(el) + const min = el.min ? `:min='${el.min}'` : '' + const max = el.max ? `:max='${el.max}'` : '' + const step = el.step ? `:step='${el.step}'` : '' + const range = el.range ? 'range' : '' + const showStops = el['show-stops'] ? `:show-stops="${el['show-stops']}"` : '' + + return `<${el.tag} ${min} ${max} ${step} ${vModel} ${range} ${showStops} ${disabled}>` + }, + 'el-time-picker': el => { + const { + disabled, vModel, clearable, placeholder, width + } = attrBuilder(el) + const startPlaceholder = el['start-placeholder'] ? `start-placeholder="${el['start-placeholder']}"` : '' + const endPlaceholder = el['end-placeholder'] ? `end-placeholder="${el['end-placeholder']}"` : '' + const rangeSeparator = el['range-separator'] ? `range-separator="${el['range-separator']}"` : '' + const isRange = el['is-range'] ? 'is-range' : '' + const format = el.format ? `format="${el.format}"` : '' + const valueFormat = el['value-format'] ? `value-format="${el['value-format']}"` : '' + const pickerOptions = el['picker-options'] ? `:picker-options='${JSON.stringify(el['picker-options'])}'` : '' + + return `<${el.tag} ${vModel} ${isRange} ${format} ${valueFormat} ${pickerOptions} ${width} ${placeholder} ${startPlaceholder} ${endPlaceholder} ${rangeSeparator} ${clearable} ${disabled}>` + }, + 'el-date-picker': el => { + const { + disabled, vModel, clearable, placeholder, width + } = attrBuilder(el) + const startPlaceholder = el['start-placeholder'] ? `start-placeholder="${el['start-placeholder']}"` : '' + const endPlaceholder = el['end-placeholder'] ? `end-placeholder="${el['end-placeholder']}"` : '' + const rangeSeparator = el['range-separator'] ? `range-separator="${el['range-separator']}"` : '' + const format = el.format ? `format="${el.format}"` : '' + const valueFormat = el['value-format'] ? `value-format="${el['value-format']}"` : '' + const type = el.type === 'date' ? '' : `type="${el.type}"` + const readonly = el.readonly ? 'readonly' : '' + + return `<${el.tag} ${type} ${vModel} ${format} ${valueFormat} ${width} ${placeholder} ${startPlaceholder} ${endPlaceholder} ${rangeSeparator} ${clearable} ${readonly} ${disabled}>` + }, + 'el-rate': el => { + const { disabled, vModel } = attrBuilder(el) + const max = el.max ? `:max='${el.max}'` : '' + const allowHalf = el['allow-half'] ? 'allow-half' : '' + const showText = el['show-text'] ? 'show-text' : '' + const showScore = el['show-score'] ? 'show-score' : '' + + return `<${el.tag} ${vModel} ${allowHalf} ${showText} ${showScore} ${disabled}>` + }, + 'el-color-picker': el => { + const { disabled, vModel } = attrBuilder(el) + const size = `size="${el.size}"` + const showAlpha = el['show-alpha'] ? 'show-alpha' : '' + const colorFormat = el['color-format'] ? `color-format="${el['color-format']}"` : '' + + return `<${el.tag} ${vModel} ${size} ${showAlpha} ${colorFormat} ${disabled}>` + }, + 'el-upload': el => { + const disabled = el.disabled ? ':disabled=\'true\'' : '' + const action = el.action ? `:action="${el.vModel}Action"` : '' + const multiple = el.multiple ? 'multiple' : '' + const listType = el['list-type'] !== 'text' ? `list-type="${el['list-type']}"` : '' + const accept = el.accept ? `accept="${el.accept}"` : '' + const name = el.name !== 'file' ? `name="${el.name}"` : '' + const autoUpload = el['auto-upload'] === false ? ':auto-upload="false"' : '' + const beforeUpload = `:before-upload="${el.vModel}BeforeUpload"` + const fileList = `:file-list="${el.vModel}fileList"` + const ref = `ref="${el.vModel}"` + let child = buildElUploadChild(el) + + if (child) child = `\n${child}\n` // 换行 + return `<${el.tag} ${ref} ${fileList} ${action} ${autoUpload} ${multiple} ${beforeUpload} ${listType} ${accept} ${name} ${disabled}>${child}` + } +} + +function attrBuilder(el) { + return { + vModel: `v-model="${confGlobal.formModel}.${el.vModel}"`, + clearable: el.clearable ? 'clearable' : '', + placeholder: el.placeholder ? `placeholder="${el.placeholder}"` : '', + width: el.style && el.style.width ? ':style="{width: \'100%\'}"' : '', + disabled: el.disabled ? ':disabled=\'true\'' : '' + } +} + +// el-buttin 子级 +function buildElButtonChild(conf) { + const children = [] + if (conf.default) { + children.push(conf.default) + } + return children.join('\n') +} + +// el-input innerHTML +function buildElInputChild(conf) { + const children = [] + if (conf.prepend) { + children.push(``) + } + if (conf.append) { + children.push(``) + } + return children.join('\n') +} + +function buildElSelectChild(conf) { + const children = [] + if (conf.options && conf.options.length) { + children.push(``) + } + return children.join('\n') +} + +function buildElRadioGroupChild(conf) { + const children = [] + if (conf.options && conf.options.length) { + const tag = conf.optionType === 'button' ? 'el-radio-button' : 'el-radio' + const border = conf.border ? 'border' : '' + children.push(`<${tag} v-for="(item, index) in ${conf.vModel}Options" :key="index" :value="item.value" :disabled="item.disabled" ${border}>{{item.label}}`) + } + return children.join('\n') +} + +function buildElCheckboxGroupChild(conf) { + const children = [] + if (conf.options && conf.options.length) { + const tag = conf.optionType === 'button' ? 'el-checkbox-button' : 'el-checkbox' + const border = conf.border ? 'border' : '' + children.push(`<${tag} v-for="(item, index) in ${conf.vModel}Options" :key="index" :label="item.value" :value="item.label" :disabled="item.disabled" ${border} />`) + } + return children.join('\n') +} + +function buildElUploadChild(conf) { + const list = [] + if (conf['list-type'] === 'picture-card') list.push('') + else list.push(`${conf.buttonText}`) + if (conf.showTip) list.push(`

`) + return list.join('\n') +} + +export function makeUpHtml(conf, type) { + const htmlList = [] + confGlobal = conf + someSpanIsNot24 = conf.fields.some(item => item.span !== 24) + conf.fields.forEach(el => { + htmlList.push(layouts[el.layout](el)) + }) + const htmlStr = htmlList.join('\n') + + let temp = buildFormTemplate(conf, htmlStr, type) + if (type === 'dialog') { + temp = dialogWrapper(temp) + } + confGlobal = null + return temp +} diff --git a/shierfeng-fastapi-frontend/src/utils/generator/icon.json b/shierfeng-fastapi-frontend/src/utils/generator/icon.json new file mode 100755 index 0000000..2d9999a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/icon.json @@ -0,0 +1 @@ +["platform-eleme","eleme","delete-solid","delete","s-tools","setting","user-solid","user","phone","phone-outline","more","more-outline","star-on","star-off","s-goods","goods","warning","warning-outline","question","info","remove","circle-plus","success","error","zoom-in","zoom-out","remove-outline","circle-plus-outline","circle-check","circle-close","s-help","help","minus","plus","check","close","picture","picture-outline","picture-outline-round","upload","upload2","download","camera-solid","camera","video-camera-solid","video-camera","message-solid","bell","s-cooperation","s-order","s-platform","s-fold","s-unfold","s-operation","s-promotion","s-home","s-release","s-ticket","s-management","s-open","s-shop","s-marketing","s-flag","s-comment","s-finance","s-claim","s-custom","s-opportunity","s-data","s-check","s-grid","menu","share","d-caret","caret-left","caret-right","caret-bottom","caret-top","bottom-left","bottom-right","back","right","bottom","top","top-left","top-right","arrow-left","arrow-right","arrow-down","arrow-up","d-arrow-left","d-arrow-right","video-pause","video-play","refresh","refresh-right","refresh-left","finished","sort","sort-up","sort-down","rank","loading","view","c-scale-to-original","date","edit","edit-outline","folder","folder-opened","folder-add","folder-remove","folder-delete","folder-checked","tickets","document-remove","document-delete","document-copy","document-checked","document","document-add","printer","paperclip","takeaway-box","search","monitor","attract","mobile","scissors","umbrella","headset","brush","mouse","coordinate","magic-stick","reading","data-line","data-board","pie-chart","data-analysis","collection-tag","film","suitcase","suitcase-1","receiving","collection","files","notebook-1","notebook-2","toilet-paper","office-building","school","table-lamp","house","no-smoking","smoking","shopping-cart-full","shopping-cart-1","shopping-cart-2","shopping-bag-1","shopping-bag-2","sold-out","sell","present","box","bank-card","money","coin","wallet","discount","price-tag","news","guide","male","female","thumb","cpu","link","connection","open","turn-off","set-up","chat-round","chat-line-round","chat-square","chat-dot-round","chat-dot-square","chat-line-square","message","postcard","position","turn-off-microphone","microphone","close-notification","bangzhu","time","odometer","crop","aim","switch-button","full-screen","copy-document","mic","stopwatch","medal-1","medal","trophy","trophy-1","first-aid-kit","discover","place","location","location-outline","location-information","add-location","delete-location","map-location","alarm-clock","timer","watch-1","watch","lock","unlock","key","service","mobile-phone","bicycle","truck","ship","basketball","football","soccer","baseball","wind-power","light-rain","lightning","heavy-rain","sunrise","sunrise-1","sunset","sunny","cloudy","partly-cloudy","cloudy-and-sunny","moon","moon-night","dish","dish-1","food","chicken","fork-spoon","knife-fork","burger","tableware","sugar","dessert","ice-cream","hot-water","water-cup","coffee-cup","cold-drink","goblet","goblet-full","goblet-square","goblet-square-full","refrigerator","grape","watermelon","cherry","apple","pear","orange","coffee","ice-tea","ice-drink","milk-tea","potato-strips","lollipop","ice-cream-square","ice-cream-round"] \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/utils/generator/js.js b/shierfeng-fastapi-frontend/src/utils/generator/js.js new file mode 100755 index 0000000..dc38bfe --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/js.js @@ -0,0 +1,370 @@ +import { titleCase } from '@/utils/index' +import { trigger } from './config' +// 文件大小设置 +const units = { + KB: '1024', + MB: '1024 / 1024', + GB: '1024 / 1024 / 1024', +} +/** + * @name: 生成js需要的数据 + * @description: 生成js需要的数据 + * @param {*} conf + * @param {*} type 弹窗或表单 + * @return {*} + */ +export function makeUpJs(conf, type) { + conf = JSON.parse(JSON.stringify(conf)) + const dataList = [] + const ruleList = [] + const optionsList = [] + const propsList = [] + const methodList = [] + const uploadVarList = [] + + conf.fields.forEach((el) => { + buildAttributes( + el, + dataList, + ruleList, + optionsList, + methodList, + propsList, + uploadVarList + ) + }) + + const script = buildexport( + conf, + type, + dataList.join('\n'), + ruleList.join('\n'), + optionsList.join('\n'), + uploadVarList.join('\n'), + propsList.join('\n'), + methodList.join('\n') + ) + + return script +} +/** + * @name: 生成参数 + * @description: 生成参数,包括表单数据表单验证数据,多选选项数据,上传数据等 + * @return {*} + */ +function buildAttributes( + el, + dataList, + ruleList, + optionsList, + methodList, + propsList, + uploadVarList +){ + buildData(el, dataList) + buildRules(el, ruleList) + + if (el.options && el.options.length) { + buildOptions(el, optionsList) + if (el.dataType === 'dynamic') { + const model = `${el.vModel}Options` + const options = titleCase(model) + buildOptionMethod(`get${options}`, model, methodList) + } + } + + if (el.props && el.props.props) { + buildProps(el, propsList) + } + + if (el.action && el.tag === 'el-upload') { + uploadVarList.push( + ` + // 上传请求路径 + const ${el.vModel}Action = ref('${el.action}') + // 上传文件列表 + const ${el.vModel}fileList = ref([])` + ) + methodList.push(buildBeforeUpload(el)) + if (!el['auto-upload']) { + methodList.push(buildSubmitUpload(el)) + } + } + + if (el.children) { + el.children.forEach((el2) => { + buildAttributes( + el2, + dataList, + ruleList, + optionsList, + methodList, + propsList, + uploadVarList + ) + }) + } +} +/** + * @name: 生成表单数据formData + * @description: 生成表单数据formData + * @param {*} conf + * @param {*} dataList 数据列表 + * @return {*} + */ +function buildData(conf, dataList) { + if (conf.vModel === undefined) return + let defaultValue + if (typeof conf.defaultValue === 'string' && !conf.multiple) { + defaultValue = `'${conf.defaultValue}'` + } else { + defaultValue = `${JSON.stringify(conf.defaultValue)}` + } + dataList.push(`${conf.vModel}: ${defaultValue},`) +} +/** + * @name: 生成表单验证数据rule + * @description: 生成表单验证数据rule + * @param {*} conf + * @param {*} ruleList 验证数据列表 + * @return {*} + */ +function buildRules(conf, ruleList) { + if (conf.vModel === undefined) return + const rules = [] + if (trigger[conf.tag]) { + if (conf.required) { + const type = Array.isArray(conf.defaultValue) ? "type: 'array'," : '' + let message = Array.isArray(conf.defaultValue) + ? `请至少选择一个${conf.vModel}` + : conf.placeholder + if (message === undefined) message = `${conf.label}不能为空` + rules.push( + `{ required: true, ${type} message: '${message}', trigger: '${ + trigger[conf.tag] + }' }` + ) + } + if (conf.regList && Array.isArray(conf.regList)) { + conf.regList.forEach((item) => { + if (item.pattern) { + rules.push( + `{ pattern: new RegExp(${item.pattern}), message: '${ + item.message + }', trigger: '${trigger[conf.tag]}' }` + ) + } + }) + } + ruleList.push(`${conf.vModel}: [${rules.join(',')}],`) + } +} +/** + * @name: 生成选项数据 + * @description: 生成选项数据,单选多选下拉等 + * @param {*} conf + * @param {*} optionsList 选项数据列表 + * @return {*} + */ +function buildOptions(conf, optionsList) { + if (conf.vModel === undefined) return + if (conf.dataType === 'dynamic') { + conf.options = [] + } + const str = `const ${conf.vModel}Options = ref(${JSON.stringify(conf.options)})` + optionsList.push(str) +} +/** + * @name: 生成方法 + * @description: 生成方法 + * @param {*} methodName 方法名 + * @param {*} model + * @param {*} methodList 方法列表 + * @return {*} + */ +function buildOptionMethod(methodName, model, methodList) { + const str = `function ${methodName}() { + // TODO 发起请求获取数据 + ${model}.value + }` + methodList.push(str) +} +/** + * @name: 生成表单组件需要的props设置 + * @description: 生成表单组件需要的props设置,如;级联组件 + * @param {*} conf + * @param {*} propsList + * @return {*} + */ +function buildProps(conf, propsList) { + if (conf.dataType === 'dynamic') { + conf.valueKey !== 'value' && (conf.props.props.value = conf.valueKey) + conf.labelKey !== 'label' && (conf.props.props.label = conf.labelKey) + conf.childrenKey !== 'children' && + (conf.props.props.children = conf.childrenKey) + } + const str = ` + // props设置 + const ${conf.vModel}Props = ref(${JSON.stringify(conf.props.props)})` + propsList.push(str) +} +/** + * @name: 生成上传组件的相关内容 + * @description: 生成上传组件的相关内容 + * @param {*} conf + * @return {*} + */ +function buildBeforeUpload(conf) { + const unitNum = units[conf.sizeUnit] + let rightSizeCode = '' + let acceptCode = '' + const returnList = [] + if (conf.fileSize) { + rightSizeCode = `let isRightSize = file.size / ${unitNum} < ${conf.fileSize} + if(!isRightSize){ + proxy.$modal.msgError('文件大小超过 ${conf.fileSize}${conf.sizeUnit}') + }` + returnList.push('isRightSize') + } + if (conf.accept) { + acceptCode = `let isAccept = new RegExp('${conf.accept}').test(file.type) + if(!isAccept){ + proxy.$modal.msgError('应该选择${conf.accept}类型的文件') + }` + returnList.push('isAccept') + } + const str = ` + /** + * @name: 上传之前的文件判断 + * @description: 上传之前的文件判断,判断文件大小文件类型等 + * @param {*} file + * @return {*} + */ + function ${conf.vModel}BeforeUpload(file) { + ${rightSizeCode} + ${acceptCode} + return ${returnList.join('&&')} + }` + return returnList.length ? str : '' +} +/** + * @name: 生成提交表单方法 + * @description: 生成提交表单方法 + * @param {Object} conf vModel 表单ref + * @return {*} + */ +function buildSubmitUpload(conf) { + const str = `function submitUpload() { + this.$refs['${conf.vModel}'].submit() + }` + return str +} +/** + * @name: 组装js代码 + * @description: 组装js代码方法 + * @return {*} + */ +function buildexport( + conf, + type, + data, + rules, + selectOptions, + uploadVar, + props, + methods +) { + let str = ` + const { proxy } = getCurrentInstance() + const ${conf.formRef} = ref() + const data = reactive({ + ${conf.formModel}: { + ${data} + }, + ${conf.formRules}: { + ${rules} + } + }) + + const {${conf.formModel}, ${conf.formRules}} = toRefs(data) + + ${selectOptions} + + ${uploadVar} + + ${props} + + ${methods} + ` + + if(type === 'dialog') { + str += ` + // 弹窗设置 + const dialogVisible = defineModel() + // 弹窗确认回调 + const emit = defineEmits(['confirm']) + /** + * @name: 弹窗打开后执行 + * @description: 弹窗打开后执行方法 + * @return {*} + */ + function onOpen(){ + + } + /** + * @name: 弹窗关闭时执行 + * @description: 弹窗关闭方法,重置表单 + * @return {*} + */ + function onClose(){ + ${conf.formRef}.value.resetFields() + } + /** + * @name: 弹窗取消 + * @description: 弹窗取消方法 + * @return {*} + */ + function close(){ + dialogVisible.value = false + } + /** + * @name: 弹窗表单提交 + * @description: 弹窗表单提交方法 + * @return {*} + */ + function handelConfirm(){ + ${conf.formRef}.value.validate((valid) => { + if (!valid) return + // TODO 提交表单 + + close() + // 回调父级组件 + emit('confirm') + }) + } + ` + } else { + str += ` + /** + * @name: 表单提交 + * @description: 表单提交方法 + * @return {*} + */ + function submitForm() { + ${conf.formRef}.value.validate((valid) => { + if (!valid) return + // TODO 提交表单 + }) + } + /** + * @name: 表单重置 + * @description: 表单重置方法 + * @return {*} + */ + function resetForm() { + ${conf.formRef}.value.resetFields() + } + ` + } + return str +} diff --git a/shierfeng-fastapi-frontend/src/utils/generator/render.js b/shierfeng-fastapi-frontend/src/utils/generator/render.js new file mode 100755 index 0000000..d6d4414 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/generator/render.js @@ -0,0 +1,156 @@ +import { defineComponent, h } from 'vue' +import { makeMap } from '@/utils/index' + +const isAttr = makeMap( + 'accept,accept-charset,accesskey,action,align,alt,async,autocomplete,' + + 'autofocus,autoplay,autosave,bgcolor,border,buffered,challenge,charset,' + + 'checked,cite,class,code,codebase,color,cols,colspan,content,http-equiv,' + + 'name,contenteditable,contextmenu,controls,coords,data,datetime,default,' + + 'defer,dir,dirname,disabled,download,draggable,dropzone,enctype,method,for,' + + 'form,formaction,headers,height,hidden,high,href,hreflang,http-equiv,' + + 'icon,id,ismap,itemprop,keytype,kind,label,lang,language,list,loop,low,' + + 'manifest,max,maxlength,media,method,GET,POST,min,multiple,email,file,' + + 'muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,' + + 'preload,radiogroup,readonly,rel,required,reversed,rows,rowspan,sandbox,' + + 'scope,scoped,seamless,selected,shape,size,type,text,password,sizes,span,' + + 'spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,' + + 'target,title,type,usemap,value,width,wrap' + 'prefix-icon' +) +const isNotProps = makeMap( + 'layout,prepend,regList,tag,document,changeTag,defaultValue' +) + +function useVModel(props, emit) { + return { + modelValue: props.defaultValue, + 'onUpdate:modelValue': (val) => emit('update:modelValue', val), + } +} +const componentChild = { + 'el-button': { + default(h, conf, key) { + return conf[key] + }, + }, + 'el-select': { + options(h, conf, key) { + return conf.options.map(item => h(resolveComponent('el-option'), { + label: item.label, + value: item.value, + })) + } + }, + 'el-radio-group': { + options(h, conf, key) { + return conf.optionType === 'button' ? conf.options.map(item => h(resolveComponent('el-checkbox-button'), { + label: item.value, + }, () => item.label)) : conf.options.map(item => h(resolveComponent('el-radio'), { + label: item.value, + border: conf.border, + }, () => item.label)) + } + }, + 'el-checkbox-group': { + options(h, conf, key) { + return conf.optionType === 'button' ? conf.options.map(item => h(resolveComponent('el-checkbox-button'), { + label: item.value, + }, () => item.label)) : conf.options.map(item => h(resolveComponent('el-checkbox'), { + label: item.value, + border: conf.border, + }, () => item.label)) + } + }, + 'el-upload': { + 'list-type': (h, conf, key) => { + const option = {} + // if (conf.showTip) { + // tip = h('div', { + // class: "el-upload__tip" + // }, () => '只能上传不超过' + conf.fileSize + conf.sizeUnit + '的' + conf.accept + '文件') + // } + if (conf['list-type'] === 'picture-card') { + return h(resolveComponent('el-icon'), option, () => h(resolveComponent('Plus'))) + } else { + // option.size = "small" + option.type = "primary" + option.icon = "Upload" + return h(resolveComponent('el-button'), option, () => conf.buttonText) + } + }, + + } +} +const componentSlot = { + 'el-upload': { + 'tip': (h, conf, key) => { + if (conf.showTip) { + return () => h('div', { + class: "el-upload__tip" + }, '只能上传不超过' + conf.fileSize + conf.sizeUnit + '的' + conf.accept + '文件') + } + }, + } +} +export default defineComponent({ + + // 使用 render 函数 + render() { + const dataObject = { + attrs: {}, + props: {}, + on: {}, + style: {} + } + const confClone = JSON.parse(JSON.stringify(this.conf)) + const children = [] + const slot = {} + const childObjs = componentChild[confClone.tag] + if (childObjs) { + Object.keys(childObjs).forEach(key => { + const childFunc = childObjs[key] + if (confClone[key]) { + children.push(childFunc(h, confClone, key)) + } + }) + } + const slotObjs = componentSlot[confClone.tag] + if (slotObjs) { + Object.keys(slotObjs).forEach(key => { + const childFunc = slotObjs[key] + if (confClone[key]) { + slot[key] = childFunc(h, confClone, key) + } + }) + } + Object.keys(confClone).forEach(key => { + const val = confClone[key] + if (dataObject[key]) { + dataObject[key] = val + } else if (isAttr(key)) { + dataObject.attrs[key] = val + } else if (!isNotProps(key)) { + dataObject.props[key] = val + } + }) + if(children.length > 0){ + slot.default = () => children + } + + return h(resolveComponent(this.conf.tag), + { + modelValue: this.$attrs.modelValue, + ...dataObject.props, + ...dataObject.attrs, + style: { + ...dataObject.style + }, + } + , slot ?? null) + }, + props: { + conf: { + type: Object, + required: true, + }, + } +}) \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/utils/index.js b/shierfeng-fastapi-frontend/src/utils/index.js new file mode 100644 index 0000000..4f9eb9f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/index.js @@ -0,0 +1,390 @@ +import { parseTime } from './shierfeng' + +/** + * 表格时间格式化 + */ +export function formatDate(cellValue) { + if (cellValue == null || cellValue == "") return ""; + var date = new Date(cellValue) + var year = date.getFullYear() + var month = date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1 + var day = date.getDate() < 10 ? '0' + date.getDate() : date.getDate() + var hours = date.getHours() < 10 ? '0' + date.getHours() : date.getHours() + var minutes = date.getMinutes() < 10 ? '0' + date.getMinutes() : date.getMinutes() + var seconds = date.getSeconds() < 10 ? '0' + date.getSeconds() : date.getSeconds() + return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds +} + +/** + * @param {number} time + * @param {string} option + * @returns {string} + */ +export function formatTime(time, option) { + if (('' + time).length === 10) { + time = parseInt(time) * 1000 + } else { + time = +time + } + const d = new Date(time) + const now = Date.now() + + const diff = (now - d) / 1000 + + if (diff < 30) { + return '刚刚' + } else if (diff < 3600) { + // less 1 hour + return Math.ceil(diff / 60) + '分钟前' + } else if (diff < 3600 * 24) { + return Math.ceil(diff / 3600) + '小时前' + } else if (diff < 3600 * 24 * 2) { + return '1天前' + } + if (option) { + return parseTime(time, option) + } else { + return ( + d.getMonth() + + 1 + + '月' + + d.getDate() + + '日' + + d.getHours() + + '时' + + d.getMinutes() + + '分' + ) + } +} + +/** + * @param {string} url + * @returns {Object} + */ +export function getQueryObject(url) { + url = url == null ? window.location.href : url + const search = url.substring(url.lastIndexOf('?') + 1) + const obj = {} + const reg = /([^?&=]+)=([^?&=]*)/g + search.replace(reg, (rs, $1, $2) => { + const name = decodeURIComponent($1) + let val = decodeURIComponent($2) + val = String(val) + obj[name] = val + return rs + }) + return obj +} + +/** + * @param {string} input value + * @returns {number} output value + */ +export function byteLength(str) { + // returns the byte length of an utf8 string + let s = str.length + for (var i = str.length - 1; i >= 0; i--) { + const code = str.charCodeAt(i) + if (code > 0x7f && code <= 0x7ff) s++ + else if (code > 0x7ff && code <= 0xffff) s += 2 + if (code >= 0xDC00 && code <= 0xDFFF) i-- + } + return s +} + +/** + * @param {Array} actual + * @returns {Array} + */ +export function cleanArray(actual) { + const newArray = [] + for (let i = 0; i < actual.length; i++) { + if (actual[i]) { + newArray.push(actual[i]) + } + } + return newArray +} + +/** + * @param {Object} json + * @returns {Array} + */ +export function param(json) { + if (!json) return '' + return cleanArray( + Object.keys(json).map(key => { + if (json[key] === undefined) return '' + return encodeURIComponent(key) + '=' + encodeURIComponent(json[key]) + }) + ).join('&') +} + +/** + * @param {string} url + * @returns {Object} + */ +export function param2Obj(url) { + const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ') + if (!search) { + return {} + } + const obj = {} + const searchArr = search.split('&') + searchArr.forEach(v => { + const index = v.indexOf('=') + if (index !== -1) { + const name = v.substring(0, index) + const val = v.substring(index + 1, v.length) + obj[name] = val + } + }) + return obj +} + +/** + * @param {string} val + * @returns {string} + */ +export function html2Text(val) { + const div = document.createElement('div') + div.innerHTML = val + return div.textContent || div.innerText +} + +/** + * Merges two objects, giving the last one precedence + * @param {Object} target + * @param {(Object|Array)} source + * @returns {Object} + */ +export function objectMerge(target, source) { + if (typeof target !== 'object') { + target = {} + } + if (Array.isArray(source)) { + return source.slice() + } + Object.keys(source).forEach(property => { + const sourceProperty = source[property] + if (typeof sourceProperty === 'object') { + target[property] = objectMerge(target[property], sourceProperty) + } else { + target[property] = sourceProperty + } + }) + return target +} + +/** + * @param {HTMLElement} element + * @param {string} className + */ +export function toggleClass(element, className) { + if (!element || !className) { + return + } + let classString = element.className + const nameIndex = classString.indexOf(className) + if (nameIndex === -1) { + classString += '' + className + } else { + classString = + classString.substr(0, nameIndex) + + classString.substr(nameIndex + className.length) + } + element.className = classString +} + +/** + * @param {string} type + * @returns {Date} + */ +export function getTime(type) { + if (type === 'start') { + return new Date().getTime() - 3600 * 1000 * 24 * 90 + } else { + return new Date(new Date().toDateString()) + } +} + +/** + * @param {Function} func + * @param {number} wait + * @param {boolean} immediate + * @return {*} + */ +export function debounce(func, wait, immediate) { + let timeout, args, context, timestamp, result + + const later = function() { + // 据上一次触发时间间隔 + const last = +new Date() - timestamp + + // 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait + if (last < wait && last > 0) { + timeout = setTimeout(later, wait - last) + } else { + timeout = null + // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用 + if (!immediate) { + result = func.apply(context, args) + if (!timeout) context = args = null + } + } + } + + return function(...args) { + context = this + timestamp = +new Date() + const callNow = immediate && !timeout + // 如果延时不存在,重新设定延时 + if (!timeout) timeout = setTimeout(later, wait) + if (callNow) { + result = func.apply(context, args) + context = args = null + } + + return result + } +} + +/** + * This is just a simple version of deep copy + * Has a lot of edge cases bug + * If you want to use a perfect deep copy, use lodash's _.cloneDeep + * @param {Object} source + * @returns {Object} + */ +export function deepClone(source) { + if (!source && typeof source !== 'object') { + throw new Error('error arguments', 'deepClone') + } + const targetObj = source.constructor === Array ? [] : {} + Object.keys(source).forEach(keys => { + if (source[keys] && typeof source[keys] === 'object') { + targetObj[keys] = deepClone(source[keys]) + } else { + targetObj[keys] = source[keys] + } + }) + return targetObj +} + +/** + * @param {Array} arr + * @returns {Array} + */ +export function uniqueArr(arr) { + return Array.from(new Set(arr)) +} + +/** + * @returns {string} + */ +export function createUniqueString() { + const timestamp = +new Date() + '' + const randomNum = parseInt((1 + Math.random()) * 65536) + '' + return (+(randomNum + timestamp)).toString(32) +} + +/** + * Check if an element has a class + * @param {HTMLElement} elm + * @param {string} cls + * @returns {boolean} + */ +export function hasClass(ele, cls) { + return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)')) +} + +/** + * Add class to element + * @param {HTMLElement} elm + * @param {string} cls + */ +export function addClass(ele, cls) { + if (!hasClass(ele, cls)) ele.className += ' ' + cls +} + +/** + * Remove class from element + * @param {HTMLElement} elm + * @param {string} cls + */ +export function removeClass(ele, cls) { + if (hasClass(ele, cls)) { + const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)') + ele.className = ele.className.replace(reg, ' ') + } +} + +export function makeMap(str, expectsLowerCase) { + const map = Object.create(null) + const list = str.split(',') + for (let i = 0; i < list.length; i++) { + map[list[i]] = true + } + return expectsLowerCase + ? val => map[val.toLowerCase()] + : val => map[val] +} + +export const exportDefault = 'export default ' + +export const beautifierConf = { + html: { + indent_size: '2', + indent_char: ' ', + max_preserve_newlines: '-1', + preserve_newlines: false, + keep_array_indentation: false, + break_chained_methods: false, + indent_scripts: 'separate', + brace_style: 'end-expand', + space_before_conditional: true, + unescape_strings: false, + jslint_happy: false, + end_with_newline: true, + wrap_line_length: '110', + indent_inner_html: true, + comma_first: false, + e4x: true, + indent_empty_lines: true + }, + js: { + indent_size: '2', + indent_char: ' ', + max_preserve_newlines: '-1', + preserve_newlines: false, + keep_array_indentation: false, + break_chained_methods: false, + indent_scripts: 'normal', + brace_style: 'end-expand', + space_before_conditional: true, + unescape_strings: false, + jslint_happy: true, + end_with_newline: true, + wrap_line_length: '110', + indent_inner_html: true, + comma_first: false, + e4x: true, + indent_empty_lines: true + } +} + +// 首字母大小 +export function titleCase(str) { + return str.replace(/( |^)[a-z]/g, L => L.toUpperCase()) +} + +// 下划转驼峰 +export function camelCase(str) { + return str.replace(/_[a-z]/g, str1 => str1.substr(-1).toUpperCase()) +} + +export function isNumberStr(str) { + return /^[+-]?(0|([1-9]\d*))(\.\d+)?$/g.test(str) +} + diff --git a/shierfeng-fastapi-frontend/src/utils/jsencrypt.js b/shierfeng-fastapi-frontend/src/utils/jsencrypt.js new file mode 100644 index 0000000..78d9523 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/jsencrypt.js @@ -0,0 +1,30 @@ +import JSEncrypt from 'jsencrypt/bin/jsencrypt.min' + +// 密钥对生成 http://web.chacuo.net/netrsakeypair + +const publicKey = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH\n' + + 'nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==' + +const privateKey = 'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY\n' + + '7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKN\n' + + 'PuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gA\n' + + 'kM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWow\n' + + 'cSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99Ecv\n' + + 'DQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthh\n' + + 'YhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3\n' + + 'UP8iWi1Qw0Y=' + +// 加密 +export function encrypt(txt) { + const encryptor = new JSEncrypt() + encryptor.setPublicKey(publicKey) // 设置公钥 + return encryptor.encrypt(txt) // 对数据进行加密 +} + +// 解密 +export function decrypt(txt) { + const encryptor = new JSEncrypt() + encryptor.setPrivateKey(privateKey) // 设置私钥 + return encryptor.decrypt(txt) // 对数据进行解密 +} + diff --git a/shierfeng-fastapi-frontend/src/utils/permission.js b/shierfeng-fastapi-frontend/src/utils/permission.js new file mode 100644 index 0000000..93fee87 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/permission.js @@ -0,0 +1,51 @@ +import useUserStore from '@/store/modules/user' + +/** + * 字符权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkPermi(value) { + if (value && value instanceof Array && value.length > 0) { + const permissions = useUserStore().permissions + const permissionDatas = value + const all_permission = "*:*:*"; + + const hasPermission = permissions.some(permission => { + return all_permission === permission || permissionDatas.includes(permission) + }) + + if (!hasPermission) { + return false + } + return true + } else { + console.error(`need roles! Like checkPermi="['system:user:add','system:user:edit']"`) + return false + } +} + +/** + * 角色权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkRole(value) { + if (value && value instanceof Array && value.length > 0) { + const roles = useUserStore().roles + const permissionRoles = value + const super_admin = "admin"; + + const hasRole = roles.some(role => { + return super_admin === role || permissionRoles.includes(role) + }) + + if (!hasRole) { + return false + } + return true + } else { + console.error(`need roles! Like checkRole="['admin','editor']"`) + return false + } +} \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/utils/request.js b/shierfeng-fastapi-frontend/src/utils/request.js new file mode 100644 index 0000000..c53d1fb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/request.js @@ -0,0 +1,206 @@ +import axios from 'axios' +import { ElNotification , ElMessageBox, ElMessage, ElLoading } from 'element-plus' +import { getToken } from '@/utils/auth' +import errorCode from '@/utils/errorCode' +import { tansParams, blobValidate } from '@/utils/shierfeng' +import cache from '@/plugins/cache' +import { saveAs } from 'file-saver' +import useUserStore from '@/store/modules/user' +import { + decryptTransportErrorResponse, + decryptTransportResponse, + encryptTransportRequest, + invalidateTransportKeyMeta, + resetTransportRequestConfig, + shouldRetryTransportWithFreshKey +} from '@/utils/transportCrypto' + +let downloadLoadingInstance; +// 是否显示重新登录 +export let isRelogin = { show: false }; + +axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8' +// 创建axios实例 +const service = axios.create({ + // axios中请求配置有baseURL选项,表示请求URL公共部分 + baseURL: import.meta.env.VITE_APP_BASE_API, + // 超时 + timeout: 10000 +}) + +/** + * 统一处理请求发送前的公共逻辑。 + * + * @param {Object} config Axios 请求配置 + * @returns {Promise} 最终发送的请求配置 + */ +// request拦截器 +service.interceptors.request.use(async config => { + // 是否需要设置 token + const isToken = (config.headers || {}).isToken === false + // 是否需要防止数据重复提交 + const isRepeatSubmit = (config.headers || {}).repeatSubmit === false + // 间隔时间(ms),小于此时间视为重复提交 + const interval = (config.headers || {}).interval || 1000 + if (getToken() && !isToken) { + config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 + } + if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) { + const requestObj = { + url: config.url, + data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data, + time: new Date().getTime() + } + const requestSize = Object.keys(JSON.stringify(requestObj)).length; // 请求数据大小 + const limitSize = 5 * 1024 * 1024; // 限制存放数据5M + if (requestSize >= limitSize) { + console.warn(`[${config.url}]: ` + '请求数据大小超出允许的5M限制,无法进行防重复提交验证。') + return config; + } + const sessionObj = cache.session.getJSON('sessionObj') + if (sessionObj === undefined || sessionObj === null || sessionObj === '') { + cache.session.setJSON('sessionObj', requestObj) + } else { + const s_url = sessionObj.url; // 请求地址 + const s_data = sessionObj.data; // 请求数据 + const s_time = sessionObj.time; // 请求时间 + if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) { + const message = '数据正在处理,请勿重复提交'; + console.warn(`[${s_url}]: ` + message) + return Promise.reject(new Error(message)) + } else { + cache.session.setJSON('sessionObj', requestObj) + } + } + } + // 在参数拼接前完成传输层加密,避免明文查询串提前写入 URL。 + config = await encryptTransportRequest(config) + // get请求映射params参数 + if (config.method === 'get' && config.params) { + let url = config.url + '?' + tansParams(config.params); + url = url.slice(0, -1); + config.params = {}; + config.url = url; + } + return config +}, error => { + console.log(error) + return Promise.reject(error) +}) + +/** + * 统一处理响应成功场景下的解密与业务状态码判断。 + * + * @param {Object} res Axios 响应对象 + * @returns {Promise} 业务响应数据 + */ +// 响应拦截器 +service.interceptors.response.use(async res => { + // 响应若命中了传输层加密,这里先还原为原始业务 JSON。 + res = await decryptTransportResponse(res) + // 未设置状态码则默认成功状态 + const code = res.data.code || 200; + // 获取错误信息 + const msg = errorCode[code] || res.data.msg || errorCode['default'] + // 二进制数据则直接返回 + if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') { + return res.data + } + if (code === 401) { + if (!isRelogin.show) { + isRelogin.show = true; + ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => { + isRelogin.show = false; + useUserStore().logOut().then(() => { + location.href = '/index'; + }) + }).catch(() => { + isRelogin.show = false; + }); + } + return Promise.reject('无效的会话,或者会话已过期,请重新登录。') + } else if (code === 500) { + ElMessage({ message: msg, type: 'error' }) + return Promise.reject(new Error(msg)) + } else if (code === 601) { + ElMessage({ message: msg, type: 'warning' }) + return Promise.reject(new Error(msg)) + } else if (code !== 200) { + ElNotification.error({ title: msg }) + return Promise.reject('error') + } else { + return Promise.resolve(res.data) + } + }, + async error => { + // 错误响应也可能是加密信封,先尝试解密再进入统一错误提示流程。 + error = await decryptTransportErrorResponse(error) + // 若后端提示密钥失效,则清空本地公钥缓存并基于原始请求重试一次。 + if (shouldRetryTransportWithFreshKey(error) && error.config && !error.config.__transportRetried) { + invalidateTransportKeyMeta() + error.config.__transportRetried = true + error.config.headers = error.config.headers || {} + error.config.headers.repeatSubmit = false + resetTransportRequestConfig(error.config) + return service.request(error.config) + } + console.log('err' + error) + const response = error.response + const responseStatus = response?.status + const responseCode = response?.data?.code + const responseMsg = response?.data?.msg + if (responseMsg) { + const messageType = responseStatus === 429 || responseCode === 429 ? 'warning' : 'error' + ElMessage({ message: responseMsg, type: messageType, duration: 5 * 1000 }) + return Promise.reject(new Error(responseMsg)) + } + let { message } = error; + if (message == "Network Error") { + message = "后端接口连接异常"; + } else if (message.includes("timeout")) { + message = "系统接口请求超时"; + } else if (message.includes("Request failed with status code")) { + message = "系统接口" + message.slice(-3) + "异常"; + } + ElMessage({ message: message, type: 'error', duration: 5 * 1000 }) + return Promise.reject(error) + } +) + +/** + * 通用文件下载方法。 + * + * @param {string} url 下载接口地址 + * @param {*} params 请求参数 + * @param {string} filename 下载文件名 + * @param {Object} config 额外请求配置 + * @returns {Promise} + */ +// 通用下载方法 +export function download(url, params, filename, config) { + downloadLoadingInstance = ElLoading.service({ text: "正在下载数据,请稍候", background: "rgba(0, 0, 0, 0.7)", }) + return service.post(url, params, { + transformRequest: [(params) => { return tansParams(params) }], + headers: { 'Content-Type': 'application/x-www-form-urlencoded', encrypt: false, encryptResponse: false }, + responseType: 'blob', + ...config + }).then(async (data) => { + const isBlob = blobValidate(data); + if (isBlob) { + const blob = new Blob([data]) + saveAs(blob, filename) + } else { + const resText = await data.text(); + const rspObj = JSON.parse(resText); + const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'] + ElMessage.error(errMsg); + } + downloadLoadingInstance.close(); + }).catch((r) => { + console.error(r) + ElMessage.error('下载文件出现错误,请联系管理员!') + downloadLoadingInstance.close(); + }) +} + +export default service diff --git a/shierfeng-fastapi-frontend/src/utils/scroll-to.js b/shierfeng-fastapi-frontend/src/utils/scroll-to.js new file mode 100644 index 0000000..c5d8e04 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/scroll-to.js @@ -0,0 +1,58 @@ +Math.easeInOutQuad = function(t, b, c, d) { + t /= d / 2 + if (t < 1) { + return c / 2 * t * t + b + } + t-- + return -c / 2 * (t * (t - 2) - 1) + b +} + +// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts +var requestAnimFrame = (function() { + return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) } +})() + +/** + * Because it's so fucking difficult to detect the scrolling element, just move them all + * @param {number} amount + */ +function move(amount) { + document.documentElement.scrollTop = amount + document.body.parentNode.scrollTop = amount + document.body.scrollTop = amount +} + +function position() { + return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop +} + +/** + * @param {number} to + * @param {number} duration + * @param {Function} callback + */ +export function scrollTo(to, duration, callback) { + const start = position() + const change = to - start + const increment = 20 + let currentTime = 0 + duration = (typeof (duration) === 'undefined') ? 500 : duration + var animateScroll = function() { + // increment the time + currentTime += increment + // find the value with the quadratic in-out easing function + var val = Math.easeInOutQuad(currentTime, start, change, duration) + // move the document.body + move(val) + // do the animation unless its over + if (currentTime < duration) { + requestAnimFrame(animateScroll) + } else { + if (callback && typeof (callback) === 'function') { + // the animation is done so lets callback + callback() + } + } + } + animateScroll() +} diff --git a/shierfeng-fastapi-frontend/src/utils/shierfeng.js b/shierfeng-fastapi-frontend/src/utils/shierfeng.js new file mode 100644 index 0000000..2103742 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/shierfeng.js @@ -0,0 +1,245 @@ + + +/** + * 通用js方法封装处理 + * Copyright (c) 2019 ruoyi + */ + +// 日期格式化 +export function parseTime(time, pattern) { + if (arguments.length === 0 || !time) { + return null + } + const format = pattern || '{y}-{m}-{d} {h}:{i}:{s}' + let date + if (typeof time === 'object') { + date = time + } else { + if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) { + time = parseInt(time) + } else if (typeof time === 'string') { + time = time.replace(new RegExp(/-/gm), '/').replace('T', ' ').replace(new RegExp(/\.[\d]{3}/gm), ''); + } + if ((typeof time === 'number') && (time.toString().length === 10)) { + time = time * 1000 + } + date = new Date(time) + } + const formatObj = { + y: date.getFullYear(), + m: date.getMonth() + 1, + d: date.getDate(), + h: date.getHours(), + i: date.getMinutes(), + s: date.getSeconds(), + a: date.getDay() + } + const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => { + let value = formatObj[key] + // Note: getDay() returns 0 on Sunday + if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] } + if (result.length > 0 && value < 10) { + value = '0' + value + } + return value || 0 + }) + return time_str +} + +// 表单重置 +export function resetForm(refName) { + if (this.$refs[refName]) { + this.$refs[refName].resetFields(); + } +} + +// 添加日期范围 +export function addDateRange(params, dateRange, propName) { + let search = params; + dateRange = Array.isArray(dateRange) ? dateRange : []; + if (typeof (propName) === 'undefined') { + search['beginTime'] = dateRange[0]; + search['endTime'] = dateRange[1]; + } else { + search['begin' + propName] = dateRange[0]; + search['end' + propName] = dateRange[1]; + } + return search; +} + +// 回显数据字典 +export function selectDictLabel(datas, value) { + if (value === undefined) { + return ""; + } + var actions = []; + Object.keys(datas).some((key) => { + if (datas[key].value == ('' + value)) { + actions.push(datas[key].label); + return true; + } + }) + if (actions.length === 0) { + actions.push(value); + } + return actions.join(''); +} + +// 回显数据字典(字符串数组) +export function selectDictLabels(datas, value, separator) { + if (value === undefined || value.length ===0) { + return ""; + } + if (Array.isArray(value)) { + value = value.join(","); + } + var actions = []; + var currentSeparator = undefined === separator ? "," : separator; + var temp = value.split(currentSeparator); + Object.keys(value.split(currentSeparator)).some((val) => { + var match = false; + Object.keys(datas).some((key) => { + if (datas[key].value == ('' + temp[val])) { + actions.push(datas[key].label + currentSeparator); + match = true; + } + }) + if (!match) { + actions.push(temp[val] + currentSeparator); + } + }) + return actions.join('').substring(0, actions.join('').length - 1); +} + +// 字符串格式化(%s ) +export function sprintf(str) { + var args = arguments, flag = true, i = 1; + str = str.replace(/%s/g, function () { + var arg = args[i++]; + if (typeof arg === 'undefined') { + flag = false; + return ''; + } + return arg; + }); + return flag ? str : ''; +} + +// 转换字符串,undefined,null等转化为"" +export function parseStrEmpty(str) { + if (!str || str == "undefined" || str == "null") { + return ""; + } + return str; +} + +// 数据合并 +export function mergeRecursive(source, target) { + for (var p in target) { + try { + if (target[p].constructor == Object) { + source[p] = mergeRecursive(source[p], target[p]); + } else { + source[p] = target[p]; + } + } catch (e) { + source[p] = target[p]; + } + } + return source; +}; + +/** + * 构造树型结构数据 + * @param {*} data 数据源 + * @param {*} id id字段 默认 'id' + * @param {*} parentId 父节点字段 默认 'parentId' + * @param {*} children 孩子节点字段 默认 'children' + */ +export function handleTree(data, id, parentId, children) { + let config = { + id: id || 'id', + parentId: parentId || 'parentId', + childrenList: children || 'children' + }; + + var childrenListMap = {}; + var nodeIds = {}; + var tree = []; + + for (let d of data) { + let parentId = d[config.parentId]; + if (childrenListMap[parentId] == null) { + childrenListMap[parentId] = []; + } + nodeIds[d[config.id]] = d; + childrenListMap[parentId].push(d); + } + + for (let d of data) { + let parentId = d[config.parentId]; + if (nodeIds[parentId] == null) { + tree.push(d); + } + } + + for (let t of tree) { + adaptToChildrenList(t); + } + + function adaptToChildrenList(o) { + if (childrenListMap[o[config.id]] !== null) { + o[config.childrenList] = childrenListMap[o[config.id]]; + } + if (o[config.childrenList]) { + for (let c of o[config.childrenList]) { + adaptToChildrenList(c); + } + } + } + return tree; +} + +/** +* 参数处理 +* @param {*} params 参数 +*/ +export function tansParams(params) { + let result = '' + for (const propName of Object.keys(params)) { + const value = params[propName]; + var part = encodeURIComponent(propName) + "="; + if (value !== null && value !== "" && typeof (value) !== "undefined") { + if (typeof value === 'object') { + for (const key of Object.keys(value)) { + if (value[key] !== null && value[key] !== "" && typeof (value[key]) !== 'undefined') { + let params = propName + '[' + key + ']'; + var subPart = encodeURIComponent(params) + "="; + result += subPart + encodeURIComponent(value[key]) + "&"; + } + } + } else { + result += part + encodeURIComponent(value) + "&"; + } + } + } + return result +} + + +// 返回项目路径 +export function getNormalPath(p) { + if (p.length === 0 || !p || p == 'undefined') { + return p + }; + let res = p.replace('//', '/') + if (res[res.length - 1] === '/') { + return res.slice(0, res.length - 1) + } + return res; +} + +// 验证是否为blob格式 +export function blobValidate(data) { + return data.type !== 'application/json' +} diff --git a/shierfeng-fastapi-frontend/src/utils/theme.js b/shierfeng-fastapi-frontend/src/utils/theme.js new file mode 100644 index 0000000..f4badc6 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/theme.js @@ -0,0 +1,49 @@ +// 处理主题样式 +export function handleThemeStyle(theme) { + document.documentElement.style.setProperty('--el-color-primary', theme) + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, `${getLightColor(theme, i / 10)}`) + } + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty(`--el-color-primary-dark-${i}`, `${getDarkColor(theme, i / 10)}`) + } +} + +// hex颜色转rgb颜色 +export function hexToRgb(str) { + str = str.replace('#', '') + let hexs = str.match(/../g) + for (let i = 0; i < 3; i++) { + hexs[i] = parseInt(hexs[i], 16) + } + return hexs +} + +// rgb颜色转Hex颜色 +export function rgbToHex(r, g, b) { + let hexs = [r.toString(16), g.toString(16), b.toString(16)] + for (let i = 0; i < 3; i++) { + if (hexs[i].length == 1) { + hexs[i] = `0${hexs[i]}` + } + } + return `#${hexs.join('')}` +} + +// 变浅颜色值 +export function getLightColor(color, level) { + let rgb = hexToRgb(color) + for (let i = 0; i < 3; i++) { + rgb[i] = Math.floor((255 - rgb[i]) * level + rgb[i]) + } + return rgbToHex(rgb[0], rgb[1], rgb[2]) +} + +// 变深颜色值 +export function getDarkColor(color, level) { + let rgb = hexToRgb(color) + for (let i = 0; i < 3; i++) { + rgb[i] = Math.floor(rgb[i] * (1 - level)) + } + return rgbToHex(rgb[0], rgb[1], rgb[2]) +} diff --git a/shierfeng-fastapi-frontend/src/utils/transportCrypto.js b/shierfeng-fastapi-frontend/src/utils/transportCrypto.js new file mode 100644 index 0000000..db0e61c --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/transportCrypto.js @@ -0,0 +1,748 @@ +import axios from 'axios' + +import { + ensureTransportCryptoPolicyLoaded, + getTransportCryptoPolicy, + shouldEncryptQuery, + shouldEncryptRequest, + shouldEncryptResponse +} from '@/utils/transportCryptoPolicy' +import cache from '@/plugins/cache' + +const TRANSPORT_BASE_URL = import.meta.env.VITE_APP_BASE_API +const TRANSPORT_ENABLE_HEADER = 'X-Transport-Encrypt' +const TRANSPORT_KEY_ID_HEADER = 'X-Key-Id' +const ENCRYPTED_RESPONSE_HEADER = 'x-body-encrypted' +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = '1' + +const transportClient = axios.create({ + baseURL: TRANSPORT_BASE_URL, + timeout: 10000 +}) + +let cachedKeyMeta = null +let inflightKeyMetaPromise = null +const KEY_REFRESH_BUFFER_MIN_SECONDS = 30 +const KEY_REFRESH_BUFFER_MAX_SECONDS = 300 +const TRANSPORT_KEY_META_CACHE_KEY = 'transportCryptoKeyMeta' +const TRANSPORT_RETRYABLE_ERROR_MESSAGES = new Set(['Decryption failed', '密钥版本不存在']) + +/** + * 获取当前浏览器的 Web Crypto 实例。 + * + * @returns {Crypto} 浏览器加密能力对象 + */ +function getBrowserCrypto() { + const browserCrypto = globalThis.crypto + if (!browserCrypto?.subtle) { + throw new Error('当前浏览器不支持 Web Crypto API') + } + return browserCrypto +} + +/** + * 从请求头对象中读取指定字段。 + * + * @param {Object|Headers} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined + } + if (typeof headers.get === 'function') { + return headers.get(name) + } + return headers[name] ?? headers[name.toLowerCase()] +} + +/** + * 为请求头对象设置指定字段。 + * + * @param {Object|Headers} headers 请求头对象 + * @param {string} name 请求头名称 + * @param {*} value 请求头值 + * @returns {void} + */ +function setHeaderValue(headers, name, value) { + if (!headers) { + return + } + if (typeof headers.set === 'function') { + headers.set(name, value) + return + } + headers[name] = value +} + +/** + * 将字节数组编码为 Base64URL 文本。 + * + * @param {Uint8Array} bytes 待编码字节数组 + * @returns {string} Base64URL 文本 + */ +function toBase64Url(bytes) { + let binary = '' + bytes.forEach(byte => { + binary += String.fromCharCode(byte) + }) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +/** + * 将 Base64URL 文本还原为字节数组。 + * + * @param {string} text Base64URL 文本 + * @returns {Uint8Array} 解码后的字节数组 + */ +function fromBase64Url(text) { + const normalizedText = text.replace(/-/g, '+').replace(/_/g, '/') + const paddingLength = (4 - (normalizedText.length % 4 || 4)) % 4 + const binary = atob(normalizedText + '='.repeat(paddingLength)) + return Uint8Array.from(binary, char => char.charCodeAt(0)) +} + +/** + * 将 PEM 公钥转换为 Web Crypto 可导入的 ArrayBuffer。 + * + * @param {string} pem PEM 格式公钥 + * @returns {ArrayBuffer} DER 二进制内容 + */ +function pemToArrayBuffer(pem) { + const normalizedPem = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\s+/g, '') + const binary = atob(normalizedPem) + return Uint8Array.from(binary, char => char.charCodeAt(0)).buffer +} + +/** + * 对查询参数信封进行 JSON 编码后再转为 Base64URL。 + * + * @param {Object} envelope 查询参数信封 + * @returns {string} 编码后的查询字符串片段 + */ +function encodeQueryEnvelope(envelope) { + const jsonText = JSON.stringify(envelope) + return toBase64Url(new TextEncoder().encode(jsonText)) +} + +/** + * 计算加密查询参数最终生成的 URL 长度。 + * + * @param {string} url 请求地址 + * @param {Object} params 查询参数 + * @returns {number} URL 长度 + */ +function buildQueryUrlLength(url = '', params = {}) { + const queryText = new URLSearchParams(params).toString() + if (!queryText) { + return String(url || '').length + } + const normalizedUrl = String(url || '') + const separator = normalizedUrl.includes('?') ? '&' : '?' + return `${normalizedUrl}${separator}${queryText}`.length +} + +/** + * 获取基础 API 地址对应的路径前缀。 + * + * @returns {string} 基础路径前缀 + */ +function getBaseApiPath() { + if (!TRANSPORT_BASE_URL) { + return '' + } + if (TRANSPORT_BASE_URL.startsWith('http://') || TRANSPORT_BASE_URL.startsWith('https://')) { + const baseApiPath = new URL(TRANSPORT_BASE_URL).pathname + return baseApiPath === '/' ? '' : baseApiPath + } + return TRANSPORT_BASE_URL +} + +/** + * 计算参与 AAD 校验的标准请求路径。 + * + * @param {string} url 请求地址 + * @returns {string} 标准化请求路径 + */ +function getRequestPath(url = '') { + const baseApiPath = getBaseApiPath() + const normalizedUrl = String(url || '') + + let pathname = normalizedUrl + if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) { + pathname = new URL(normalizedUrl).pathname + } else { + pathname = normalizedUrl.split('?')[0] || '/' + } + + if (baseApiPath && pathname.startsWith(baseApiPath)) { + const normalizedPath = pathname.slice(baseApiPath.length) + return normalizedPath || '/' + } + return pathname || '/' +} + +/** + * 构建请求方向的 AAD 元数据。 + * + * @param {Object} config Axios 请求配置 + * @returns {Object} 请求 AAD + */ +function buildRequestAad(config) { + return { + method: (config.method || 'get').toUpperCase(), + path: getRequestPath(config.url) + } +} + +/** + * 构建响应方向的 AAD 元数据。 + * + * @param {Object} config Axios 请求配置 + * @returns {Object} 响应 AAD + */ +function buildResponseAad(config) { + return { + method: (config?.method || 'get').toUpperCase(), + path: getRequestPath(config?.url), + direction: 'response' + } +} + +/** + * 将空值载荷规范化为可序列化对象。 + * + * @param {*} payload 原始载荷 + * @returns {*} 规范化后的载荷 + */ +function normalizePlainPayload(payload) { + if (payload === undefined || payload === null) { + return {} + } + return payload +} + +/** + * 将请求载荷序列化为 JSON 文本。 + * + * @param {*} payload 原始载荷 + * @returns {string} JSON 文本 + */ +function stringifyPayload(payload) { + const normalizedPayload = normalizePlainPayload(payload) + return JSON.stringify(normalizedPayload) +} + +/** + * 克隆请求配置中的可变字段,避免重试时互相污染。 + * + * @param {*} value 待克隆值 + * @returns {*} 克隆结果 + */ +function cloneRequestValue(value) { + if (value === undefined || value === null) { + return value + } + if (typeof globalThis.structuredClone === 'function') { + return globalThis.structuredClone(value) + } + if (typeof value === 'object') { + return JSON.parse(JSON.stringify(value)) + } + return value +} + +/** + * 将信封字段转换为适合表单提交的字符串。 + * + * @param {*} value 字段值 + * @returns {string} 序列化文本 + */ +function stringifyEnvelopeField(value) { + if (value && typeof value === 'object') { + return JSON.stringify(value) + } + return String(value) +} + +/** + * 将加密信封编码为表单字符串。 + * + * @param {Object} envelope 信封对象 + * @returns {string} 表单编码文本 + */ +function encodeFormEnvelope(envelope) { + const formData = new URLSearchParams() + Object.entries(envelope).forEach(([key, value]) => { + formData.set(key, stringifyEnvelopeField(value)) + }) + return formData.toString() +} + +/** + * 将输入解析为 JSON 对象并校验结构。 + * + * @param {*} payload 原始数据 + * @param {string} errorMessage 校验失败提示 + * @returns {Object} 解析后的对象 + */ +function parseJsonObject(payload, errorMessage) { + const parsedPayload = typeof payload === 'string' ? JSON.parse(payload) : payload + if (!parsedPayload || typeof parsedPayload !== 'object' || Array.isArray(parsedPayload)) { + throw new Error(errorMessage) + } + return parsedPayload +} + +/** + * 校验公钥接口响应壳是否有效。 + * + * @param {Object} responsePayload 公钥接口原始响应 + * @returns {void} + */ +function validateTransportPublicKeyResponse(responsePayload) { + if (responsePayload?.code !== 200 || !responsePayload?.data || typeof responsePayload.data !== 'object') { + throw new Error(responsePayload?.msg || '获取传输层公钥失败') + } +} + +/** + * 校验公钥业务载荷是否满足当前协议要求。 + * + * @param {Object} payload 公钥业务载荷 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateTransportPublicKeyPayload(payload, transportPolicy) { + if (!payload?.publicKey || !payload?.kid) { + throw new Error('获取传输层公钥失败') + } + if (String(payload.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION) !== transportPolicy.envelopeVersion) { + throw new Error('传输层公钥协议版本不受支持') + } + if (payload.alg !== transportPolicy.requestEnvelopeAlgorithm) { + throw new Error('传输层公钥算法不受支持') + } +} + +/** + * 校验响应信封与当前请求上下文是否一致。 + * + * @param {Object} envelope 响应信封 + * @param {Object} response Axios 响应对象 + * @param {Object} transportContext 请求加密上下文 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateResponseEnvelope(envelope, response, transportContext, transportPolicy) { + const expectedAad = buildResponseAad(response.config) + const responseKid = getHeaderValue(response.headers, TRANSPORT_KEY_ID_HEADER) + const aad = envelope.aad + + if (String(envelope.v || '') !== transportPolicy.envelopeVersion) { + throw new Error('传输层响应协议版本不受支持') + } + if (String(envelope.alg || '') !== transportPolicy.responseEnvelopeAlgorithm) { + throw new Error('传输层响应算法不受支持') + } + if (String(envelope.kid || '') !== String(transportContext.kid)) { + throw new Error('传输层响应密钥版本不匹配') + } + if (responseKid && String(envelope.kid) !== String(responseKid)) { + throw new Error('传输层响应头与响应体密钥版本不一致') + } + if (!aad || typeof aad !== 'object' || Array.isArray(aad)) { + throw new Error('传输层响应AAD不合法') + } + if (String(aad.method || '').toUpperCase() !== expectedAad.method || String(aad.path || '') !== expectedAad.path) { + throw new Error('传输层响应的method/path与当前请求不匹配') + } + if (String(aad.direction || '') !== expectedAad.direction) { + throw new Error('传输层响应方向标识不合法') + } +} + +/** + * 记录原始请求快照,供密钥刷新重试时恢复。 + * + * @param {Object} config Axios 请求配置 + * @returns {void} + */ +function rememberOriginalRequestSnapshot(config) { + if (config.__transportOriginalSnapshot) { + return + } + config.__transportOriginalSnapshot = { + url: config.url, + params: cloneRequestValue(config.params), + data: cloneRequestValue(config.data), + contentType: getHeaderValue(config.headers, 'Content-Type') + } +} + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000) +} + +/** + * 根据公钥有效期计算本地提前刷新时间。 + * + * @param {number} expireAt 公钥失效时间 + * @param {number} fetchedAt 公钥获取时间 + * @returns {number} 建议刷新时间 + */ +function buildKeyRefreshAt(expireAt, fetchedAt = getNowTimestamp()) { + const normalizedExpireAt = Number(expireAt || 0) + const normalizedFetchedAt = Number(fetchedAt || 0) + const ttlSeconds = Math.max(normalizedExpireAt - normalizedFetchedAt, 0) + if (!normalizedExpireAt || !ttlSeconds) { + return 0 + } + const refreshBufferSeconds = Math.min( + KEY_REFRESH_BUFFER_MAX_SECONDS, + Math.max(KEY_REFRESH_BUFFER_MIN_SECONDS, Math.floor(ttlSeconds * 0.1)) + ) + return Math.max(normalizedFetchedAt, normalizedExpireAt - refreshBufferSeconds) +} + +/** + * 判断缓存中的公钥元数据是否仍可使用。 + * + * @param {Object} keyMeta 公钥元数据 + * @param {number} nowTimestamp 当前时间戳 + * @returns {boolean} 是否可继续使用 + */ +function isUsableKeyMeta(keyMeta, nowTimestamp = getNowTimestamp()) { + if (!keyMeta?.publicKeyPem || !keyMeta?.kid || !keyMeta?.expireAt) { + return false + } + const refreshAt = Number(keyMeta.refreshAt || buildKeyRefreshAt(keyMeta.expireAt, keyMeta.fetchedAt || nowTimestamp)) + return refreshAt > nowTimestamp +} + +/** + * 获取当前可用的后端公钥与密钥元信息。 + * + * @param {boolean} forceRefresh 是否强制刷新 + * @returns {Promise} 公钥元信息 + */ +async function getTransportKeyMeta(forceRefresh = false) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded() + const nowTimestamp = getNowTimestamp() + if (!forceRefresh && !cachedKeyMeta) { + const persistedKeyMeta = cache.session.getJSON(TRANSPORT_KEY_META_CACHE_KEY) + if (isUsableKeyMeta(persistedKeyMeta, nowTimestamp)) { + const browserCrypto = getBrowserCrypto() + const cryptoKey = await browserCrypto.subtle.importKey( + 'spki', + pemToArrayBuffer(persistedKeyMeta.publicKeyPem), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ) + cachedKeyMeta = { + kid: persistedKeyMeta.kid, + alg: persistedKeyMeta.alg, + envelopeVersion: persistedKeyMeta.envelopeVersion || transportPolicy.envelopeVersion, + publicKey: cryptoKey, + publicKeyPem: persistedKeyMeta.publicKeyPem, + expireAt: persistedKeyMeta.expireAt, + fetchedAt: persistedKeyMeta.fetchedAt || nowTimestamp, + refreshAt: persistedKeyMeta.refreshAt || buildKeyRefreshAt(persistedKeyMeta.expireAt, persistedKeyMeta.fetchedAt || nowTimestamp) + } + } + } + if (!forceRefresh && isUsableKeyMeta(cachedKeyMeta, nowTimestamp)) { + return cachedKeyMeta + } + if (inflightKeyMetaPromise) { + return inflightKeyMetaPromise + } + inflightKeyMetaPromise = transportClient.get(transportPolicy.publicKeyUrl || '/transport/crypto/public-key').then(async response => { + const responsePayload = response.data || {} + const payload = responsePayload.data || {} + const fetchedAt = getNowTimestamp() + validateTransportPublicKeyResponse(responsePayload) + validateTransportPublicKeyPayload(payload, transportPolicy) + const browserCrypto = getBrowserCrypto() + const cryptoKey = await browserCrypto.subtle.importKey( + 'spki', + pemToArrayBuffer(payload.publicKey), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ) + cachedKeyMeta = { + kid: payload.kid, + alg: payload.alg, + envelopeVersion: String(payload.envelopeVersion || transportPolicy.envelopeVersion), + publicKey: cryptoKey, + publicKeyPem: payload.publicKey, + expireAt: payload.expireAt, + fetchedAt, + refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt) + } + cache.session.setJSON(TRANSPORT_KEY_META_CACHE_KEY, { + kid: payload.kid, + alg: payload.alg, + envelopeVersion: String(payload.envelopeVersion || transportPolicy.envelopeVersion), + publicKeyPem: payload.publicKey, + expireAt: payload.expireAt, + fetchedAt, + refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt) + }) + inflightKeyMetaPromise = null + return cachedKeyMeta + }).catch(error => { + inflightKeyMetaPromise = null + throw error + }) + return inflightKeyMetaPromise +} + +/** + * 为当前请求创建一次性的对称密钥上下文。 + * + * @returns {Promise} 请求级传输上下文 + */ +async function buildTransportContext() { + const browserCrypto = getBrowserCrypto() + const keyMeta = await getTransportKeyMeta() + const aesKey = await browserCrypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) + const rawAesKey = new Uint8Array(await browserCrypto.subtle.exportKey('raw', aesKey)) + const encryptedAesKey = new Uint8Array( + await browserCrypto.subtle.encrypt({ name: 'RSA-OAEP' }, keyMeta.publicKey, rawAesKey) + ) + return { + kid: keyMeta.kid, + alg: keyMeta.alg, + envelopeVersion: keyMeta.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + aesKey, + ek: toBase64Url(encryptedAesKey) + } +} + +/** + * 使用 AES-GCM 对明文载荷执行信封加密。 + * + * @param {Object} context 请求级传输上下文 + * @param {string} plainText 明文内容 + * @param {Object} aad AAD 元数据 + * @returns {Promise} 加密信封 + */ +async function encryptPayloadText(context, plainText, aad) { + const browserCrypto = getBrowserCrypto() + const iv = browserCrypto.getRandomValues(new Uint8Array(12)) + const ciphertext = new Uint8Array( + await browserCrypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(JSON.stringify(aad)) }, + context.aesKey, + new TextEncoder().encode(plainText) + ) + ) + return { + v: context.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + kid: context.kid, + alg: context.alg, + ts: Math.floor(Date.now() / 1000), + nonce: browserCrypto.randomUUID(), + ek: context.ek, + aad, + iv: toBase64Url(iv), + ct: toBase64Url(ciphertext) + } +} + +/** + * 使用请求上下文中的 AES 密钥解密响应信封。 + * + * @param {Object} envelope 响应信封 + * @param {Object} context 请求级传输上下文 + * @returns {Promise} 解密后的明文 + */ +async function decryptEnvelope(envelope, context) { + const browserCrypto = getBrowserCrypto() + const decryptedBytes = await browserCrypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: fromBase64Url(envelope.iv), + additionalData: new TextEncoder().encode(JSON.stringify(envelope.aad || {})) + }, + context.aesKey, + fromBase64Url(envelope.ct) + ) + return new TextDecoder().decode(decryptedBytes) +} + +/** + * 复用同一次请求内的传输上下文,避免重复生成密钥。 + * + * @param {Object} config Axios 请求配置 + * @returns {Promise} 请求级传输上下文 + */ +function getOrCreateTransportContext(config) { + if (config.__transportCryptoContextPromise) { + return config.__transportCryptoContextPromise + } + config.__transportCryptoContextPromise = buildTransportContext() + return config.__transportCryptoContextPromise +} + +/** + * 对 Axios 请求配置执行传输层加密封装。 + * + * @param {Object} config Axios 请求配置 + * @returns {Promise} 加密后的请求配置 + */ +export async function encryptTransportRequest(config) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded() + if (!shouldEncryptRequest(config, transportPolicy)) { + config.__transportCryptoEnabledForRequest = false + return config + } + + rememberOriginalRequestSnapshot(config) + const transportContext = await getOrCreateTransportContext(config) + const contentType = (getHeaderValue(config.headers, 'Content-Type') || 'application/json').toLowerCase() + const method = (config.method || 'get').toLowerCase() + const requestAad = buildRequestAad(config) + + if (shouldEncryptQuery(config, transportPolicy) && (config.params || method === 'get' || method === 'delete')) { + const queryEnvelope = await encryptPayloadText( + transportContext, + JSON.stringify(normalizePlainPayload(config.params)), + requestAad + ) + config.params = { __enc: encodeQueryEnvelope(queryEnvelope) } + if (buildQueryUrlLength(config.url, config.params) > Number(transportPolicy.maxEncryptedGetUrlLength || 4096)) { + throw new Error('当前GET/DELETE请求参数加密后长度超限,请改用POST请求或精简查询条件') + } + } + + if (method === 'post' || method === 'put' || method === 'patch' || method === 'delete') { + const plainText = stringifyPayload(config.data) + const bodyEnvelope = await encryptPayloadText(transportContext, plainText, requestAad) + if (contentType.includes('application/x-www-form-urlencoded')) { + config.data = encodeFormEnvelope(bodyEnvelope) + } else { + config.data = bodyEnvelope + setHeaderValue(config.headers, 'Content-Type', 'application/json;charset=utf-8') + } + } + + setHeaderValue(config.headers, TRANSPORT_ENABLE_HEADER, '1') + setHeaderValue(config.headers, TRANSPORT_KEY_ID_HEADER, transportContext.kid) + config.__transportCryptoContext = transportContext + config.__transportCryptoEnabledForRequest = true + return config +} + +/** + * 清空当前缓存的公钥元数据。 + * + * @returns {void} + */ +export function invalidateTransportKeyMeta() { + cachedKeyMeta = null + inflightKeyMetaPromise = null + cache.session.remove(TRANSPORT_KEY_META_CACHE_KEY) +} + +/** + * 将被加密改写过的请求恢复为原始形态。 + * + * @param {Object} config Axios 请求配置 + * @returns {Object} 恢复后的请求配置 + */ +export function resetTransportRequestConfig(config) { + const originalSnapshot = config?.__transportOriginalSnapshot + if (!config || !originalSnapshot) { + return config + } + + config.url = originalSnapshot.url + config.params = cloneRequestValue(originalSnapshot.params) + config.data = cloneRequestValue(originalSnapshot.data) + if (originalSnapshot.contentType) { + setHeaderValue(config.headers, 'Content-Type', originalSnapshot.contentType) + } + delete config.__transportCryptoContext + delete config.__transportCryptoContextPromise + delete config.__transportCryptoEnabledForRequest + return config +} + +/** + * 判断错误是否属于可通过刷新公钥重试的场景。 + * + * @param {Object} error 错误对象 + * @returns {boolean} 是否可刷新密钥重试 + */ +export function shouldRetryTransportWithFreshKey(error) { + const responseMsg = error?.response?.data?.msg + const errorMessage = error?.message + return TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(responseMsg) || TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(errorMessage) +} + +/** + * 解密成功响应中的传输层信封。 + * + * @param {Object} response Axios 响应对象 + * @returns {Promise} 解密后的响应对象 + */ +export async function decryptTransportResponse(response) { + if (getHeaderValue(response.headers, ENCRYPTED_RESPONSE_HEADER) !== '1') { + return response + } + if (!shouldEncryptResponse(response.config, getTransportCryptoPolicy())) { + return response + } + const transportPolicy = getTransportCryptoPolicy() + const transportContext = response.config.__transportCryptoContext + if (!transportContext) { + throw new Error('缺少响应解密上下文') + } + + const envelope = parseJsonObject(response.data, '传输层响应信封格式不合法') + validateResponseEnvelope(envelope, response, transportContext, transportPolicy) + const plaintext = await decryptEnvelope(envelope, transportContext) + response.data = JSON.parse(plaintext) + return response +} + +/** + * 尝试解密异常响应中的传输层信封。 + * + * @param {Object} error Axios 错误对象 + * @returns {Promise} 原始或已解密的错误对象 + */ +export async function decryptTransportErrorResponse(error) { + const response = error?.response + if (!response || getHeaderValue(response.headers, ENCRYPTED_RESPONSE_HEADER) !== '1') { + return error + } + if (!shouldEncryptResponse(response.config || {}, getTransportCryptoPolicy())) { + return error + } + const transportPolicy = getTransportCryptoPolicy() + const transportContext = response.config?.__transportCryptoContext + if (!transportContext) { + return error + } + + try { + const envelope = parseJsonObject(response.data, '传输层响应信封格式不合法') + validateResponseEnvelope(envelope, response, transportContext, transportPolicy) + const plaintext = await decryptEnvelope(envelope, transportContext) + response.data = JSON.parse(plaintext) + } catch (decryptError) { + console.error(decryptError) + } + return error +} diff --git a/shierfeng-fastapi-frontend/src/utils/transportCryptoPolicy.js b/shierfeng-fastapi-frontend/src/utils/transportCryptoPolicy.js new file mode 100644 index 0000000..b0b3783 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/transportCryptoPolicy.js @@ -0,0 +1,368 @@ +import axios from 'axios' + +import cache from '@/plugins/cache' + +const TRANSPORT_BASE_URL = import.meta.env.VITE_APP_BASE_API +const EXCLUDED_URL_PATTERNS = [ + '/transport/crypto/frontend-config', + '/transport/crypto/public-key', + '/common/download', + '/common/download/resource' +] +const TRANSPORT_FRONTEND_CONFIG_CACHE_KEY = 'transportCryptoFrontendConfig' +const TRANSPORT_FRONTEND_CONFIG_URL = '/transport/crypto/frontend-config' +const TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS = 60 +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = '1' +const DEFAULT_REQUEST_ENVELOPE_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +const DEFAULT_RESPONSE_ENVELOPE_ALGORITHM = 'AES_256_GCM' +const DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH = 4096 + +const transportPolicyClient = axios.create({ + baseURL: TRANSPORT_BASE_URL, + timeout: 10000 +}) + +let cachedTransportPolicy = null +let inflightTransportPolicyPromise = null + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000) +} + +/** + * 判断请求地址是否命中固定排除名单。 + * + * @param {string} url 请求地址 + * @returns {boolean} 是否命中排除规则 + */ +function matchExcludedUrl(url = '') { + return EXCLUDED_URL_PATTERNS.some(pattern => url.includes(pattern)) +} + +/** + * 判断路径是否匹配指定前缀集合。 + * + * @param {string} path 待匹配路径 + * @param {string[]} pathPatterns 路径前缀集合 + * @returns {boolean} 是否匹配成功 + */ +function matchPathPrefix(path = '', pathPatterns = []) { + return pathPatterns.some(pattern => path === pattern || path.startsWith(`${pattern}/`)) +} + +/** + * 读取请求头中的指定字段,兼容原生对象与 Headers 实例。 + * + * @param {Object|Headers} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined + } + if (typeof headers.get === 'function') { + return headers.get(name) + } + return headers[name] ?? headers[name.toLowerCase()] +} + +/** + * 解析基础 API 地址对应的路径前缀。 + * + * @returns {string} 基础路径前缀 + */ +function getBaseApiPath() { + if (!TRANSPORT_BASE_URL) { + return '' + } + if (TRANSPORT_BASE_URL.startsWith('http://') || TRANSPORT_BASE_URL.startsWith('https://')) { + const baseApiPath = new URL(TRANSPORT_BASE_URL).pathname + return baseApiPath === '/' ? '' : baseApiPath + } + return TRANSPORT_BASE_URL +} + +/** + * 计算用于策略匹配的标准请求路径。 + * + * @param {string} url 请求地址 + * @returns {string} 标准化请求路径 + */ +function getRequestPath(url = '') { + const baseApiPath = getBaseApiPath() + const normalizedUrl = String(url || '') + + let pathname = normalizedUrl + if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) { + pathname = new URL(normalizedUrl).pathname + } else { + pathname = normalizedUrl.split('?')[0] || '/' + } + + if (baseApiPath && pathname.startsWith(baseApiPath)) { + const normalizedPath = pathname.slice(baseApiPath.length) + return normalizedPath || '/' + } + return pathname || '/' +} + +/** + * 标准化后端下发的路径数组。 + * + * @param {Array} paths 原始路径集合 + * @returns {string[]} 标准化后的路径列表 + */ +function normalizePaths(paths) { + if (!Array.isArray(paths)) { + return [] + } + return paths.map(path => String(path || '').trim()).filter(Boolean) +} + +/** + * 将后端配置响应转换为前端统一的策略对象。 + * + * @param {Object} payload 后端返回配置 + * @returns {Object} 标准化后的策略对象 + */ +function normalizeTransportPolicy(payload) { + return { + transportCryptoEnabled: Boolean(payload?.transportCryptoEnabled), + transportCryptoMode: String(payload?.transportCryptoMode || 'off'), + transportCryptoActive: Boolean(payload?.transportCryptoActive), + envelopeVersion: String(payload?.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION), + publicKeyUrl: String(payload?.publicKeyUrl || '/transport/crypto/public-key'), + requestEnvelopeAlgorithm: String(payload?.requestEnvelopeAlgorithm || DEFAULT_REQUEST_ENVELOPE_ALGORITHM), + responseEnvelopeAlgorithm: String(payload?.responseEnvelopeAlgorithm || DEFAULT_RESPONSE_ENVELOPE_ALGORITHM), + enabledPaths: normalizePaths(payload?.enabledPaths), + requiredPaths: normalizePaths(payload?.requiredPaths), + excludePaths: normalizePaths(payload?.excludePaths), + maxEncryptedGetUrlLength: Number(payload?.maxEncryptedGetUrlLength || DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH), + configExpireAt: Number(payload?.configExpireAt || 0), + retryAt: Number(payload?.retryAt || payload?.configExpireAt || 0) + } +} + +/** + * 构建无法获取后端配置时的本地兜底策略。 + * + * @returns {Object} 明文回退策略 + */ +function buildFallbackTransportPolicy() { + const nowTimestamp = getNowTimestamp() + return { + transportCryptoEnabled: false, + transportCryptoMode: 'off', + transportCryptoActive: false, + envelopeVersion: DEFAULT_TRANSPORT_ENVELOPE_VERSION, + publicKeyUrl: '/transport/crypto/public-key', + requestEnvelopeAlgorithm: DEFAULT_REQUEST_ENVELOPE_ALGORITHM, + responseEnvelopeAlgorithm: DEFAULT_RESPONSE_ENVELOPE_ALGORITHM, + enabledPaths: [], + requiredPaths: [], + excludePaths: [...EXCLUDED_URL_PATTERNS], + maxEncryptedGetUrlLength: DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH, + configExpireAt: nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + retryAt: nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS + } +} + +/** + * 基于旧策略构建短期可重试策略。 + * + * @param {Object} policy 旧的策略对象 + * @returns {Object} 可重试策略 + */ +function buildRetryableTransportPolicy(policy) { + const normalizedPolicy = normalizeTransportPolicy(policy) + const retryAt = getNowTimestamp() + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS + return { + ...normalizedPolicy, + retryAt + } +} + +/** + * 判断策略是否仍处于可用期内。 + * + * @param {Object} policy 待校验策略 + * @returns {boolean} 是否可用 + */ +function isUsableTransportPolicy(policy) { + if (!policy) { + return false + } + if (!policy.publicKeyUrl) { + return false + } + if (!policy.retryAt) { + return false + } + return policy.retryAt > getNowTimestamp() +} + +/** + * 从会话缓存读取最近一次持久化策略。 + * + * @returns {Object|null} 缓存策略 + */ +function loadPersistedTransportPolicy() { + const persistedTransportPolicy = cache.session.getJSON(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY) + if (!persistedTransportPolicy) { + return null + } + return normalizeTransportPolicy(persistedTransportPolicy) +} + +/** + * 获取当前生效的传输加密策略。 + * + * @returns {Object} 当前策略对象 + */ +export function getTransportCryptoPolicy() { + return cachedTransportPolicy || buildFallbackTransportPolicy() +} + +/** + * 清空当前策略缓存与会话缓存。 + * + * @returns {void} + */ +export function invalidateTransportCryptoPolicy() { + cachedTransportPolicy = null + inflightTransportPolicyPromise = null + cache.session.remove(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY) +} + +/** + * 确保本地已加载一份可用的传输加密策略。 + * + * @param {boolean} forceRefresh 是否强制刷新策略 + * @returns {Promise} 当前可用策略 + */ +export async function ensureTransportCryptoPolicyLoaded(forceRefresh = false) { + if (!forceRefresh && !cachedTransportPolicy) { + const persistedTransportPolicy = loadPersistedTransportPolicy() + if (isUsableTransportPolicy(persistedTransportPolicy)) { + cachedTransportPolicy = persistedTransportPolicy + } + } + + if (!forceRefresh && isUsableTransportPolicy(cachedTransportPolicy)) { + return cachedTransportPolicy + } + + if (inflightTransportPolicyPromise) { + return inflightTransportPolicyPromise + } + + inflightTransportPolicyPromise = transportPolicyClient.get(TRANSPORT_FRONTEND_CONFIG_URL).then(response => { + const payload = normalizeTransportPolicy(response?.data?.data || {}) + cachedTransportPolicy = payload + cache.session.setJSON(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, payload) + inflightTransportPolicyPromise = null + return cachedTransportPolicy + }).catch(error => { + const staleTransportPolicy = cachedTransportPolicy || loadPersistedTransportPolicy() + inflightTransportPolicyPromise = null + cachedTransportPolicy = staleTransportPolicy + ? buildRetryableTransportPolicy(staleTransportPolicy) + : buildFallbackTransportPolicy() + cache.session.setJSON(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, cachedTransportPolicy) + if (staleTransportPolicy) { + console.warn('加载传输加密前端配置失败,当前继续沿用最近一次后端策略', error) + } else { + console.warn('加载传输加密前端配置失败,当前回退为明文请求策略', error) + } + return cachedTransportPolicy + }) + + return inflightTransportPolicyPromise +} + +/** + * 判断当前请求是否需要执行请求加密。 + * + * @param {Object} config 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否需要加密 + */ +export function shouldEncryptRequest(config, transportPolicy = getTransportCryptoPolicy()) { + if (!transportPolicy.transportCryptoActive) { + return false + } + const requestPath = getRequestPath(config.url) + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false + } + if ((transportPolicy.enabledPaths || []).length && !matchPathPrefix(requestPath, transportPolicy.enabledPaths || [])) { + return false + } + if ((config.headers || {}).encrypt === false) { + return false + } + if (matchExcludedUrl(config.url)) { + return false + } + if (config.responseType === 'blob' || config.responseType === 'arraybuffer') { + return false + } + const contentType = getHeaderValue(config.headers, 'Content-Type') || '' + if (contentType.includes('multipart/form-data')) { + return false + } + return true +} + +/** + * 判断当前响应是否需要执行自动解密。 + * + * @param {Object} config 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否需要解密 + */ +export function shouldEncryptResponse(config, transportPolicy = getTransportCryptoPolicy()) { + const requestPath = getRequestPath(config.url) + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false + } + if ((transportPolicy.enabledPaths || []).length && !matchPathPrefix(requestPath, transportPolicy.enabledPaths || [])) { + return false + } + if ((config.headers || {}).encryptResponse === false) { + return false + } + if (matchExcludedUrl(config.url)) { + return false + } + if (config.responseType === 'blob' || config.responseType === 'arraybuffer') { + return false + } + if (config.__transportCryptoEnabledForRequest === true) { + return true + } + if (config.__transportCryptoEnabledForRequest === false) { + return false + } + return transportPolicy.transportCryptoActive +} + +/** + * 判断查询参数是否需要走加密信封流程。 + * + * @param {Object} config 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用查询参数加密 + */ +export function shouldEncryptQuery(config, transportPolicy = getTransportCryptoPolicy()) { + if ((config.headers || {}).encryptQuery === false) { + return false + } + return shouldEncryptRequest(config, transportPolicy) +} diff --git a/shierfeng-fastapi-frontend/src/utils/validate.js b/shierfeng-fastapi-frontend/src/utils/validate.js new file mode 100644 index 0000000..6a4c0c5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/utils/validate.js @@ -0,0 +1,114 @@ +/** + * 路径匹配器 + * @param {string} pattern + * @param {string} path + * @returns {Boolean} + */ +export function isPathMatch(pattern, path) { + const regexPattern = pattern.replace(/\//g, '\\/').replace(/\*\*/g, '.*').replace(/\*/g, '[^\\/]*') + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(path) +} + +/** + * 判断value字符串是否为空 + * @param {string} value + * @returns {Boolean} + */ +export function isEmpty(value) { + if (value == null || value == "" || value == undefined || value == "undefined") { + return true + } + return false +} + +/** + * 判断url是否是http或https + * @param {string} url + * @returns {Boolean} + */ +export function isHttp(url) { + return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1 +} + +/** + * 判断path是否为外链 + * @param {string} path + * @returns {Boolean} + */ +export function isExternal(path) { + return /^(https?:|mailto:|tel:)/.test(path) +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validUsername(str) { + const valid_map = ['admin', 'editor'] + return valid_map.indexOf(str.trim()) >= 0 +} + +/** + * @param {string} url + * @returns {Boolean} + */ +export function validURL(url) { + const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/ + return reg.test(url) +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validLowerCase(str) { + const reg = /^[a-z]+$/ + return reg.test(str) +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validUpperCase(str) { + const reg = /^[A-Z]+$/ + return reg.test(str) +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validAlphabets(str) { + const reg = /^[A-Za-z]+$/ + return reg.test(str) +} + +/** + * @param {string} email + * @returns {Boolean} + */ +export function validEmail(email) { + const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + return reg.test(email) +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function isString(str) { + return typeof str === 'string' || str instanceof String +} + +/** + * @param {Array} arg + * @returns {Boolean} + */ +export function isArray(arg) { + if (typeof Array.isArray === 'undefined') { + return Object.prototype.toString.call(arg) === '[object Array]' + } + return Array.isArray(arg) +} diff --git a/shierfeng-fastapi-frontend/src/views/ai/chat/components/AiMessage.vue b/shierfeng-fastapi-frontend/src/views/ai/chat/components/AiMessage.vue new file mode 100644 index 0000000..b233d3b --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/ai/chat/components/AiMessage.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/ai/chat/index.vue b/shierfeng-fastapi-frontend/src/views/ai/chat/index.vue new file mode 100644 index 0000000..7b44d26 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/ai/chat/index.vue @@ -0,0 +1,1338 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/ai/model/index.vue b/shierfeng-fastapi-frontend/src/views/ai/model/index.vue new file mode 100644 index 0000000..641fbfa --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/ai/model/index.vue @@ -0,0 +1,473 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/dashboard/editable-link-group.vue b/shierfeng-fastapi-frontend/src/views/dashboard/editable-link-group.vue new file mode 100644 index 0000000..2f116fb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/dashboard/editable-link-group.vue @@ -0,0 +1,68 @@ + + + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/views/dashboard/index.vue b/shierfeng-fastapi-frontend/src/views/dashboard/index.vue new file mode 100644 index 0000000..f6f4473 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/dashboard/index.vue @@ -0,0 +1,752 @@ + + + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/error/401.vue b/shierfeng-fastapi-frontend/src/views/error/401.vue new file mode 100644 index 0000000..1ba3792 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/error/401.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/error/404.vue b/shierfeng-fastapi-frontend/src/views/error/404.vue new file mode 100644 index 0000000..f205303 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/error/404.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/login.vue b/shierfeng-fastapi-frontend/src/views/login.vue new file mode 100644 index 0000000..52b0406 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/login.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/cache/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/cache/index.vue new file mode 100644 index 0000000..8926445 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/cache/index.vue @@ -0,0 +1,132 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/cache/list.vue b/shierfeng-fastapi-frontend/src/views/monitor/cache/list.vue new file mode 100644 index 0000000..1696f3a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/cache/list.vue @@ -0,0 +1,246 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/druid/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/druid/index.vue new file mode 100644 index 0000000..91b7e38 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/druid/index.vue @@ -0,0 +1,14 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/job/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/job/index.vue new file mode 100644 index 0000000..c7c67fd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/job/index.vue @@ -0,0 +1,530 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/job/log.vue b/shierfeng-fastapi-frontend/src/views/monitor/job/log.vue new file mode 100644 index 0000000..757e2db --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/job/log.vue @@ -0,0 +1,295 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/logininfor/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/logininfor/index.vue new file mode 100644 index 0000000..7480b5e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/logininfor/index.vue @@ -0,0 +1,225 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/online/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/online/index.vue new file mode 100644 index 0000000..eb17ebc --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/online/index.vue @@ -0,0 +1,106 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/operlog/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/operlog/index.vue new file mode 100644 index 0000000..0d55881 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/operlog/index.vue @@ -0,0 +1,301 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/server/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/server/index.vue new file mode 100644 index 0000000..ad9d42d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/server/index.vue @@ -0,0 +1,183 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/monitor/transportCrypto/index.vue b/shierfeng-fastapi-frontend/src/views/monitor/transportCrypto/index.vue new file mode 100644 index 0000000..c4e88a4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/monitor/transportCrypto/index.vue @@ -0,0 +1,1034 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/redirect/index.vue b/shierfeng-fastapi-frontend/src/views/redirect/index.vue new file mode 100644 index 0000000..a469960 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/redirect/index.vue @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/views/register.vue b/shierfeng-fastapi-frontend/src/views/register.vue new file mode 100644 index 0000000..455f6c3 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/register.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/config/index.vue b/shierfeng-fastapi-frontend/src/views/system/config/index.vue new file mode 100644 index 0000000..f904e08 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/config/index.vue @@ -0,0 +1,310 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/dept/index.vue b/shierfeng-fastapi-frontend/src/views/system/dept/index.vue new file mode 100644 index 0000000..7a65d6d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/dept/index.vue @@ -0,0 +1,274 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/dict/data.vue b/shierfeng-fastapi-frontend/src/views/system/dict/data.vue new file mode 100644 index 0000000..0d8a99a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/dict/data.vue @@ -0,0 +1,350 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/dict/index.vue b/shierfeng-fastapi-frontend/src/views/system/dict/index.vue new file mode 100644 index 0000000..8c600f5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/dict/index.vue @@ -0,0 +1,312 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/menu/index.vue b/shierfeng-fastapi-frontend/src/views/system/menu/index.vue new file mode 100644 index 0000000..b0eb1fe --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/menu/index.vue @@ -0,0 +1,441 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/notice/index.vue b/shierfeng-fastapi-frontend/src/views/system/notice/index.vue new file mode 100644 index 0000000..dd6364e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/notice/index.vue @@ -0,0 +1,283 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/post/index.vue b/shierfeng-fastapi-frontend/src/views/system/post/index.vue new file mode 100644 index 0000000..53db828 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/post/index.vue @@ -0,0 +1,277 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/role/authUser.vue b/shierfeng-fastapi-frontend/src/views/system/role/authUser.vue new file mode 100644 index 0000000..66b5f5e --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/role/authUser.vue @@ -0,0 +1,172 @@ + + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/role/index.vue b/shierfeng-fastapi-frontend/src/views/system/role/index.vue new file mode 100644 index 0000000..99808ca --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/role/index.vue @@ -0,0 +1,559 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/role/selectUser.vue b/shierfeng-fastapi-frontend/src/views/system/role/selectUser.vue new file mode 100644 index 0000000..9269f5c --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/role/selectUser.vue @@ -0,0 +1,138 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/user/authRole.vue b/shierfeng-fastapi-frontend/src/views/system/user/authRole.vue new file mode 100644 index 0000000..93516b4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/user/authRole.vue @@ -0,0 +1,123 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/user/index.vue b/shierfeng-fastapi-frontend/src/views/system/user/index.vue new file mode 100644 index 0000000..d35dbdf --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/user/index.vue @@ -0,0 +1,901 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/user/profile/index.vue b/shierfeng-fastapi-frontend/src/views/system/user/profile/index.vue new file mode 100644 index 0000000..26f4bd4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/user/profile/index.vue @@ -0,0 +1,94 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/user/profile/resetPwd.vue b/shierfeng-fastapi-frontend/src/views/system/user/profile/resetPwd.vue new file mode 100644 index 0000000..96daef3 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/user/profile/resetPwd.vue @@ -0,0 +1,57 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/system/user/profile/userAvatar.vue b/shierfeng-fastapi-frontend/src/views/system/user/profile/userAvatar.vue new file mode 100644 index 0000000..3b39636 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/user/profile/userAvatar.vue @@ -0,0 +1,171 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/views/system/user/profile/userInfo.vue b/shierfeng-fastapi-frontend/src/views/system/user/profile/userInfo.vue new file mode 100644 index 0000000..2737fe1 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/system/user/profile/userInfo.vue @@ -0,0 +1,67 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/build/CodeTypeDialog.vue b/shierfeng-fastapi-frontend/src/views/tool/build/CodeTypeDialog.vue new file mode 100755 index 0000000..de0beb7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/build/CodeTypeDialog.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/views/tool/build/DraggableItem.vue b/shierfeng-fastapi-frontend/src/views/tool/build/DraggableItem.vue new file mode 100755 index 0000000..927aafb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/build/DraggableItem.vue @@ -0,0 +1,68 @@ + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/views/tool/build/IconsDialog.vue b/shierfeng-fastapi-frontend/src/views/tool/build/IconsDialog.vue new file mode 100755 index 0000000..98d9c13 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/build/IconsDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/build/RightPanel.vue b/shierfeng-fastapi-frontend/src/views/tool/build/RightPanel.vue new file mode 100755 index 0000000..5fe80fb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/build/RightPanel.vue @@ -0,0 +1,906 @@ + + + + + \ No newline at end of file diff --git a/shierfeng-fastapi-frontend/src/views/tool/build/TreeNodeDialog.vue b/shierfeng-fastapi-frontend/src/views/tool/build/TreeNodeDialog.vue new file mode 100755 index 0000000..372d3af --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/build/TreeNodeDialog.vue @@ -0,0 +1,93 @@ + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/build/index.vue b/shierfeng-fastapi-frontend/src/views/tool/build/index.vue new file mode 100755 index 0000000..64a9317 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/build/index.vue @@ -0,0 +1,660 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/gen/basicInfoForm.vue b/shierfeng-fastapi-frontend/src/views/tool/gen/basicInfoForm.vue new file mode 100644 index 0000000..39c8515 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/gen/basicInfoForm.vue @@ -0,0 +1,48 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/gen/createTable.vue b/shierfeng-fastapi-frontend/src/views/tool/gen/createTable.vue new file mode 100644 index 0000000..00fdfdb --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/gen/createTable.vue @@ -0,0 +1,46 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/gen/editTable.vue b/shierfeng-fastapi-frontend/src/views/tool/gen/editTable.vue new file mode 100644 index 0000000..06e4ba5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/gen/editTable.vue @@ -0,0 +1,220 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/gen/genInfoForm.vue b/shierfeng-fastapi-frontend/src/views/tool/gen/genInfoForm.vue new file mode 100644 index 0000000..c0ba7f7 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/gen/genInfoForm.vue @@ -0,0 +1,306 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/gen/importTable.vue b/shierfeng-fastapi-frontend/src/views/tool/gen/importTable.vue new file mode 100644 index 0000000..d0115c4 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/gen/importTable.vue @@ -0,0 +1,126 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/gen/index.vue b/shierfeng-fastapi-frontend/src/views/tool/gen/index.vue new file mode 100644 index 0000000..395c834 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/gen/index.vue @@ -0,0 +1,313 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/tool/swagger/index.vue b/shierfeng-fastapi-frontend/src/views/tool/swagger/index.vue new file mode 100644 index 0000000..d744aff --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/tool/swagger/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/shierfeng-fastapi-frontend/vite.config.js b/shierfeng-fastapi-frontend/vite.config.js new file mode 100644 index 0000000..10ef100 --- /dev/null +++ b/shierfeng-fastapi-frontend/vite.config.js @@ -0,0 +1,73 @@ +import { defineConfig, loadEnv } from 'vite' +import path from 'path' +import createVitePlugins from './vite/plugins' + +// https://vitejs.dev/config/ +export default defineConfig(({ mode, command }) => { + const env = loadEnv(mode, process.cwd()) + const { VITE_APP_ENV } = env + return { + // 部署生产环境和开发环境下的URL。 + // 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上 + // 如果应用被部署在一个子路径上,需要用此选项指定子路径。例如部署在 https://example.com/admin/ 时,将 baseUrl 设置为 /admin/。 + base: VITE_APP_ENV === 'production' ? '/' : '/', + plugins: createVitePlugins(env, command === 'build'), + resolve: { + // https://cn.vitejs.dev/config/#resolve-alias + alias: { + // 设置路径 + '~': path.resolve(__dirname, './'), + // 设置别名 + '@': path.resolve(__dirname, './src') + }, + // https://cn.vitejs.dev/config/#resolve-extensions + extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'] + }, + // 打包配置 + build: { + // https://vite.dev/config/build-options.html + sourcemap: command === 'build' ? false : 'inline', + outDir: 'dist', + assetsDir: 'assets', + chunkSizeWarningLimit: 2000, + rollupOptions: { + output: { + chunkFileNames: 'static/js/[name]-[hash].js', + entryFileNames: 'static/js/[name]-[hash].js', + assetFileNames: 'static/[ext]/[name]-[hash].[ext]' + } + } + }, + // vite 相关配置 + server: { + port: 80, + host: true, + open: true, + proxy: { + // https://cn.vitejs.dev/config/#server-proxy + '/dev-api': { + target: 'http://127.0.0.1:9099', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/dev-api/, '') + } + } + }, + //fix:error:stdin>:7356:1: warning: "@charset" must be the first rule in the file + css: { + postcss: { + plugins: [ + { + postcssPlugin: 'internal:charset-removal', + AtRule: { + charset: (atRule) => { + if (atRule.name === 'charset') { + atRule.remove(); + } + } + } + } + ] + } + } + } +}) diff --git a/shierfeng-fastapi-frontend/vite/plugins/auto-import.js b/shierfeng-fastapi-frontend/vite/plugins/auto-import.js new file mode 100644 index 0000000..a5d3576 --- /dev/null +++ b/shierfeng-fastapi-frontend/vite/plugins/auto-import.js @@ -0,0 +1,12 @@ +import autoImport from 'unplugin-auto-import/vite' + +export default function createAutoImport() { + return autoImport({ + imports: [ + 'vue', + 'vue-router', + 'pinia' + ], + dts: false + }) +} diff --git a/shierfeng-fastapi-frontend/vite/plugins/compression.js b/shierfeng-fastapi-frontend/vite/plugins/compression.js new file mode 100644 index 0000000..f473c57 --- /dev/null +++ b/shierfeng-fastapi-frontend/vite/plugins/compression.js @@ -0,0 +1,28 @@ +import compression from 'vite-plugin-compression' + +export default function createCompression(env) { + const { VITE_BUILD_COMPRESS } = env + const plugin = [] + if (VITE_BUILD_COMPRESS) { + const compressList = VITE_BUILD_COMPRESS.split(',') + if (compressList.includes('gzip')) { + // 开启 gzip 后,部署环境需同步启用静态文件解压支持。 + plugin.push( + compression({ + ext: '.gz', + deleteOriginFile: false + }) + ) + } + if (compressList.includes('brotli')) { + plugin.push( + compression({ + ext: '.br', + algorithm: 'brotliCompress', + deleteOriginFile: false + }) + ) + } + } + return plugin +} diff --git a/shierfeng-fastapi-frontend/vite/plugins/index.js b/shierfeng-fastapi-frontend/vite/plugins/index.js new file mode 100644 index 0000000..eb9e9dd --- /dev/null +++ b/shierfeng-fastapi-frontend/vite/plugins/index.js @@ -0,0 +1,43 @@ +import vue from '@vitejs/plugin-vue' +import monacoEditorEsmPlugin from 'vite-plugin-monaco-editor-esm' + +import createAutoImport from './auto-import' +import createSvgIcon from './svg-icon' +import createCompression from './compression' +import createSetupExtend from './setup-extend' + +const monacoWorkers = [ + { + label: 'editorWorkerService', + entry: 'monaco-editor/esm/vs/editor/editor.worker.js' + }, + { + label: 'css', + entry: 'monaco-editor/esm/vs/language/css/css.worker.js' + }, + { + label: 'html', + entry: 'monaco-editor/esm/vs/language/html/html.worker.js' + }, + { + label: 'json', + entry: 'monaco-editor/esm/vs/language/json/json.worker.js' + }, + { + label: 'typescript', + entry: 'monaco-editor/esm/vs/language/typescript/ts.worker.js' + } +] + +export default function createVitePlugins(viteEnv, isBuild = false) { + const vitePlugins = [vue()] + vitePlugins.push(createAutoImport()) + vitePlugins.push(createSetupExtend()) + vitePlugins.push(monacoEditorEsmPlugin({ + languageWorkers: [], + customWorkers: monacoWorkers + })) + vitePlugins.push(createSvgIcon(isBuild)) + isBuild && vitePlugins.push(...createCompression(viteEnv)) + return vitePlugins +} diff --git a/shierfeng-fastapi-frontend/vite/plugins/setup-extend.js b/shierfeng-fastapi-frontend/vite/plugins/setup-extend.js new file mode 100644 index 0000000..ed8342e --- /dev/null +++ b/shierfeng-fastapi-frontend/vite/plugins/setup-extend.js @@ -0,0 +1,5 @@ +import setupExtend from 'unplugin-vue-setup-extend-plus/vite' + +export default function createSetupExtend() { + return setupExtend({}) +} diff --git a/shierfeng-fastapi-frontend/vite/plugins/svg-icon.js b/shierfeng-fastapi-frontend/vite/plugins/svg-icon.js new file mode 100644 index 0000000..30a4140 --- /dev/null +++ b/shierfeng-fastapi-frontend/vite/plugins/svg-icon.js @@ -0,0 +1,10 @@ +import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' +import path from 'path' + +export default function createSvgIcon(isBuild) { + return createSvgIconsPlugin({ + iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')], + symbolId: 'icon-[dir]-[name]', + svgoOptions: isBuild + }) +} diff --git a/shierfeng-fastapi-test/README.md b/shierfeng-fastapi-test/README.md new file mode 100644 index 0000000..63fc0e1 --- /dev/null +++ b/shierfeng-fastapi-test/README.md @@ -0,0 +1,115 @@ +# ShiErFeng 项目测试套件 + +这是一个为施尔丰项目创建的完整测试套件,使用 Playwright 进行端到端测试。测试环境已禁用验证码功能,以简化测试流程。 + +## 功能特性 + +- 使用默认方式手动启动前后端服务或`Docker Compose`自动启动项目前后端服务 +- 测试环境已禁用验证码功能 +- 验证登录流程和认证机制 +- 测试所有受保护的页面功能 +- 验证未登录用户访问受保护页面时的重定向行为 + +## 依赖安装 + +```bash +pip install -r requirements.txt +playwright install +``` + +## 使用方法 + +### 方式一:默认方法 + +#### 启动前端 + +```bash +cd shierfeng-fastapi-frontend +npm install +npm run dev +``` + +#### 启动后端 + +```bash +cd shierfeng-fastapi-backend +pip install -r requirements.txt +python app.py --env=dev +``` + +#### 运行测试 + +```bash +cd shierfeng-fastapi-test +pip install -r requirements.txt +python -m pytest -v +``` + +### 方式二:使用Docker + +#### 进入测试目录 + +```bash +cd shierfeng-fastapi-test +``` + +#### 启动 Docker 服务 + +```bash +# MySQL版本 +docker compose -f docker-compose.test.my.yml up -d --build +# PostgreSQL版本 +docker compose -f docker-compose.test.pg.yml up -d --build +``` + +#### 运行测试 + +```bash +pip install -r requirements.txt +python -m pytest -v +``` + +## 测试内容 + +### 登录测试 + +- 验证登录页面正常加载 +- 验证登录流程(测试环境已禁用验证码) +- 测试认证后的页面访问 + +### 页面访问和功能测试 + +- 仪表盘页面 +- 用户管理页面 +- 角色管理页面 +- 菜单管理页面 +- 部门管理页面 +- 岗位管理页面 +- 字典管理页面 +- 参数配置页面 +- 通知公告页面 +- 日志管理页面(操作日志、登录日志) +- 在线用户页面 +- 定时任务页面 +- 服务监控页面 +- 数据监控页面 +- 缓存监控页面 +- 缓存列表页面 +- 代码生成页面 +- 系统接口页面 + +### 认证测试 + +- 验证未登录用户访问受保护页面时被重定向到登录页 +- 验证登录后可以访问受保护页面 + +## 配置说明 + +使用 `docker-compose.test.my.yml`或`docker-compose.test.pg.yml`启动服务,默认前端端口为 `80`,后端端口为 `9099`。测试环境已禁用验证码功能。 + +## 注意事项 + +1. 确保系统已安装 Docker 和 Docker Compose +2. 确保端口 `80` 和 `9099` 未被占用 +3. 首次运行时 Docker 镜像构建可能需要几分钟时间 +4. 测试使用默认管理员账户:用户名 `admin`,密码 `admin123` diff --git a/shierfeng-fastapi-test/common/__init__.py b/shierfeng-fastapi-test/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-test/common/base_page_test.py b/shierfeng-fastapi-test/common/base_page_test.py new file mode 100644 index 0000000..0542a68 --- /dev/null +++ b/shierfeng-fastapi-test/common/base_page_test.py @@ -0,0 +1,87 @@ +import re + +from playwright.async_api._context_manager import PlaywrightContextManager + +from common.login_helper import LoginHelper + + +class BasePageTest: + browser = None + context = None + page = None + token = None + + async def setup(self, playwright: PlaywrightContextManager) -> None: + """初始化浏览器和登录""" + # 首先登录获取token + helper = LoginHelper() + self.token = helper.login(username='admin', password='admin123') + assert self.token is not None, '登录应该成功' + + # 启动浏览器 + self.browser = await playwright.chromium.launch(headless=True) + self.context = await self.browser.new_context() + # 设置认证token + await self.context.add_cookies( + [ + { + 'name': 'Admin-Token', + 'value': self.token, + 'domain': 'localhost', + 'path': '/', + 'httpOnly': False, + 'secure': False, + } + ] + ) + self.page = await self.context.new_page() + + async def teardown(self) -> None: + """清理资源""" + if self.context: + await self.context.close() + if self.browser: + await self.browser.close() + + async def goto_page(self, url: str) -> None: + """访问指定页面""" + await self.page.goto(url) + + async def wait_for_page_title(self, title_text: str, timeout: int = 10000) -> None: + """等待页面标题出现""" + await self.page.wait_for_selector(f'div:has-text("{title_text}")', timeout=timeout) + page_title = await self.page.inner_text(f'div:has-text("{title_text}")') + assert title_text in page_title + + async def wait_for_selector(self, selector: str, timeout: int = 10000) -> None: + """等待选择器出现""" + await self.page.wait_for_selector(selector, timeout=timeout) + + async def query_selector(self, selector: str) -> any: + """查询选择器元素""" + return await self.page.query_selector(selector) + + async def click_button(self, button_text: str) -> None: + """点击按钮""" + button = await self.page.wait_for_selector(f'text="{button_text}"', timeout=5000) + await button.click() + + async def fill_input(self, selector: str, value: str) -> None: + """填充输入框""" + await self.page.fill(selector, value) + + async def get_text_content(self, selector: str) -> str: + """获取元素文本内容""" + element = await self.page.query_selector(selector) + if element: + return await element.text_content() + return None + + async def get_table_total_rows(self) -> int: + """获取表格总行数""" + element = self.page.locator('span.el-pagination__total').first + text = await element.text_content() + + # 提取数字 + number = re.search(r'\d+', text).group() + return int(number) diff --git a/shierfeng-fastapi-test/common/config.py b/shierfeng-fastapi-test/common/config.py new file mode 100644 index 0000000..003ab28 --- /dev/null +++ b/shierfeng-fastapi-test/common/config.py @@ -0,0 +1,3 @@ +class Config: + frontend_url = 'http://localhost:80' + backend_url = 'http://localhost:9099' diff --git a/shierfeng-fastapi-test/common/login_helper.py b/shierfeng-fastapi-test/common/login_helper.py new file mode 100644 index 0000000..9a0b48b --- /dev/null +++ b/shierfeng-fastapi-test/common/login_helper.py @@ -0,0 +1,57 @@ +import requests + +from common.config import Config + + +class LoginHelper: + """ + 登录辅助类 + """ + + def __init__(self, base_url: str = Config.frontend_url) -> None: + self.base_url = base_url + self.session = requests.Session() + + def login(self, username: str = 'admin', password: str = 'admin123', max_retries: int = 3) -> str | None: + """ + 执行登录操作(在测试环境中,验证码已禁用) + """ + for _attempt in range(max_retries): + # 在测试环境中,验证码已禁用,所以直接登录 + login_data = {'username': username, 'password': password} + + headers = {'Content-Type': 'application/x-www-form-urlencoded', 'Referer': f'{self.base_url}/login'} + + response = self.session.post(f'{Config.backend_url}/login', data=login_data, headers=headers) + + http_ok = 200 + if response.status_code == http_ok: + result = response.json() + # 检查登录是否成功 - 可能是 code=200 或 success=true + if result.get('code') == http_ok or result.get('success'): + # 尝试从不同可能的位置获取token + token = result.get('token') # 直接在根级别 + if not token: + token = result.get('data', {}).get('token') # 在data对象内 + + if token: + print('登录成功') + return token + print(f'登录失败: 响应中未找到token - {result}') + else: + print(f'登录失败: {result.get("msg", "未知错误")}') + else: + print(f'登录请求失败: 状态码 {response.status_code}') + + print('登录失败,已达到最大重试次数') + return None + + +# 使用示例 +if __name__ == '__main__': + helper = LoginHelper() + token = helper.login() + if token: + print(f'获取到token: {token}') + else: + print('登录失败') diff --git a/shierfeng-fastapi-test/disable_captcha.sql b/shierfeng-fastapi-test/disable_captcha.sql new file mode 100644 index 0000000..9da9589 --- /dev/null +++ b/shierfeng-fastapi-test/disable_captcha.sql @@ -0,0 +1,4 @@ +-- 用于测试环境的SQL脚本,禁用验证码功能 + +-- 更新验证码配置为禁用状态 +UPDATE sys_config SET config_value = 'false' WHERE config_key = 'sys.account.captchaEnabled'; diff --git a/shierfeng-fastapi-test/docker-compose.test.my.yml b/shierfeng-fastapi-test/docker-compose.test.my.yml new file mode 100644 index 0000000..2fbd8a1 --- /dev/null +++ b/shierfeng-fastapi-test/docker-compose.test.my.yml @@ -0,0 +1,77 @@ +services: + # 前端服务 + shierfeng-frontend: + build: + context: ../shierfeng-fastapi-frontend + dockerfile: Dockerfile + image: shierfeng-frontend:latest + container_name: shierfeng-frontend-test + ports: + - "80:80" + volumes: + - ../shierfeng-fastapi-frontend/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf + depends_on: + - shierfeng-backend-my + networks: + - shierfeng-network-test + + # 后端服务(MySQL版本) + shierfeng-backend-my: + build: + context: ../shierfeng-fastapi-backend + dockerfile: Dockerfile.my + image: shierfeng-backend-my:latest + container_name: shierfeng-backend-my-test + ports: + - "9099:9099" + environment: + TRANSPORT_CRYPTO_ENABLED: "false" + TRANSPORT_CRYPTO_MODE: "off" + depends_on: + shierfeng-mysql: + condition: service_healthy + shierfeng-redis: + condition: service_healthy + networks: + - shierfeng-network-test + + # MySQL服务 + shierfeng-mysql: + image: mysql:8.0 + container_name: shierfeng-mysql-test + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: shierfeng-fastapi + ports: + - "13307:3306" + volumes: + - ../shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:/docker-entrypoint-initdb.d/01-shierfeng-fastapi.sql + - ./disable_captcha.sql:/docker-entrypoint-initdb.d/02-disable-captcha.sql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci --skip-character-set-client-handshake=1 + networks: + - shierfeng-network-test + healthcheck: + test: ["CMD", "bash", "-c", "mysqladmin ping -h localhost -u root -proot && mysql -h localhost -u root -proot -e 'SELECT 1'"] + interval: 5s + timeout: 10s + retries: 30 + + # Redis服务 + shierfeng-redis: + image: redis:latest + container_name: shierfeng-redis-test + ports: + - "16380:6379" + networks: + - shierfeng-network-test + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 10s + retries: 30 + +# 网络配置 +networks: + shierfeng-network-test: + name: shierfeng-network-test + driver: bridge \ No newline at end of file diff --git a/shierfeng-fastapi-test/docker-compose.test.pg.yml b/shierfeng-fastapi-test/docker-compose.test.pg.yml new file mode 100644 index 0000000..3a113c5 --- /dev/null +++ b/shierfeng-fastapi-test/docker-compose.test.pg.yml @@ -0,0 +1,77 @@ +services: + # 前端服务 + shierfeng-frontend: + build: + context: ../shierfeng-fastapi-frontend + dockerfile: Dockerfile + image: shierfeng-frontend:latest + container_name: shierfeng-frontend-test + ports: + - "80:80" + volumes: + - ../shierfeng-fastapi-frontend/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf + depends_on: + - shierfeng-backend-pg + networks: + - shierfeng-network-test + + # 后端服务(PostgreSQL版本) + shierfeng-backend-pg: + build: + context: ../shierfeng-fastapi-backend + dockerfile: Dockerfile.pg + image: shierfeng-backend-pg:latest + container_name: shierfeng-backend-pg-test + ports: + - "9099:9099" + environment: + TRANSPORT_CRYPTO_ENABLED: "false" + TRANSPORT_CRYPTO_MODE: "off" + depends_on: + shierfeng-pg: + condition: service_healthy + shierfeng-redis: + condition: service_healthy + networks: + - shierfeng-network-test + + # PostgreSQL服务 + shierfeng-pg: + image: postgres:14 + container_name: shierfeng-pg-test + environment: + POSTGRES_PASSWORD: root + POSTGRES_DB: shierfeng-fastapi + POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C + ports: + - "15433:5432" + volumes: + - ../shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql:/docker-entrypoint-initdb.d/01-shierfeng-fastapi-pg.sql + - ./disable_captcha.sql:/docker-entrypoint-initdb.d/02-disable-captcha.sql + networks: + - shierfeng-network-test + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres"] + interval: 5s + timeout: 10s + retries: 30 + + # Redis服务 + shierfeng-redis: + image: redis:latest + container_name: shierfeng-redis-test + ports: + - "16381:6379" + networks: + - shierfeng-network-test + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 10s + retries: 30 + +# 网络配置 +networks: + shierfeng-network-test: + name: shierfeng-network-test + driver: bridge \ No newline at end of file diff --git a/shierfeng-fastapi-test/monitor/__init__.py b/shierfeng-fastapi-test/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-test/monitor/test_cache_list.py b/shierfeng-fastapi-test/monitor/test_cache_list.py new file mode 100644 index 0000000..244a1fa --- /dev/null +++ b/shierfeng-fastapi-test/monitor/test_cache_list.py @@ -0,0 +1,81 @@ +import pytest +from playwright.async_api import async_playwright, expect + +from common.base_page_test import BasePageTest +from common.config import Config + + +class CacheListTest(BasePageTest): + """缓存列表测试类""" + + async def test_cache_list(self) -> None: + """测试缓存列表页面""" + await self.page.goto(Config.frontend_url + '/monitor/cacheList') + await self.page.wait_for_load_state('networkidle') + + # 1. 缓存列表 - 点击 sys_config + await self.page.wait_for_selector('text=缓存列表') + + # 等待 sys_config 出现并点击 + sys_config_row = self.page.locator('.el-card', has_text='缓存列表').locator('tr', has_text='sys_config') + await sys_config_row.wait_for() + await sys_config_row.click() + + # 2. 键名列表 - 点击 sys.account.captchaEnabled + await self.page.wait_for_selector('text=键名列表') + + # 等待 sys.account.captchaEnabled 出现并点击 + captcha_row = self.page.locator('.el-card', has_text='键名列表').locator( + 'tr', has_text='sys.account.captchaEnabled' + ) + await captcha_row.wait_for() + await captcha_row.click() + + # 3. 验证缓存内容为 false + await self.page.wait_for_selector('text=缓存内容') + + # 等待数据加载 + await self.page.wait_for_timeout(2000) + + # 获取内容 + # 缓存内容显示在一个 textarea 中 + content_area = self.page.locator('div.el-form-item', has_text='缓存内容:').locator('textarea') + + # 也可以尝试直接获取 .el-form-item__content 的文本,如果 textarea 不可交互 + if await content_area.count() > 0: + # 尝试等待内容不为空 + try: + await expect(content_area).not_to_be_empty(timeout=5000) + except TimeoutError: + pass + + value = await content_area.input_value() + print(f'Cache content value: {value}') + + # 如果为空,可能需要重新点击一下 + if not value: + print('Value is empty, trying to click key again') + await captcha_row.click() + await self.page.wait_for_timeout(2000) + value = await content_area.input_value() + print(f'Cache content value after retry: {value}') + + assert 'false' in value.lower(), f"Expected 'false' in cache content, got: {value}" + else: + # 如果不是 textarea,尝试获取文本 + content_div = self.page.locator('div.el-form-item', has_text='缓存内容:').locator('.el-form-item__content') + value = await content_div.text_content() + print(f'Cache content text: {value}') + assert 'false' in value.lower(), f"Expected 'false' in cache content, got: {value}" + + +@pytest.mark.asyncio +async def test_cache_list_page() -> None: + """测试缓存列表页面功能""" + async with async_playwright() as p: + test_instance = CacheListTest() + await test_instance.setup(p) + try: + await test_instance.test_cache_list() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/monitor/test_cache_monitor.py b/shierfeng-fastapi-test/monitor/test_cache_monitor.py new file mode 100644 index 0000000..1b550bb --- /dev/null +++ b/shierfeng-fastapi-test/monitor/test_cache_monitor.py @@ -0,0 +1,48 @@ +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class CacheMonitorTest(BasePageTest): + """缓存监控测试类""" + + async def test_cache_monitor(self) -> None: + """测试缓存监控页面""" + await self.page.goto(Config.frontend_url + '/monitor/cache') + await self.page.wait_for_load_state('networkidle') + + # 验证基本信息 + await self.page.wait_for_selector('text=基本信息') + await self.page.wait_for_selector('text=Redis版本') + await self.page.wait_for_selector('text=运行模式') + + # 验证命令统计 + await self.page.wait_for_selector('text=命令统计') + + # 验证内存信息 + await self.page.wait_for_selector('text=内存信息') + + # 验证端口为 6379 + port_row = self.page.locator('tr', has_text='端口') + try: + await port_row.wait_for(timeout=5000) + text = await port_row.text_content() + assert '6379' in text, f"Expected port '6379' in row, but got: {text}" + except Exception: + print("Warning: '端口' row not found, checking page content") + content = await self.page.content() + assert '6379' in content, "Port '6379' not found in page content" + + +@pytest.mark.asyncio +async def test_cache_monitor_page() -> None: + """测试缓存监控页面功能""" + async with async_playwright() as p: + test_instance = CacheMonitorTest() + await test_instance.setup(p) + try: + await test_instance.test_cache_monitor() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/monitor/test_job_management.py b/shierfeng-fastapi-test/monitor/test_job_management.py new file mode 100644 index 0000000..db16267 --- /dev/null +++ b/shierfeng-fastapi-test/monitor/test_job_management.py @@ -0,0 +1,185 @@ +from datetime import datetime + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class JobManagementTest(BasePageTest): + """定时任务管理测试""" + + def generate_job_data(self) -> dict: + """生成测试数据""" + timestamp = datetime.now().strftime('%H%M%S') + return { + 'job_name': f'测试任务_{timestamp}', + 'job_group': '默认', + 'job_executor': '进程池', + 'invoke_target': 'module_task.scheduler_test.job', # 必须为这个值 + 'cron_expression': '0/2 * * * * ?', # 每2秒执行一次 + 'new_invoke_target': 'module_task.scheduler_test.job', # 修改时也用这个 + 'job_name_edit': f'测试任务_{timestamp}_edit', + } + + async def create_job( + self, job_name: str, job_group: str, job_executor: str, invoke_target: str, cron_expression: str + ) -> None: + """创建定时任务""" + await self.page.get_by_role('button', name='新增').click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写表单 + await dialog.get_by_role('textbox', name='任务名称').fill(job_name) + + # 选择任务分组 + await dialog.locator("label:has-text('任务分组') + div .el-select").click() + await self.page.get_by_role('option', name=job_group).click() + + # 选择任务执行器 + await dialog.locator("label:has-text('任务执行器') + div .el-select").click() + await self.page.get_by_role('option', name=job_executor).click() + + # 填写调用方法 + await dialog.get_by_role('textbox', name='调用方法').fill(invoke_target) + + # 填写cron表达式 + await dialog.get_by_role('textbox', name='cron表达式').fill(cron_expression) + + # 策略:如果"失败"策略等有默认值,就不管了 + + await dialog.get_by_role('button', name='确 定').click() + await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + + async def search_job(self, job_name: str) -> None: + """搜索任务""" + # 我们可以限制在 form 里搜索 + form = self.page.locator('form').first + await form.get_by_role('textbox', name='任务名称').fill(job_name) + await self.page.get_by_role('button', name='搜索').click() + await self.page.wait_for_timeout(1000) # 等待搜索结果 + + async def edit_job(self, job_name: str, new_invoke_target: str) -> None: + """修改任务""" + await self.search_job(job_name) + + # 点击修改 (在操作列) + row = self.page.locator('tbody tr').first + # 操作列按钮顺序: 修改, 删除, 执行一次, 详细, 日志 + await row.locator('button').nth(0).click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改调用方法 + await dialog.get_by_role('textbox', name='调用方法').fill(new_invoke_target) + + await dialog.get_by_role('button', name='确 定').click() + await self.wait_for_selector("div:has-text('修改成功')", timeout=10000) + + async def toggle_job_status(self, job_name: str) -> None: + """切换任务状态""" + await self.search_job(job_name) + + row = self.page.locator('tbody tr').first + # 切换开关 + # 需要点击可见的 .el-switch 或 .el-switch__core + await row.locator('.el-switch').click() + + # 确认切换 + await self.page.get_by_role('button', name='确定').click() + await self.page.wait_for_timeout(1000) + await self.wait_for_selector("div:has-text('成功')", timeout=10000) + + async def run_job_once(self, job_name: str) -> None: + """执行一次任务""" + await self.search_job(job_name) + + row = self.page.locator('tbody tr').first + # 点击执行一次 (第3个按钮) + await row.locator('button').nth(2).click() + + # 确认执行 + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector("div:has-text('执行成功')", timeout=10000) + + async def view_job_log(self, job_name: str) -> None: + """查看调度日志""" + # 可以点击顶部的 "日志" 按钮,也可以点击行的 "调度日志" + # 我们点击行的 "调度日志" (icon="Operation") + await self.search_job(job_name) + row = self.page.locator('tbody tr').first + + log_btn = row.locator('button').nth(4) + await log_btn.click() + + # 等待跳转到日志页面 (实际上是 router push 到 /monitor/job-log) + # 检查 URL + await self.page.wait_for_url('**/monitor/job-log**') + + # 验证在日志页面 + await self.wait_for_selector('text=调度日志') + + # 关闭返回 (点击 "关闭" 按钮) + await self.page.get_by_role('button', name='关闭').click() + + # 等待返回任务列表 + await self.page.wait_for_url('**/monitor/job') + + async def delete_job(self, job_name: str) -> None: + """删除任务""" + await self.search_job(job_name) + + row = self.page.locator('tbody tr').first + # 点击删除 (第2个按钮) + await row.locator('button').nth(1).click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector("div:has-text('删除成功')", timeout=10000) + + async def test_job_crud_operations(self) -> None: + """测试定时任务管理全流程""" + data = self.generate_job_data() + + # 1. 进入定时任务页面 + await self.goto_page(Config.frontend_url + '/monitor/job') + await self.wait_for_selector('text=任务名称') + + # 2. 创建任务 + await self.create_job( + data['job_name'], data['job_group'], data['job_executor'], data['invoke_target'], data['cron_expression'] + ) + + # 3. 修改任务 (修改调用方法) + await self.edit_job(data['job_name'], data['new_invoke_target']) + + # 4. 切换状态 (开启/关闭) + await self.toggle_job_status(data['job_name']) # 开启 + await self.page.wait_for_timeout(1000) + await self.toggle_job_status(data['job_name']) # 关闭 + + # 5. 执行一次 + await self.run_job_once(data['job_name']) + + # 6. 查看日志 + await self.view_job_log(data['job_name']) + + # 7. 删除任务 + await self.delete_job(data['job_name']) + + +@pytest.mark.asyncio +async def test_job_management_page() -> None: + """测试定时任务管理页面功能""" + async with async_playwright() as p: + test_instance = JobManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_job_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/monitor/test_online_user.py b/shierfeng-fastapi-test/monitor/test_online_user.py new file mode 100644 index 0000000..940f698 --- /dev/null +++ b/shierfeng-fastapi-test/monitor/test_online_user.py @@ -0,0 +1,73 @@ +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class OnlineUserTest(BasePageTest): + """在线用户测试类""" + + async def test_online_user_operations(self) -> None: + """测试在线用户强退""" + # 1. 模拟 niangao 用户登录以产生在线会话 + # 使用一个新的上下文来模拟另一个用户的登录 + niangao_context = await self.browser.new_context() + niangao_page = await niangao_context.new_page() + + # 登录 niangao + try: + await niangao_page.goto(Config.frontend_url + '/login') + await niangao_page.locator('div.el-form-item', has_text='账号').locator('input').fill('niangao') + await niangao_page.locator('div.el-form-item', has_text='密码').locator('input').fill('admin123') + await niangao_page.get_by_role('button', name='登 录').click() + # 等待登录成功 + await niangao_page.wait_for_url('**/index') + except Exception as e: + print(f'Niangao login failed: {e}. Trying admin123') + try: + await niangao_page.locator('div.el-form-item', has_text='密码').locator('input').fill('123456') + await niangao_page.get_by_role('button', name='登 录').click() + await niangao_page.wait_for_url('**/index') + except Exception as e2: + print(f'Niangao login failed again: {e2}') + + # 2. 切换回 admin (self.page) 进行操作 + await self.page.goto(Config.frontend_url + '/monitor/online') + await self.page.wait_for_load_state('networkidle') + + # 3. 搜索 niangao 用户 + await self.page.locator('div.el-form-item', has_text='用户名称').locator('input').fill('niangao') + await self.page.get_by_role('button', name='搜索').click() + await self.page.wait_for_timeout(1000) + + # 4. 强退操作 + # 检查是否有数据 + rows = self.page.locator('.el-table__body tr') + count = await rows.count() + if count > 0: + # 点击强退 + await rows.first.get_by_role('button', name='强退').click() + # 确认 + await self.page.get_by_role('button', name='确定').click() + # 验证 + await self.page.wait_for_selector('text=删除成功', timeout=3000) + + # 再次搜索确认消失 + await self.page.wait_for_timeout(1000) + await self.page.get_by_role('button', name='搜索').click() + + # 关闭 niangao 的上下文 + await niangao_context.close() + + +@pytest.mark.asyncio +async def test_online_user_page() -> None: + """测试在线用户页面功能""" + async with async_playwright() as p: + test_instance = OnlineUserTest() + await test_instance.setup(p) + try: + await test_instance.test_online_user_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/monitor/test_server_monitor.py b/shierfeng-fastapi-test/monitor/test_server_monitor.py new file mode 100644 index 0000000..0d89aa4 --- /dev/null +++ b/shierfeng-fastapi-test/monitor/test_server_monitor.py @@ -0,0 +1,46 @@ +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class ServerMonitorTest(BasePageTest): + """服务监控测试类""" + + async def test_server_monitor(self) -> None: + """测试服务监控页面""" + await self.page.goto(Config.frontend_url + '/monitor/server') + await self.page.wait_for_load_state('networkidle') + + # 验证主要板块存在 + await self.page.wait_for_selector('text=CPU') + await self.page.wait_for_selector('text=内存') + await self.page.wait_for_selector('text=服务器信息') + await self.page.wait_for_selector('text=Python解释器信息') + await self.page.wait_for_selector('text=磁盘状态') + + # 验证项目路径为 /app + # 尝试在表格行中查找 + project_path_row = self.page.locator('tr', has_text='项目路径') + try: + await project_path_row.wait_for(timeout=5000) + text = await project_path_row.text_content() + assert '/app' in text, f"Expected project path '/app' in row, but got: {text}" + except Exception: + # 如果没找到行,尝试全局搜索 + print("Warning: '项目路径' row not found, checking page content") + content = await self.page.content() + assert '/app' in content, "Project path '/app' not found in page content" + + +@pytest.mark.asyncio +async def test_server_monitor_page() -> None: + """测试服务监控页面功能""" + async with async_playwright() as p: + test_instance = ServerMonitorTest() + await test_instance.setup(p) + try: + await test_instance.test_server_monitor() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/requirements.txt b/shierfeng-fastapi-test/requirements.txt new file mode 100644 index 0000000..df7958d --- /dev/null +++ b/shierfeng-fastapi-test/requirements.txt @@ -0,0 +1,5 @@ +playwright +pytest +pytest-asyncio +pytest-playwright +ruff \ No newline at end of file diff --git a/shierfeng-fastapi-test/ruff.toml b/shierfeng-fastapi-test/ruff.toml new file mode 100644 index 0000000..23d788c --- /dev/null +++ b/shierfeng-fastapi-test/ruff.toml @@ -0,0 +1,68 @@ +line-length = 120 +show-fixes = true +target-version = "py310" +unsafe-fixes = true + +[lint] +select = [ + "FAST", # fastapi + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "PIE", # flake8-pie + "RSE", # flake8-raise + "RET", # flake8-return + "SIM", # flake8-simplify + "TC", # flake8-type-checking + "FLY", # flynt + "I", # isort + "N", # pep8-naming + "PERF", # perflint + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "PGH", # pygrep-hooks + "PL", # pylint + "UP", # pyupgrade + "RUF", # Ruff-specific rules +] +ignore = [ + "ANN002", # missing type annotation for *{name} + "ANN003", # missing type annotation for **{name} + "ANN401", # dynamically typed expressions (typing.Any) are disallowed in {name} + "B008", # do not perform function calls in argument defaults + "COM812", # trailing comma missing + "RET504", # unnecessary assignment to {name} before return statement + "SIM105", # use contextlib.suppress({exception}) instead of try-except-pass + "C901", # too complex + "N818", # exception name {name} should be named with an Error suffix + "E501", # line too long + "W191", # indentation contains tabs + "RUF001", # string contains ambiguous + "RUF002", # docstring contains ambiguous + "RUF003", # comment contains ambiguous + "RUF012", # mutable class attributes should be annotated with typing.ClassVar +] + +[lint.flake8-type-checking] +runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"] + +[lint.isort] +known-third-party = ["alembic"] + +[lint.pylint] +max-args = 10 +max-returns = 10 + +[lint.pyupgrade] +# Preserve types, even if a file imports `from __future__ import annotations`. +keep-runtime-typing = true + +[format] +docstring-code-format = true +quote-style = "single" \ No newline at end of file diff --git a/shierfeng-fastapi-test/system/__init__.py b/shierfeng-fastapi-test/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-test/system/test_config_management.py b/shierfeng-fastapi-test/system/test_config_management.py new file mode 100644 index 0000000..e4e0563 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_config_management.py @@ -0,0 +1,165 @@ +import time + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class ConfigManagementTest(BasePageTest): + """参数设置测试类""" + + def generate_config_data(self) -> dict: + """生成测试数据""" + timestamp = int(time.time()) + return { + 'config_name': f'test_config_{timestamp}', + 'config_key': f'test_config_key_{timestamp}', + 'config_value': f'test_config_value_{timestamp}', + } + + async def create_config(self, config_name: str, config_key: str, config_value: str) -> None: + """创建参数配置""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').first.click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写参数信息 + await dialog.get_by_role('textbox', name='参数名称').fill(config_name) + await dialog.get_by_role('textbox', name='参数键名').fill(config_key) + await dialog.get_by_role('textbox', name='参数键值').fill(config_value) + + # 等待一段时间确保输入完成 + await self.page.wait_for_timeout(500) + + # 选择参数状态 - 选择系统内置选项为N(否),避免后端限制删除 + # 在对话框中找到系统内置的单选按钮并选择 + await self.wait_for_selector('input[type="radio"]', timeout=5000) + # 点击系统内置'否'选项 + # 根据codegen脚本,使用更精确的定位器 + await self.page.locator('label:nth-child(2) > .el-radio__input > .el-radio__inner').click() + + # 等待一段时间确保选择完成 + await self.page.wait_for_timeout(500) + + # 点击确认按钮 + confirm_button = self.page.get_by_role('button', name='确 定').first + await confirm_button.click() + + # 等待一段时间确保请求发送 + await self.page.wait_for_timeout(1000) + + # 等待新增成功提示 + await self.wait_for_selector('div:has-text("新增成功")', timeout=10000) + + async def search_config(self, config_name: str, config_key: str = '') -> None: + """搜索参数配置""" + # 在查询表单中输入参数信息进行查询 + await self.page.get_by_role('textbox', name='参数名称').fill(config_name) + if config_key: + await self.page.get_by_role('textbox', name='参数键名').fill(config_key) + + # 点击搜索按钮 + await self.page.get_by_role('button', name='搜索').first.click() + + # 等待搜索结果加载 + await self.page.wait_for_timeout(1000) + + async def edit_config(self, updated_value: str) -> None: + """编辑参数配置""" + # 点击编辑按钮 + await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改参数键值 + await dialog.get_by_role('textbox', name='参数键值').fill(updated_value) + + # 确认 + await self.page.get_by_role('button', name='确 定').first.click() + + # 等待编辑成功提示 + await self.wait_for_selector('div:has-text("修改成功")', timeout=10000) + + async def delete_config(self) -> None: + """删除参数配置""" + # 点击删除按钮 + await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').first.click() + + # 等待删除成功提示 + await self.wait_for_selector('div:has-text("删除成功")', timeout=10000) + + # 重置查询表单,确保能看到所有参数 + reset_button = self.page.get_by_role('button', name='重置') + await reset_button.click() + + # 等待列表刷新 + await self.page.wait_for_timeout(1000) + + async def test_config_crud_operations(self) -> None: + """测试参数设置的增删查改功能""" + # 访问参数设置页面 + await self.goto_page(Config.frontend_url + '/system/config') + + # 等待页面加载完成 + await self.wait_for_page_title('参数设置', timeout=10000) + await self.page.wait_for_timeout(1000) # 等待列表刷新 + + # 记录初始参数数量 + initial_config_count = await self.get_table_total_rows() + + # 生成测试数据 + data = self.generate_config_data() + + # 1.新增 + await self.create_config(data['config_name'], data['config_key'], data['config_value']) + + # 验证新增 + await self.page.wait_for_timeout(1000) # 等待列表刷新 + new_config_count = await self.get_table_total_rows() + assert new_config_count > initial_config_count, '新增参数后数量应该增加' + + # 2.搜索 + await self.search_config(data['config_name'], data['config_key']) + + # 验证搜索 + search_result_count = await self.get_table_total_rows() + assert search_result_count >= 1, '搜索结果应该至少包含一个参数' + + # 3.编辑 + await self.edit_config(f'updated_config_value_{time.time()}') + + # 验证编辑 + await self.page.wait_for_timeout(1000) # 等待列表刷新 + + # 重新搜索以验证修改结果 + await self.search_config(data['config_name']) + + # 4.删除 + await self.delete_config() + + # 验证删除 + await self.page.wait_for_timeout(1000) # 等待列表刷新 + final_config_count = await self.get_table_total_rows() + assert final_config_count == initial_config_count, '删除参数后数量应该恢复到初始值' + + +@pytest.mark.asyncio +async def test_config_management_page() -> None: + """测试参数设置页面功能""" + async with async_playwright() as p: + test_instance = ConfigManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_config_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_dept_management.py b/shierfeng-fastapi-test/system/test_dept_management.py new file mode 100644 index 0000000..0083433 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_dept_management.py @@ -0,0 +1,135 @@ +import time + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class DeptManagementTest(BasePageTest): + """部门管理页面测试类""" + + def generate_dept_data(self) -> dict: + """生成测试数据""" + timestamp = int(time.time()) + return { + 'dept_name': f'测试部门_{timestamp}', + 'order_num': '3', + 'leader_name': '年糕', + } + + async def create_dept(self, dept_name: str, order_num: str) -> None: + """创建部门""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').first.click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 上级部门 + # 使用 label 定位父级 form-item,再点击内部的 wrapper + await dialog.locator('div.el-form-item').filter(has_text='上级部门').locator('.el-select__wrapper').click() + # 选择 "集团总公司" (根节点) + # 使用 .el-popper 定位下拉框中的内容 + await self.page.locator('.el-popper:visible').get_by_text('集团总公司').click() + + # 填写部门名称 + await dialog.get_by_role('textbox', name='部门名称').fill(dept_name) + + # 填写显示排序 + # el-input-number 通常是一个 spinbutton 或者 input + await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num) + + # 点击确定 + await self.page.get_by_role('button', name='确 定').click() + # 等待成功消息 + await self.wait_for_selector("div:has-text('成功')", timeout=10000) + + async def search_dept(self, dept_name: str) -> None: + """搜索部门""" + search_form = self.page.locator('form').first + await search_form.get_by_role('textbox', name='部门名称').fill(dept_name) + # 点击搜索按钮 + await self.page.get_by_role('button', name='搜索').first.click() + + # 等待加载 + await self.page.wait_for_timeout(1000) + + async def edit_dept(self, leader_name: str) -> None: + """编辑部门""" + # 点击修改按钮 (第一行) + # 树形表格结构可能不同,但通常也是在 tbody 的 row 中 + await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改负责人 + await dialog.get_by_role('textbox', name='负责人').fill(leader_name) + + # 确定 + await self.page.get_by_role('button', name='确 定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("修改成功")', timeout=10000) + + async def delete_dept(self) -> None: + """删除部门""" + # 点击删除按钮 + await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("删除成功")', timeout=10000) + + # 重置搜索 + await self.page.get_by_role('button', name='重置').click() + await self.page.wait_for_timeout(1000) + + async def test_dept_crud_operations(self) -> None: + """测试部门管理增删查改""" + # 访问页面 + await self.goto_page(Config.frontend_url + '/system/dept') + await self.wait_for_page_title('部门管理', timeout=10000) + + # 生成测试数据 + data = self.generate_dept_data() + + # 1. 新增 + await self.create_dept(data['dept_name'], data['order_num']) + + # 验证新增 + await self.search_dept(data['dept_name']) + # 树形表格,只要有行显示即可 + rows = await self.page.query_selector_all('.el-table__row') + assert len(rows) >= 1, '新增后搜索应有结果' + + # 2. 编辑 + await self.edit_dept(data['leader_name']) + + # 重新搜索以验证修改结果 + await self.search_dept(data['dept_name']) + + # 3. 删除 + await self.delete_dept() + + # 验证删除 + await self.search_dept(data['dept_name']) + rows_after = await self.page.query_selector_all('.el-table__row') + assert len(rows_after) == 0, '删除后搜索应无结果' + + +@pytest.mark.asyncio +async def test_dept_management_page() -> None: + """测试部门管理页面功能""" + async with async_playwright() as p: + test_instance = DeptManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_dept_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_dict_management.py b/shierfeng-fastapi-test/system/test_dict_management.py new file mode 100644 index 0000000..cf35556 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_dict_management.py @@ -0,0 +1,201 @@ +from datetime import datetime + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class DictManagementTest(BasePageTest): + """字典管理测试类""" + + def generate_dict_data(self) -> dict: + """生成测试数据""" + timestamp = datetime.now().strftime('%H%M%S') + return { + 'dict_name': f'测试字典_{timestamp}', + 'dict_type': f'test_dict_{timestamp}', + 'dict_data_label_1': '正常', + 'dict_data_value_1': '1', + 'dict_data_label_2': '异常', + 'dict_data_value_2': '2', + 'remark': f'备注_{timestamp}', + } + + async def create_dict_type(self, dict_name: str, dict_type: str) -> None: + """创建字典类型""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').first.click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写表单 + await dialog.get_by_role('textbox', name='字典名称').fill(dict_name) + await dialog.get_by_role('textbox', name='字典类型').fill(dict_type) + + # 提交 + await dialog.get_by_role('button', name='确 定').click() + + # 验证成功 + await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + + async def search_dict_type(self, dict_name: str, dict_type: str) -> None: + """搜索字典类型""" + form = self.page.locator('form').first + await form.get_by_role('textbox', name='字典名称').fill(dict_name) + await form.get_by_role('textbox', name='字典类型').fill(dict_type) + # 点击搜索按钮 + await self.page.get_by_role('button', name='搜索').click() + await self.page.wait_for_timeout(1000) + + async def edit_dict_type(self, dict_name: str, remark: str) -> None: + """修改字典类型""" + await self.search_dict_type(dict_name, '') + + # 点击修改按钮 + row = self.page.locator('tbody tr').first + await row.get_by_role('button', name='修改').click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + await dialog.get_by_role('textbox', name='备注').fill(remark) + await dialog.get_by_role('button', name='确 定').click() + + await self.wait_for_selector("div:has-text('修改成功')", timeout=10000) + + async def manage_dict_data(self, dict_type: str, data: dict) -> None: + """管理字典数据""" + # 1. 进入字典数据页面 + # 点击字典类型链接 + row = self.page.locator('tbody tr').first + await row.get_by_role('link', name=dict_type).click() + + # 等待页面切换(Playwright在SPA中通常不需要处理多标签页,除非新开tab) + # 这里是 router-link,应该是页内跳转 + # 检查是否到了字典数据页面 + # 或者等待URL变化,或者等待特定元素 + # data.vue 有 "关闭" 按钮 + await self.wait_for_selector("button:has-text('关闭')", timeout=5000) + + # 2. 新增数据1 (正常) + await self.create_dict_data(data['dict_data_label_1'], data['dict_data_value_1'], '1', '主要(primary)') + + # 3. 新增数据2 (异常) + await self.create_dict_data(data['dict_data_label_2'], data['dict_data_value_2'], '2', '危险(danger)') + + # 4. 修改数据 + await self.edit_dict_data(data['dict_data_label_2'], '测试备注') + + # 5. 删除数据 + await self.delete_dict_data(data['dict_data_label_2']) + await self.delete_dict_data(data['dict_data_label_1']) + + # 6. 关闭页面 (返回字典类型) + await self.page.get_by_role('button', name='关闭', exact=True).click() + # 等待返回 + await self.wait_for_selector('text=字典名称') # 假设返回后能看到原来的页面元素 + + async def create_dict_data(self, label: str, value: str, sort: str, list_class: str) -> None: + """创建字典数据""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').first.click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + await dialog.get_by_role('textbox', name='数据标签').fill(label) + await dialog.get_by_role('textbox', name='数据键值').fill(value) + await dialog.get_by_role('spinbutton', name='显示排序').fill(sort) + + # 选择回显样式 + # 点击下拉框触发器 + # el-select 的结构通常是 wrapper -> input (readonly) + # 我们可以点击 label 为 "回显样式" 的 form-item 下的 select + select_trigger = dialog.locator("label:has-text('回显样式') + div .el-select") + if await select_trigger.count() == 0: + # 尝试直接找 .el-select,可能布局不同 + select_trigger = dialog.locator('.el-select').filter(has_text='默认(default)').first + + # 如果还是找不到,尝试点击那个默认值文本 + if await select_trigger.count() == 0: + await dialog.get_by_text('默认(default)').click() + else: + await select_trigger.click() + + # 选择选项 (选项在 body 的 el-popper 中) + # 等待选项出现 + await self.page.get_by_role('option', name=list_class).click() + + await dialog.get_by_role('button', name='确 定').click() + await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + + async def edit_dict_data(self, label: str, remark: str) -> None: + """修改字典数据""" + # 点击修改按钮 + row = self.page.locator('tbody tr').filter(has_text=label).first + await row.get_by_role('button', name='修改').click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + await dialog.get_by_role('textbox', name='备注').fill(remark) + await dialog.get_by_role('button', name='确 定').click() + + await self.wait_for_selector("div:has-text('修改成功')", timeout=10000) + + async def delete_dict_data(self, label: str) -> None: + """删除字典数据""" + # 点击删除按钮 + row = self.page.locator('tbody tr').filter(has_text=label).first + await row.get_by_role('button', name='删除').click() + + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector("div:has-text('删除成功')", timeout=10000) + + async def delete_dict_type(self, dict_name: str) -> None: + """删除字典类型""" + await self.search_dict_type(dict_name, '') + + # 点击删除按钮 + row = self.page.locator('tbody tr').filter(has_text=dict_name).first + await row.get_by_role('button', name='删除').click() + + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector("div:has-text('删除成功')", timeout=10000) + + async def test_dict_crud_operations(self) -> None: + """测试字典管理全流程""" + data = self.generate_dict_data() + + # 1. 进入字典管理 + await self.goto_page(Config.frontend_url + '/system/dict') + # 等待页面加载 + await self.wait_for_selector('text=字典名称') # 页面标题或其他标识 + + # 2. 创建字典类型 + await self.create_dict_type(data['dict_name'], data['dict_type']) + + # 3. 搜索并修改 + await self.edit_dict_type(data['dict_name'], data['remark']) + + # 4. 管理字典数据 + await self.manage_dict_data(data['dict_type'], data) + + # 5. 删除字典类型 + await self.delete_dict_type(data['dict_name']) + + +@pytest.mark.asyncio +async def test_dict_management_page() -> None: + """测试字典管理页面功能""" + async with async_playwright() as p: + test_instance = DictManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_dict_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_log_management.py b/shierfeng-fastapi-test/system/test_log_management.py new file mode 100644 index 0000000..0b43df1 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_log_management.py @@ -0,0 +1,138 @@ +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class LogManagementTest(BasePageTest): + """日志管理测试类""" + + async def test_operlog_operations(self) -> None: + """测试操作日志查询、删除和清空""" + # 1. 导航到操作日志页面 + # 直接访问正确的路由 + await self.page.goto(Config.frontend_url + '/system/log/operlog') + await self.page.wait_for_load_state('networkidle') + + try: + await self.page.wait_for_selector('.app-container', timeout=10000) + except Exception: + # 如果首次加载失败,尝试刷新 + await self.page.reload() + await self.page.wait_for_selector('.app-container', timeout=10000) + + # 2. 查询操作 + # 输入操作人员 + await self.page.locator('div.el-form-item', has_text='操作人员').locator('input').fill('admin') + # 点击搜索按钮 + await self.page.get_by_role('button', name='搜索').click() + await self.page.wait_for_timeout(1000) # 等待搜索结果 + # 点击重置按钮 + await self.page.get_by_role('button', name='重置').click() + await self.page.wait_for_timeout(1000) # 等待重置结果 + + # 3. 删除操作 + # 检查是否有数据,如果有则进行删除测试 + rows = self.page.locator('.el-table__body tr') + count = await rows.count() + if count > 0: + # 选中第一条 + await rows.first.locator('.el-checkbox').click() + # 点击删除 + await self.page.get_by_role('button', name='删除').click() + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + # 等待删除完成提示 + try: + await self.page.wait_for_selector('text=删除成功', timeout=10000) + except Exception: + pass # 可能是 toast 提示,稍纵即逝 + + # 4. 清空操作 + # 点击清空 + await self.page.get_by_role('button', name='清空').click() + # 确认清空 + await self.page.get_by_role('button', name='确定').click() + + # 5. 验证清空后还有一条"清空"的操作日志 + await self.page.wait_for_timeout(1000) # 等待清空操作完成并刷新 + await self.page.get_by_role('button', name='搜索').click() # 刷新列表 + await self.page.wait_for_timeout(1000) + + # 验证列表不为空 (因为清空操作本身会产生一条日志) + rows_after_clean = self.page.locator('.el-table__body tr') + count_after_clean = await rows_after_clean.count() + assert count_after_clean > 0, '操作日志清空后应该至少有一条记录(清空操作本身)' + + # 可以在这里进一步验证第一条记录的类型是否为清空,但题目只要求不是空列表 + + async def test_logininfor_operations(self) -> None: + """测试登录日志查询、删除和清空""" + # 1. 导航到登录日志页面 + await self.page.goto(Config.frontend_url + '/system/log/logininfor') + await self.page.wait_for_load_state('networkidle') + + try: + await self.page.wait_for_selector('.app-container', timeout=10000) + except Exception: + # 如果失败,尝试刷新 + await self.page.reload() + await self.page.wait_for_selector('.app-container', timeout=10000) + + # 2. 查询操作 + # 输入用户名称 + await self.page.locator('div.el-form-item', has_text='用户名称').locator('input').fill('admin') + # 点击搜索 + await self.page.get_by_role('button', name='搜索').click() + await self.page.wait_for_timeout(1000) + # 点击重置 + await self.page.get_by_role('button', name='重置').click() + await self.page.wait_for_timeout(1000) + + # 3. 删除操作 + # 检查是否有数据 + rows = self.page.locator('.el-table__body tr') + count = await rows.count() + if count > 0: + # 选中第一条 + await rows.first.locator('.el-checkbox').click() + # 点击删除 + await self.page.get_by_role('button', name='删除').click() + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + # 等待删除成功 + try: + await self.page.wait_for_selector('text=删除成功', timeout=10000) + except Exception: + pass + + # 4. 清空操作 + # 点击清空 + await self.page.get_by_role('button', name='清空').click() + # 确认清空 + await self.page.get_by_role('button', name='确定').click() + + # 5. 验证清空后列表为空 + await self.page.wait_for_timeout(1000) + # 检查是否显示"暂无数据" + no_data = await self.page.get_by_text('暂无数据').is_visible() + if not no_data: + # 再次检查行数 + count_final = await self.page.locator('.el-table__body tr').count() + assert count_final == 0, '登录日志清空后应该没有数据' + + +@pytest.mark.asyncio +async def test_log_management_page() -> None: + """测试日志管理页面功能""" + async with async_playwright() as p: + test_instance = LogManagementTest() + await test_instance.setup(p) + try: + # 运行操作日志测试 + await test_instance.test_operlog_operations() + # 运行登录日志测试 + await test_instance.test_logininfor_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_menu_management.py b/shierfeng-fastapi-test/system/test_menu_management.py new file mode 100644 index 0000000..c42dfd8 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_menu_management.py @@ -0,0 +1,145 @@ +from datetime import datetime + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class MenuManagementTest(BasePageTest): + """菜单管理测试""" + + def generate_menu_data(self) -> dict: + """生成测试数据""" + timestamp = datetime.now().strftime('%H%M%S') + return { + 'dir_name': f'测试目录_{timestamp}', + 'menu_name': f'测试菜单_{timestamp}', + 'path_dir': f'test_dir_{timestamp}', + 'path_menu': f'test_menu_{timestamp}', + 'path_menu_new': f'test_menu_new_{timestamp}', + 'order_num': '10', + } + + async def create_directory(self, menu_name: str, path: str, order_num: str) -> None: + """创建目录""" + # 点击顶部新增按钮 + await self.page.get_by_role('button', name='新增').first.click() + + # 等待对话框出现 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写表单 + await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num) + await dialog.get_by_role('textbox', name='菜单名称').fill(menu_name) + await dialog.get_by_role('textbox', name='路由地址').fill(path) + + # 提交 + await dialog.get_by_role('button', name='确 定').click() + + # 验证成功提示 + await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + + async def search_menu(self, menu_name: str) -> None: + """搜索菜单""" + # 填写搜索条件 + # 使用 first 匹配搜索表单(通常在顶部),避免匹配到隐藏对话框中的同名字段 + await self.page.locator('form').first.get_by_role('textbox', name='菜单名称').fill(menu_name) + await self.page.get_by_role('button', name='搜索').click() + + # 等待表格刷新(简单等待) + await self.page.wait_for_timeout(1000) + + async def create_sub_menu(self, parent_name: str, menu_name: str, path: str, order_num: str) -> None: + """在指定目录下创建子菜单""" + # 找到父级菜单行 + row = self.page.locator('tbody tr').filter(has_text=parent_name).first + + # 点击行内新增按钮 + await row.get_by_role('button', name='新增').click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 选择菜单类型为"菜单" + await dialog.locator('label').filter(has_text='菜单').first.click() + + # 填写表单 + await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num) + await dialog.get_by_role('textbox', name='菜单名称').fill(menu_name) + await dialog.get_by_role('textbox', name='路由地址').fill(path) + + # 提交 + await dialog.get_by_role('button', name='确 定').click() + + # 验证成功 + await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + + async def edit_sub_menu(self, menu_name: str, new_path: str) -> None: + """修改子菜单""" + # 搜索子菜单 + await self.search_menu(menu_name) + + row = self.page.locator('tbody tr').filter(has_text=menu_name).first + await row.get_by_role('button', name='修改').click() + + await self.wait_for_selector('.el-dialog', timeout=5000) + dialog = self.page.locator('.el-dialog') + + await dialog.get_by_role('textbox', name='路由地址').fill(new_path) + await self.page.get_by_role('button', name='确 定').click() + + await self.wait_for_selector("div:has-text('修改成功')", timeout=10000) + + async def delete_menus(self, menu_names: list[str]) -> None: + """删除菜单(列表)""" + for name in menu_names: + await self.search_menu(name) + row = self.page.locator('tbody tr').filter(has_text=name).first + + # 点击删除 + await row.get_by_role('button', name='删除').click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + + await self.wait_for_selector("div:has-text('删除成功')", timeout=10000) + + async def test_menu_crud_operations(self) -> None: + """测试菜单增删改查流程""" + data = self.generate_menu_data() + + # 1. 进入菜单管理页面 + await self.goto_page(Config.frontend_url + '/system/menu') + await self.wait_for_page_title('菜单管理', timeout=10000) + + # 2. 新增目录 + await self.create_directory(data['dir_name'], data['path_dir'], data['order_num']) + + # 3. 搜索目录并新增子菜单 + await self.search_menu(data['dir_name']) + await self.create_sub_menu(data['dir_name'], data['menu_name'], data['path_menu'], '1') + + # 4. 修改子菜单 + await self.edit_sub_menu(data['menu_name'], data['path_menu_new']) + + # 5. 删除子菜单 + await self.delete_menus([data['menu_name']]) + + # 6. 删除目录 + await self.delete_menus([data['dir_name']]) + + +@pytest.mark.asyncio +async def test_menu_management_page() -> None: + """测试菜单管理页面功能""" + async with async_playwright() as p: + test_instance = MenuManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_menu_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_notice_management.py b/shierfeng-fastapi-test/system/test_notice_management.py new file mode 100644 index 0000000..d73a618 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_notice_management.py @@ -0,0 +1,165 @@ +import re +import time + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class NoticeManagementTest(BasePageTest): + """通知公告管理测试类""" + + def generate_notice_data(self) -> dict: + """生成测试数据""" + timestamp = int(time.time()) + return { + 'notice_title': f'test_notice_{timestamp}', + 'updated_title': f'updated_notice_{timestamp}', + } + + async def create_notice(self, notice_title: str, notice_type: int = 1, notice_content: str = '测试内容') -> None: + """创建通知公告""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').first.click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写公告信息 + await dialog.get_by_role('textbox', name='公告标题').fill(notice_title) + + # 选择公告类型 + # 根据codegen脚本,点击下拉框触发器 + await self.page.locator('div').filter(has_text=re.compile(r'^请选择$')).nth(4).click() + # 选择对应类型选项,1为通知,2为公告 + if notice_type == 1: + await self.page.get_by_role('option', name='通知').click() + else: + await self.page.get_by_role('option', name='公告').click() + + # 填写公告内容 + await self.page.locator('.ql-container').click() + await self.page.locator('.ql-editor').fill(notice_content) + + # 等待一段时间确保输入完成 + await self.page.wait_for_timeout(500) + + # 点击确认按钮 + await self.page.get_by_role('button', name='确 定').first.click() + + # 等待一段时间确保请求发送 + await self.page.wait_for_timeout(1000) + + # 等待新增成功提示 + await self.wait_for_selector('div:has-text("新增成功")', timeout=10000) + + async def search_notice(self, notice_title: str) -> None: + """搜索通知公告""" + # 在查询表单中输入公告标题进行查询 + await self.page.get_by_role('textbox', name='公告标题').fill(notice_title) + + # 点击搜索按钮 + await self.page.get_by_role('button', name='搜索').first.click() + + # 等待搜索结果加载 + await self.page.wait_for_timeout(1000) + + async def edit_notice(self, updated_title: str, updated_content: str = '更新的测试内容') -> None: + """编辑通知公告""" + # 点击编辑按钮 + await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改公告标题 + await dialog.get_by_role('textbox', name='公告标题').fill(updated_title) + + # 修改公告内容 + await self.page.locator('.ql-editor').fill(updated_content) + + # 点击确认按钮 + await self.page.get_by_role('button', name='确 定').first.click() + + # 等待编辑成功提示 + await self.wait_for_selector('div:has-text("修改成功")', timeout=10000) + + async def delete_notice(self) -> None: + """删除通知公告""" + # 点击删除按钮 + await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').first.click() + + # 等待删除成功提示 + await self.wait_for_selector('div:has-text("删除成功")', timeout=10000) + + # 重置查询表单,确保能看到所有公告 + await self.page.get_by_role('button', name='重置').click() + + # 等待列表刷新 + await self.page.wait_for_timeout(1000) + + async def test_notice_crud_operations(self) -> None: + """测试通知公告的增删查改功能""" + # 访问通知公告页面 + await self.goto_page(Config.frontend_url + '/system/notice') + + # 等待页面加载完成 + await self.wait_for_page_title('通知公告', timeout=10000) + await self.page.wait_for_timeout(1000) # 等待列表刷新 + + # 记录初始公告数量 + initial_notice_count = await self.get_table_total_rows() + + # 生成测试数据 + data = self.generate_notice_data() + + # 创建公告 + await self.create_notice(data['notice_title']) + + # 验证公告已添加到列表中 + await self.page.wait_for_timeout(1000) # 等待列表刷新 + new_notice_count = await self.get_table_total_rows() + assert new_notice_count > initial_notice_count, '新增公告后数量应该增加' + + # 搜索公告 + await self.search_notice(data['notice_title']) + + # 验证搜索结果中包含新增的公告 + search_result_count = await self.get_table_total_rows() + assert search_result_count >= 1, '搜索结果应该至少包含一个公告' + + # 编辑公告 + await self.edit_notice(data['updated_title']) + + # 验证编辑成功 + await self.page.wait_for_timeout(1000) # 等待列表刷新 + + # 重新搜索以验证修改结果 + await self.search_notice(data['updated_title']) + + # 删除公告 + await self.delete_notice() + + # 验证公告已从列表中删除 + await self.page.wait_for_timeout(1000) # 等待列表刷新 + final_notice_count = await self.get_table_total_rows() + assert final_notice_count == initial_notice_count, '删除公告后数量应该恢复到初始值' + + +@pytest.mark.asyncio +async def test_notice_management_page() -> None: + """测试通知公告页面功能""" + async with async_playwright() as p: + test_instance = NoticeManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_notice_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_post_management.py b/shierfeng-fastapi-test/system/test_post_management.py new file mode 100644 index 0000000..f57b0f1 --- /dev/null +++ b/shierfeng-fastapi-test/system/test_post_management.py @@ -0,0 +1,136 @@ +from datetime import datetime + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class PostManagementTest(BasePageTest): + """岗位管理测试类""" + + def generate_post_data(self) -> dict: + """生成测试数据""" + timestamp = datetime.now().strftime('%H%M%S') + return { + 'post_name': f'测试岗位_{timestamp}', + 'post_code': f'test_{timestamp}', + 'post_sort': '10', + 'remark': f'测试备注_{timestamp}', + } + + async def test_post_crud_operations(self) -> None: + """测试岗位增删改查流程""" + # 1. 导航到岗位管理页面 + await self.navigate_to_post_management() + + # 生成测试数据 + data = self.generate_post_data() + + # 2. 新增岗位 + await self.create_post(data['post_name'], data['post_code'], data['post_sort']) + + # 3. 搜索岗位 + await self.search_post(data['post_name'], data['post_code']) + + # 4. 修改岗位 + await self.edit_post(data['post_name'], data['remark']) + + # 5. 删除岗位 + await self.delete_post(data['post_name']) + + async def navigate_to_post_management(self) -> None: + """导航到岗位管理""" + # 直接导航到岗位管理页面 + await self.goto_page(Config.frontend_url + '/system/post') + # 等待页面加载 + await self.wait_for_selector('.app-container') + + async def create_post(self, post_name: str, post_code: str, post_sort: str) -> None: + """新增岗位""" + await self.page.get_by_role('button', name='新增').click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写表单 + # 注意:Element Plus 表单必填项可能有 * 号,Label 匹配可能需要注意 + await dialog.get_by_role('textbox', name='岗位名称').fill(post_name) + await dialog.get_by_role('textbox', name='岗位编码').fill(post_code) + + # 岗位顺序是 input-number + # script.py 使用 spinbutton + await dialog.get_by_role('spinbutton', name='岗位顺序').fill(post_sort) + + await dialog.get_by_role('button', name='确 定').click() + + # 等待成功提示 + await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + + async def search_post(self, post_name: str, post_code: str) -> None: + """搜索岗位""" + # 填写搜索表单 + # 限定在搜索区域,防止定位到弹窗或其他地方(虽然此时没有弹窗) + search_form = self.page.locator('.el-form').first + + await search_form.get_by_role('textbox', name='岗位编码').fill(post_code) + await search_form.get_by_role('textbox', name='岗位名称').fill(post_name) + + await self.page.get_by_role('button', name='搜索').click() + + # 等待表格加载结果 + # 验证表格中包含刚创建的岗位 + await self.wait_for_selector(f"tbody tr:has-text('{post_name}')", timeout=10000) + + async def edit_post(self, post_name: str, remark: str) -> None: + """修改岗位""" + # 确保当前显示的是我们要修改的岗位 + await self.search_post(post_name, '') + + row = self.page.locator('tbody tr').first + # 点击修改按钮 + await row.get_by_role('button', name='修改').click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改备注 + await dialog.get_by_role('textbox', name='备注').fill(remark) + + await dialog.get_by_role('button', name='确 定').click() + + # 等待成功提示 + await self.wait_for_selector("div:has-text('修改成功')", timeout=10000) + + async def delete_post(self, post_name: str) -> None: + """删除岗位""" + # 确保当前显示的是我们要删除的岗位 + search_form = self.page.locator('.el-form').first + await search_form.get_by_role('textbox', name='岗位编码').clear() + await search_form.get_by_role('textbox', name='岗位名称').fill(post_name) + await self.page.get_by_role('button', name='搜索').click() + + await self.wait_for_selector(f"tbody tr:has-text('{post_name}')", timeout=10000) + + row = self.page.locator('tbody tr').first + # 点击删除按钮 + await row.get_by_role('button', name='删除').click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + + # 等待成功提示 + await self.wait_for_selector("div:has-text('删除成功')", timeout=10000) + + +@pytest.mark.asyncio +async def test_post_management_page() -> None: + """测试岗位管理页面功能""" + async with async_playwright() as p: + test_instance = PostManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_post_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_role_management.py b/shierfeng-fastapi-test/system/test_role_management.py new file mode 100644 index 0000000..00f064e --- /dev/null +++ b/shierfeng-fastapi-test/system/test_role_management.py @@ -0,0 +1,148 @@ +import time + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class RoleManagementTest(BasePageTest): + """角色管理测试类""" + + def generate_role_data(self) -> dict: + """生成测试数据""" + timestamp = int(time.time()) + return { + 'role_name': f'test_role_{timestamp}', + 'role_key': f'test_{timestamp}', + } + + async def create_role(self, role_name: str, role_key: str, role_sort: int = 3) -> None: + """创建角色""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写信息 + await dialog.get_by_role('textbox', name='角色名称').fill(role_name) + await dialog.get_by_role('textbox', name='权限字符').fill(role_key) + await dialog.get_by_role('spinbutton', name='角色顺序').fill(str(role_sort)) + + # 选择菜单权限 (点击第一个复选框) + await self.page.locator('.el-tree-node__content .el-checkbox').first.click() + + # 确定 + await self.page.get_by_role('button', name='确 定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("新增成功")', timeout=10000) + + async def search_role(self, role_name: str, role_key: str) -> None: + """搜索角色""" + search_form = self.page.locator('form').first + await search_form.get_by_placeholder('请输入角色名称').fill(role_name) + await search_form.get_by_placeholder('请输入权限字符').fill(role_key) + await search_form.get_by_role('button', name='搜索').click() + + # 等待加载 + await self.page.wait_for_timeout(1000) + + async def edit_role(self, new_remark: str) -> None: + """编辑角色""" + # 点击修改按钮 (第一行) + # 按钮没有文字,只有图标,所以使用 nth(0) + row = self.page.locator('tbody tr').first + await row.get_by_role('button').nth(0).click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改备注 + await dialog.get_by_role('textbox', name='备注').fill(new_remark) + + # 确定 + await self.page.get_by_role('button', name='确 定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("修改成功")', timeout=10000) + + async def change_role_status(self) -> None: + """修改角色状态""" + # 点击开关 (第一行) + # 使用 .el-switch__core 或者 role=switch + # script.py uses .el-switch__action, let's try .el-switch + switch = self.page.locator('.el-switch').first + await switch.click() + + # 确认对话框 + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector('div:has-text("成功")', timeout=5000) + + # 再次点击恢复 + await self.page.wait_for_timeout(1000) + await switch.click() + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector('div:has-text("成功")', timeout=5000) + + async def delete_role(self) -> None: + """删除角色""" + # 点击删除按钮 (第一行第二个按钮) + row = self.page.locator('tbody tr').first + await row.get_by_role('button').nth(1).click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("删除成功")', timeout=10000) + + # 重置搜索 + await self.page.get_by_role('button', name='重置').click() + await self.page.wait_for_timeout(1000) + + async def test_role_crud_operations(self) -> None: + """测试角色管理CRUD""" + # 访问页面 + await self.goto_page(Config.frontend_url + '/system/role') + await self.wait_for_page_title('角色管理', timeout=10000) + + # 生成测试数据 + data = self.generate_role_data() + + # 1. 新增 + await self.create_role(data['role_name'], data['role_key']) + + # 验证新增结果 + await self.search_role(data['role_name'], data['role_key']) + rows = await self.get_table_total_rows() + assert rows >= 1, '新增后搜索应有结果' + + # 2. 修改状态 + await self.change_role_status() + + # 3. 编辑 + await self.edit_role('Updated remark') + + # 4. 删除 + await self.delete_role() + + # 验证删除结果 + await self.search_role(data['role_name'], data['role_key']) + rows_after = await self.get_table_total_rows() + assert rows_after == 0, '删除后搜索应无结果' + + +@pytest.mark.asyncio +async def test_role_management_page() -> None: + """测试角色管理页面功能""" + async with async_playwright() as p: + test_instance = RoleManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_role_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/system/test_user_management.py b/shierfeng-fastapi-test/system/test_user_management.py new file mode 100644 index 0000000..afe732b --- /dev/null +++ b/shierfeng-fastapi-test/system/test_user_management.py @@ -0,0 +1,170 @@ +import time + +import pytest +from playwright.async_api import async_playwright + +from common.base_page_test import BasePageTest +from common.config import Config + + +class UserManagementTest(BasePageTest): + """用户管理测试类""" + + def generate_user_data(self) -> dict: + """生成测试数据""" + timestamp = int(time.time()) + return { + 'user_name': f'test_{timestamp}', + 'nick_name': f'测试用户_{timestamp}', + 'phone': '13888888888', + } + + async def create_user(self, user_name: str, nick_name: str, phone: str) -> None: + """创建用户""" + # 点击新增按钮 + await self.page.get_by_role('button', name='新增').click() + + # 等待对话框 + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 填写基本信息 + await dialog.get_by_role('textbox', name='用户昵称').fill(nick_name) + + # 归属部门 + # 使用 label 定位父级 form-item,再点击内部的 wrapper + await dialog.locator('div.el-form-item').filter(has_text='归属部门').locator('.el-select__wrapper').click() + # 选择 "集团总公司" (根节点) + # 使用 .el-popper 定位下拉框中的内容 + await self.page.locator('.el-popper:visible').get_by_text('集团总公司').click() + + await dialog.get_by_role('textbox', name='用户名称').fill(user_name) + # 手机号码在搜索框也有,所以必须限定在 dialog 内 + # 注意:有些 label 可能不带 *,或者带 * 但 get_by_role 需要准确匹配 + # 这里使用 get_by_placeholder 或者 filter 来定位更稳妥,或者直接 scope 到 dialog + await dialog.locator('div.el-form-item').filter(has_text='手机号码').get_by_role('textbox').fill(phone) + await dialog.locator('div.el-form-item').filter(has_text='邮箱').get_by_role('textbox').fill('test@example.com') + await dialog.locator('div.el-form-item').filter(has_text='用户密码').get_by_role('textbox').fill('123456') + + # 用户性别 + # 定位 label 为 "用户性别" 的父级 form-item,然后找里面的 "请选择" 文本 + await dialog.locator('div.el-form-item').filter(has_text='用户性别').get_by_text('请选择').click() + await self.page.get_by_role('option', name='男').click() + + # 岗位 + await dialog.locator('div.el-form-item').filter(has_text='岗位').get_by_text('请选择').click() + await self.page.get_by_role('option', name='董事长').click() + + # 角色 + await dialog.locator('div.el-form-item').filter(has_text='角色').get_by_text('请选择').click() + await self.page.get_by_role('option', name='普通角色').click() + + await dialog.locator('div.el-form-item').filter(has_text='备注').get_by_role('textbox').fill('测试用户') + + # 点击确定 + await self.page.get_by_role('button', name='确 定').click() + # 等待成功消息 + await self.wait_for_selector("div:has-text('成功')", timeout=10000) + + async def search_user(self, user_name: str) -> None: + """搜索用户""" + search_form = self.page.locator('form').first + await search_form.get_by_role('textbox', name='用户名称').fill(user_name) + await search_form.get_by_role('button', name='搜索').click() + + # 等待加载 + await self.page.wait_for_timeout(1000) + + async def edit_user(self) -> None: + """编辑用户""" + # 点击修改按钮 (第一行) + row = self.page.locator('tbody tr').first + await row.get_by_role('button').nth(0).click() + + dialog = self.page.get_by_role('dialog') + await dialog.wait_for() + + # 修改性别为女 + await dialog.locator('div.el-form-item').filter(has_text='用户性别').locator('.el-select').click() + await self.page.get_by_role('option', name='女').click() + + # 确定 + await self.page.get_by_role('button', name='确 定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("修改成功")', timeout=10000) + + async def change_user_status(self) -> None: + """修改用户状态""" + # 点击开关 (第一行) + switch = self.page.locator('.el-switch').first + await switch.click() + + # 确认对话框 + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector('div:has-text("成功")', timeout=5000) + + # 再次点击恢复 + await self.page.wait_for_timeout(1000) + await switch.click() + await self.page.get_by_role('button', name='确定').click() + await self.wait_for_selector('div:has-text("成功")', timeout=5000) + + async def delete_user(self) -> None: + """删除用户""" + # 点击删除按钮 (第一行第二个按钮) + row = self.page.locator('tbody tr').first + await row.get_by_role('button').nth(1).click() + + # 确认删除 + await self.page.get_by_role('button', name='确定').click() + + # 等待成功提示 + await self.wait_for_selector('div:has-text("删除成功")', timeout=10000) + + # 重置搜索 + await self.page.get_by_role('button', name='重置').click() + await self.page.wait_for_timeout(1000) + + async def test_user_crud_operations(self) -> None: + """测试用户管理增删查改""" + # 访问页面 + await self.goto_page(Config.frontend_url + '/system/user') + await self.wait_for_page_title('用户管理', timeout=10000) + + # 生成测试数据 + data = self.generate_user_data() + + # 1. 新增 + await self.create_user(data['user_name'], data['nick_name'], data['phone']) + + # 验证新增结果 + await self.search_user(data['user_name']) + rows = await self.get_table_total_rows() + assert rows >= 1, '新增后搜索应有结果' + + # 2. 修改状态 + await self.change_user_status() + + # 3. 编辑 + await self.edit_user() + + # 4. 删除 + await self.delete_user() + + # 验证删除结果 + await self.search_user(data['user_name']) + rows_after = await self.get_table_total_rows() + assert rows_after == 0, '删除后搜索应无结果' + + +@pytest.mark.asyncio +async def test_user_management_page() -> None: + """测试用户管理页面功能""" + async with async_playwright() as p: + test_instance = UserManagementTest() + await test_instance.setup(p) + try: + await test_instance.test_user_crud_operations() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/test_login.py b/shierfeng-fastapi-test/test_login.py new file mode 100644 index 0000000..28b25d9 --- /dev/null +++ b/shierfeng-fastapi-test/test_login.py @@ -0,0 +1,202 @@ +import pytest +from playwright.async_api import async_playwright + +from common.config import Config +from common.login_helper import LoginHelper + + +@pytest.mark.asyncio +async def test_login_page_loads() -> None: + """测试登录页面是否能正常加载""" + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + + # 访问登录页面 + await page.goto(Config.frontend_url + '/login') + + # 检查页面标题或特定元素是否存在 + title = await page.title() + assert 'vfadmin' in title + + # 检查登录表单元素是否存在 + username_input = await page.query_selector('input[placeholder="账号"]') + password_input = await page.query_selector('input[placeholder="密码"]') + + assert username_input is not None + assert password_input is not None + + await browser.close() + + +@pytest.mark.asyncio +async def test_captcha_generation() -> None: + """测试验证码是否正常生成(在测试环境中,验证码已禁用,但仍应能访问登录页面)""" + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + + # 访问登录页面 + await page.goto(Config.frontend_url + '/login') + + # 在测试环境中验证码已禁用,但仍应能访问登录页面 + title = await page.title() + assert 'vfadmin' in title + + await browser.close() + + +def test_login_without_captcha() -> None: + """测试在禁用验证码的情况下登录流程""" + helper = LoginHelper() + + # 尝试登录(测试环境中验证码已禁用) + token = helper.login(username='admin', password='admin123') + + # 验证登录结果 + assert token is not None, '登录应该成功' + assert len(token) > 0, '应该返回有效的token' + + +@pytest.mark.asyncio +async def test_login_flow_with_playwright() -> None: + """使用Playwright测试完整的登录流程(测试环境中验证码已禁用)""" + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + + # 访问登录页面 + await page.goto(Config.frontend_url + '/login') + + # 等待页面加载 + await page.wait_for_selector('input[placeholder="账号"]') + + # 输入用户名和密码 + await page.fill('input[placeholder="账号"]', 'admin') + await page.fill('input[placeholder="密码"]', 'admin123') + + # 在测试环境中验证码已禁用,所以不需要处理验证码 + # 直接点击登录按钮 + # 尝试多种可能的选择器 + try: + await page.click('button:has-text("登录")') + except Exception: + try: + await page.click('button[type="submit"]') + except Exception: + await page.click('button.el-button') + + # 等待页面跳转或检查登录结果 + try: + # 等待跳转到主页或检查登录成功消息 + await page.wait_for_url('**/index**', timeout=10000) + success = True + except Exception: + # 检查是否有错误消息 + error_message = await page.query_selector('.el-message') + if error_message: + error_text = await error_message.text_content() + print(f'登录失败: {error_text}') + success = False + else: + success = False + + assert success, '登录应该成功' + + await browser.close() + + +@pytest.mark.asyncio +async def test_protected_routes_require_auth() -> None: + """测试受保护的路由需要认证""" + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + + # 直接访问受保护的页面(如用户管理) + await page.goto(Config.frontend_url + '/system/user') + + # 检查是否被重定向到登录页面 + current_url = page.url + + # 检查页面是否包含登录相关的元素(如账号密码输入框) + try: + has_username_input = await page.is_visible('input[placeholder*="账号" i], input[placeholder*="username" i]') + except Exception: + has_username_input = False + + try: + has_password_input = await page.is_visible('input[placeholder*="密码" i], input[placeholder*="password" i]') + except Exception: + has_password_input = False + + try: + has_login_button = await page.is_visible('button:has-text("登录"), button:has-text("Login"), .login-btn') + except Exception: + has_login_button = False + + # 检查URL是否包含login路径 + is_redirected_to_login = '/login' in current_url.lower() or '/#/login' in current_url + + # 断言:应该重定向到登录页面或页面包含登录相关元素 + assert is_redirected_to_login or has_username_input or has_password_input or has_login_button, ( + f'未登录用户访问受保护页面时,没有重定向到登录页。当前URL: {current_url}, 页面内容: {await page.content()}' + ) + + await browser.close() + + +@pytest.mark.asyncio +async def test_authenticated_access() -> None: + """测试认证后的访问""" + # 首先通过API登录获取token + helper = LoginHelper() + token = helper.login(username='admin', password='admin123') + + assert token is not None, '登录应该成功' + + # 使用Playwright测试带认证的访问 + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context( + storage_state={ # 使用storage state来保持登录状态 + 'cookies': [ + { + 'name': 'Admin-Token', + 'value': token, + 'domain': 'localhost', + 'path': '/', + 'httpOnly': False, + 'secure': False, + 'sameSite': 'Lax', + } + ], + 'origins': [], + } + ) + page = await context.new_page() + + # 访问受保护的页面 + await page.goto(Config.frontend_url + '/system/user') + + # 检查是否能够访问受保护的页面(而不是被重定向到登录页) + current_url = page.url + assert '/login' not in current_url, '已登录用户应该能够访问受保护页面' + + # 检查页面是否包含用户管理相关的元素 + try: + # 等待页面加载 + await page.wait_for_selector('div:has-text("用户管理")', timeout=10000) + has_user_management = True + except Exception: + has_user_management = False + + # 或者检查是否有表格或其他用户管理组件 + table_element = await page.query_selector('el-table') + if not has_user_management and table_element: + has_user_management = True + + assert has_user_management, '应该能够访问用户管理页面' + + await context.close() + await browser.close() diff --git a/shierfeng-fastapi-test/test_pages.py b/shierfeng-fastapi-test/test_pages.py new file mode 100644 index 0000000..4172ef4 --- /dev/null +++ b/shierfeng-fastapi-test/test_pages.py @@ -0,0 +1,119 @@ +import pytest +from playwright.async_api import async_playwright + +from common.config import Config +from common.login_helper import LoginHelper + + +@pytest.mark.asyncio +async def test_dashboard_page() -> None: + """测试仪表盘页面""" + # 首先登录获取token + helper = LoginHelper() + token = helper.login(username='admin', password='admin123') + assert token is not None, '登录应该成功' + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context() + # 设置认证token + await context.add_cookies( + [ + { + 'name': 'Admin-Token', + 'value': token, + 'domain': 'localhost', + 'path': '/', + 'httpOnly': False, + 'secure': False, + } + ] + ) + page = await context.new_page() + + # 访问仪表盘页面 + await page.goto(Config.frontend_url + '/index') + + # 检查页面是否包含仪表盘相关元素 + await page.wait_for_selector('div:has-text("首页")', timeout=10000) + title = await page.inner_text('div:has-text("首页")') + assert '首页' in title + + await context.close() + await browser.close() + + +@pytest.mark.asyncio +async def test_druid_page() -> None: + """测试数据监控页面""" + # 首先登录获取token + helper = LoginHelper() + token = helper.login(username='admin', password='admin123') + assert token is not None, '登录应该成功' + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context() + # 设置认证token + await context.add_cookies( + [ + { + 'name': 'Admin-Token', + 'value': token, + 'domain': 'localhost', + 'path': '/', + 'httpOnly': False, + 'secure': False, + } + ] + ) + page = await context.new_page() + + # 访问数据库监控页面 + await page.goto(Config.frontend_url + '/monitor/druid') + + # 检查页面是否包含缓存监控相关元素 + await page.wait_for_selector('div:has-text("数据监控")', timeout=10000) + title = await page.inner_text('div:has-text("数据监控")') + assert '数据监控' in title + + await context.close() + await browser.close() + + +@pytest.mark.asyncio +async def test_build_page() -> None: + """测试表单构建页面""" + # 首先登录获取token + helper = LoginHelper() + token = helper.login(username='admin', password='admin123') + assert token is not None, '登录应该成功' + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context() + # 设置认证token + await context.add_cookies( + [ + { + 'name': 'Admin-Token', + 'value': token, + 'domain': 'localhost', + 'path': '/', + 'httpOnly': False, + 'secure': False, + } + ] + ) + page = await context.new_page() + + # 访问数据库监控页面 + await page.goto(Config.frontend_url + '/tool/build') + + # 检查页面是否包含缓存监控相关元素 + await page.wait_for_selector('div:has-text("Form Generator")', timeout=10000) + title = await page.inner_text('div:has-text("Form Generator")') + assert '表单构建' in title + + await context.close() + await browser.close() diff --git a/shierfeng-fastapi-test/tool/__init__.py b/shierfeng-fastapi-test/tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shierfeng-fastapi-test/tool/test_code_gen.py b/shierfeng-fastapi-test/tool/test_code_gen.py new file mode 100644 index 0000000..7678b8a --- /dev/null +++ b/shierfeng-fastapi-test/tool/test_code_gen.py @@ -0,0 +1,228 @@ +import re + +import pytest +from playwright.async_api import async_playwright, expect + +from common.base_page_test import BasePageTest +from common.config import Config + + +class GenTableTest(BasePageTest): + """代码生成业务测试""" + + async def test_gen_code_flow(self) -> None: + """测试代码生成全流程:导入 -> 编辑 -> 预览 -> 删除""" + table_name = 'sys_post' + + # 1. 导航到代码生成页面 + await self.navigate_to_gen_page() + + # 确保环境清理 (如果存在 sys_post 则先删除) + await self.ensure_table_not_exists(table_name) + + # 2. 导入表 + await self.import_table(table_name) + + # 3. 编辑表 + await self.edit_table(table_name, '测试生成') + + # 4. 预览代码 + await self.preview_code(table_name) + await self.page.wait_for_timeout(1000) + + # 5. 删除表 (清理环境) + await self.delete_table(table_name) + + async def navigate_to_gen_page(self) -> None: + """导航到代码生成页面""" + await self.goto_page(Config.frontend_url + '/tool/gen') + await self.wait_for_selector('.app-container') + + async def ensure_table_not_exists(self, table_name: str) -> None: + """确保表不存在 (如果存在则删除)""" + await self.delete_table(table_name, verify=False) + + async def search_table(self, table_name: str) -> None: + """搜索表""" + # 确保在主搜索表单中搜索 + search_form = self.page.locator('.el-form').first + await search_form.get_by_role('textbox', name='表名称').fill(table_name) + await search_form.get_by_role('button', name='搜索').click() + # 等待加载 + loading = self.page.locator('.el-loading-mask') + if await loading.count() > 0: + await expect(loading.first).to_be_hidden(timeout=10000) + await self.page.wait_for_timeout(300) + + async def import_table(self, table_name: str) -> None: + """导入表""" + await self.page.get_by_role('button', name='导入').click() + + dialog = self.page.locator("div[role='dialog'][aria-label='导入表']") + await dialog.wait_for() + + # 搜索要导入的表 + await dialog.get_by_placeholder('请输入表名称').fill(table_name) + await dialog.get_by_role('button', name='搜索').click() + + # 等待搜索结果 + await dialog.locator(f"tr:has-text('{table_name}')").wait_for() + + # 选中行 + row = dialog.locator('tr').filter(has=self.page.get_by_text(table_name, exact=True)) + + # 点击复选框 + checkbox = row.locator('.el-checkbox') + await checkbox.click() + + # 验证已选中 + # Element Plus checkbox 选中时,最外层 label.el-checkbox 会有 is-checked 类 + await expect(checkbox).to_have_class(re.compile(r'is-checked')) + + await self.page.wait_for_timeout(500) + + await dialog.get_by_role('button', name='确 定').click() + + # 检查是否有"请选择要导入的表"错误 + try: + await expect(self.page.get_by_text('请选择要导入的表')).to_be_visible(timeout=2000) + print("ERROR: Selection failed, '请选择要导入的表' appeared.") + except AssertionError: + pass + + # 等待一会,让弹窗自动关闭 + await self.page.wait_for_timeout(5000) + + # 如果弹窗还在,尝试关闭它以免阻塞后续操作 + if await dialog.is_visible(): + print('WARNING: Import dialog still visible after timeout. Forcing close.') + # Check for error messages + if await self.page.locator('.el-message--error').count() > 0: + msg = await self.page.locator('.el-message--error').all_inner_texts() + print(f'ERROR MESSAGE: {msg}') + + # 点击取消关闭弹窗 + await dialog.get_by_role('button', name='取 消').click() + await expect(dialog).to_be_hidden() + + # 手动刷新列表 + await self.page.get_by_role('button', name='搜索').click() + + # 验证导入成功 (搜索并在列表中看到) + # 使用 .app-container 限定在主页面表格,避免匹配到弹窗中的隐藏行 + await self.search_table(table_name) + row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter( + has=self.page.get_by_text(table_name, exact=True) + ) + for _i in range(5): + try: + await expect(row.first).to_be_visible(timeout=3000) + break + except AssertionError: + await self.search_table(table_name) + await expect(row.first).to_be_visible(timeout=5000) + + async def edit_table(self, table_name: str, remark: str) -> None: + """编辑表""" + await self.search_table(table_name) + row = self.page.locator(f"tbody tr:has-text('{table_name}')") + + # 点击编辑 (操作列第2个按钮,索引1) + # 按钮顺序: 预览, 编辑, 删除, 同步, 生成 + await row.locator('button').nth(1).click() + + # 等待编辑页面 (tab页) + await self.page.wait_for_selector("div[role='tablist']") + + # 修改基本信息 -> 表描述 + # 确保在基本信息 Tab + await self.page.get_by_text('基本信息').click() + await self.page.get_by_role('textbox', name='表描述').fill(remark) + + # 提交 + await self.page.get_by_role('button', name='提交').click() + + # 验证回到列表 + await self.wait_for_selector('.app-container') + # 验证描述已更新 + await self.search_table(table_name) + await self.wait_for_selector(f"tbody tr:has-text('{remark}')") + + async def preview_code(self, table_name: str) -> None: + """预览代码""" + await self.search_table(table_name) + row = self.page.locator(f"tbody tr:has-text('{table_name}')") + + # 点击预览 (操作列第1个按钮,索引0) + await row.locator('button').nth(0).click() + + # 等待预览弹窗 + dialog = self.page.locator("div[role='dialog'][aria-label='代码预览']") + await dialog.wait_for() + + # 验证存在代码内容 + # pre 可能有多个(多tab),只检查可见的 + await dialog.locator('pre:visible').first.wait_for() + content = await dialog.locator('pre:visible').first.text_content() + assert 'class' in content or 'import' in content or 'package' in content + + # 关闭预览 (点击右上角关闭按钮) + await dialog.locator('.el-dialog__headerbtn').click() + + async def delete_table(self, table_name: str, verify: bool = True) -> None: + """删除表 (支持删除多条重复数据)""" + # 循环删除直到不存在 + for _i in range(5): + await self.search_table(table_name) + # 使用 strict matching + # 限制在 .app-container .el-table__body-wrapper 以避免固定列导致的重复 以及 避免匹配到弹窗中的行 + row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter( + has=self.page.get_by_text(table_name, exact=True) + ) + + count = await row.count() + + if count == 0: + break + + # 针对第一行操作 + # 使用 force=True 确保点击,防止遮挡 + btns = row.first.locator('button') + await btns.nth(2).click(force=True) + + # 处理确认弹窗 + await self.page.get_by_role('button', name='确定').click() + + # 等待删除成功提示 + # 使用 specific selector 避免匹配到代码预览中的文本 + await expect(self.page.locator('.el-message__content').filter(has_text='删除成功')).to_be_visible( + timeout=5000 + ) + + # 等待提示消失,防止干扰下一次操作 + await expect(self.page.locator('.el-message__content').filter(has_text='删除成功')).to_be_hidden( + timeout=5000 + ) + + if verify: + # 验证删除成功 + # 重新搜索验证不存在 + await self.search_table(table_name) + # 使用 strict matching + row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter( + has=self.page.get_by_text(table_name, exact=True) + ) + # 期望找不到或者 count为0 + await expect(row).to_have_count(0) + + +@pytest.mark.asyncio +async def test_gen_table_page() -> None: + """测试代码生成页面功能""" + async with async_playwright() as p: + test_instance = GenTableTest() + await test_instance.setup(p) + try: + await test_instance.test_gen_code_flow() + finally: + await test_instance.teardown() diff --git a/shierfeng-fastapi-test/tool/test_swagger.py b/shierfeng-fastapi-test/tool/test_swagger.py new file mode 100644 index 0000000..1a86d6b --- /dev/null +++ b/shierfeng-fastapi-test/tool/test_swagger.py @@ -0,0 +1,38 @@ +import pytest +from playwright.async_api import async_playwright, expect + +from common.base_page_test import BasePageTest +from common.config import Config + + +class SwaggerTest(BasePageTest): + async def check_swagger_interface(self) -> None: + """测试系统接口页面 (Swagger UI)""" + + # 1. 直接导航到系统接口页面 + await self.goto_page(Config.frontend_url + '/tool/swagger') + + # 2. 验证页面加载 + # 等待 iframe 出现 + iframe = self.page.locator('iframe') + await expect(iframe).to_be_visible() + + # 获取 iframe 内容框架 + frame = self.page.frame_locator('iframe') + + # 后端未禁用Swagger时,验证 iframe 内部的标题包含 "ShiErFeng-FastAPI" + # 当前生产环境已默认禁用Swagger,此处验证标题是否包含默认禁用提示 + h1_locator = frame.locator('h1') + await expect(h1_locator).to_contain_text('Swagger UI has been disabled. Please enable it first.', timeout=15000) + + +@pytest.mark.asyncio +async def test_swagger_page() -> None: + """测试系统接口页面功能""" + async with async_playwright() as p: + test_instance = SwaggerTest() + await test_instance.setup(p) + try: + await test_instance.check_swagger_interface() + finally: + await test_instance.teardown()