From 9a71aa8c9397e71419b7d52b6f4a996a687c312c Mon Sep 17 00:00:00 2001 From: NoahLan <6995syu@163.com> Date: Sun, 10 May 2026 03:15:18 +0800 Subject: [PATCH] refactor: rebuild fastapi framework foundation --- README.md | 75 ++- copier-template/README.md.jinja | 59 +-- copier-template/app.py.jinja | 13 +- copier-template/config.py.jinja | 44 +- copier-template/copier.yml | 4 +- copier-template/migrations/README.md.jinja | 14 +- copier-template/migrations/alembic.ini | 7 +- copier-template/migrations/env.py | 73 +-- copier-template/pyproject.toml.jinja | 1 + copier-template/tests/test_example.py.jinja | 10 +- .../models/example/example.py.jinja | 10 +- .../modules/example/module.py.jinja | 16 +- .../modules/example/routes.py.jinja | 12 +- docs/ARCHITECTURE.md | 169 ++++--- docs/AUDIT.md | 76 +++ docs/CONFIGURATION.md | 163 +++--- docs/COPIER_TEMPLATE.md | 47 +- docs/FRONTEND_ADMIN_API_CONTRACT.md | 189 +++++++ docs/MIGRATIONS.md | 80 +-- docs/MODULES.md | 108 ++-- docs/README.md | 3 + docs/SEEDS.md | 51 +- docs/SERVICE_CLIENT.md | 68 ++- docs/TASKS.md | 5 +- docs/TESTING_DEPLOYMENT.md | 78 +++ iti/__init__.py | 6 +- iti/app.py | 314 +++++++++++- iti/applications/__init__.py | 139 ----- iti/applications/common/__init__.py | 2 - iti/applications/common/crud.py | 229 --------- iti/applications/common/storage/manager.py | 110 ---- iti/applications/common/utils/__init__.py | 27 - iti/applications/common/utils/cache.py | 54 -- iti/applications/common/utils/http.py | 268 ---------- iti/applications/common/utils/schema.py | 189 ------- iti/applications/common/utils/sqlalchemy.py | 29 -- .../common/utils/sys_log_helper.py | 50 -- iti/applications/common/utils/validate.py | 240 --------- iti/applications/extensions/__init__.py | 41 -- iti/applications/extensions/cache.py | 15 - iti/applications/extensions/db.py | 124 ----- iti/applications/extensions/encoder.py | 92 ---- iti/applications/extensions/error_handler.py | 101 ---- iti/applications/extensions/error_views.py | 31 -- iti/applications/extensions/event_bus.py | 10 - .../extensions/eventbus/__init__.py | 6 - .../extensions/eventbus/event_bus.py | 477 ------------------ .../extensions/eventbus/event_handler.py | 70 --- .../extensions/eventbus/event_middleware.py | 19 - iti/applications/extensions/http.py | 29 -- iti/applications/extensions/jwt.py | 30 -- iti/applications/extensions/limit.py | 40 -- iti/applications/extensions/migrate.py | 11 - iti/applications/extensions/moment.py | 8 - iti/applications/extensions/plugins.py | 62 --- iti/applications/routes/__init__.py | 11 - iti/applications/routes/front.py | 43 -- iti/applications/service/__init__.py | 4 - iti/audit.py | 257 ++++++++++ iti/auth/__init__.py | 33 ++ iti/auth/jwt.py | 66 +++ iti/auth/permissions.py | 196 +++++++ iti/cache/__init__.py | 37 ++ iti/cli.py | 32 ++ .../common/enums/__init__.py | 0 iti/{applications => }/common/enums/sys.py | 0 .../common/exceptions/biz_exp.py | 4 +- .../common/exceptions/permission.py | 5 +- iti/{applications => }/common/filter.py | 3 +- iti/{applications => }/common/logger.py | 0 .../common/utils/str.py => common/strings.py} | 0 .../common/utils => common}/time.py | 0 .../common/utils => common}/tree.py | 0 iti/config.py | 400 +++++---------- iti/db/__init__.py | 13 + iti/db/base.py | 54 ++ iti/db/session.py | 76 +++ iti/events/__init__.py | 3 + iti/events/bus.py | 40 ++ iti/exceptions.py | 37 ++ iti/health/__init__.py | 3 + iti/health/routes.py | 29 ++ iti/limiter/__init__.py | 58 +++ iti/logging_config.py | 84 +++ iti/modules/base.py | 9 +- iti/modules/registry.py | 6 +- iti/responses/__init__.py | 4 + iti/responses/auto.py | 32 ++ iti/responses/envelope.py | 53 ++ iti/schemas.py | 26 + iti/service_client/__init__.py | 5 +- iti/service_client/client.py | 116 ++--- iti/service_client/errors.py | 19 + iti/service_client/registry.py | 17 +- .../common => }/storage/__init__.py | 0 .../common => }/storage/aliyun_oss.py | 0 .../common => }/storage/huawei_obs.py | 0 .../common => }/storage/interface.py | 0 .../common => }/storage/local.py | 0 iti/storage/manager.py | 80 +++ .../common => }/storage/minio_storage.py | 0 .../common => }/storage/qiniu_kodo.py | 0 .../common => }/storage/tencent_cos.py | 0 iti/tasks/runner.py | 14 +- iti/templates/errors/400.html | 307 ----------- iti/templates/errors/403.html | 313 ------------ iti/templates/errors/404.html | 312 ------------ iti/templates/errors/500.html | 360 ------------- iti/templates/errors/footer.html | 89 ---- iti/templates/errors/header.html | 252 --------- migrations/alembic.ini | 7 +- migrations/env.py | 109 +--- ...3_7de264f96a03_framework_initial_schema.py | 359 ------------- pyproject.toml | 42 +- tests/__init__.py | 3 - tests/test_app.py | 112 ++++ tests/test_audit.py | 93 ++++ tests/test_auth.py | 107 ++++ tests/test_config.py | 165 +----- tests/test_http_utils.py | 418 --------------- tests/test_limiter.py | 109 ---- tests/test_modules.py | 82 +-- tests/test_responses.py | 25 + tests/test_service_client.py | 131 +++-- tests/test_storage.py | 18 + tests/test_tasks.py | 76 +-- 126 files changed, 3080 insertions(+), 6476 deletions(-) create mode 100644 docs/AUDIT.md create mode 100644 docs/FRONTEND_ADMIN_API_CONTRACT.md create mode 100644 docs/TESTING_DEPLOYMENT.md delete mode 100644 iti/applications/__init__.py delete mode 100644 iti/applications/common/__init__.py delete mode 100644 iti/applications/common/crud.py delete mode 100644 iti/applications/common/storage/manager.py delete mode 100644 iti/applications/common/utils/__init__.py delete mode 100644 iti/applications/common/utils/cache.py delete mode 100644 iti/applications/common/utils/http.py delete mode 100644 iti/applications/common/utils/schema.py delete mode 100644 iti/applications/common/utils/sqlalchemy.py delete mode 100644 iti/applications/common/utils/sys_log_helper.py delete mode 100644 iti/applications/common/utils/validate.py delete mode 100644 iti/applications/extensions/__init__.py delete mode 100644 iti/applications/extensions/cache.py delete mode 100644 iti/applications/extensions/db.py delete mode 100644 iti/applications/extensions/encoder.py delete mode 100644 iti/applications/extensions/error_handler.py delete mode 100644 iti/applications/extensions/error_views.py delete mode 100644 iti/applications/extensions/event_bus.py delete mode 100644 iti/applications/extensions/eventbus/__init__.py delete mode 100644 iti/applications/extensions/eventbus/event_bus.py delete mode 100644 iti/applications/extensions/eventbus/event_handler.py delete mode 100644 iti/applications/extensions/eventbus/event_middleware.py delete mode 100644 iti/applications/extensions/http.py delete mode 100644 iti/applications/extensions/jwt.py delete mode 100644 iti/applications/extensions/limit.py delete mode 100644 iti/applications/extensions/migrate.py delete mode 100644 iti/applications/extensions/moment.py delete mode 100644 iti/applications/extensions/plugins.py delete mode 100644 iti/applications/routes/__init__.py delete mode 100644 iti/applications/routes/front.py delete mode 100644 iti/applications/service/__init__.py create mode 100644 iti/audit.py create mode 100644 iti/auth/__init__.py create mode 100644 iti/auth/jwt.py create mode 100644 iti/auth/permissions.py create mode 100644 iti/cache/__init__.py rename iti/{applications => }/common/enums/__init__.py (100%) rename iti/{applications => }/common/enums/sys.py (100%) rename iti/{applications => }/common/exceptions/biz_exp.py (85%) rename iti/{applications => }/common/exceptions/permission.py (67%) rename iti/{applications => }/common/filter.py (98%) rename iti/{applications => }/common/logger.py (100%) rename iti/{applications/common/utils/str.py => common/strings.py} (100%) rename iti/{applications/common/utils => common}/time.py (100%) rename iti/{applications/common/utils => common}/tree.py (100%) create mode 100644 iti/db/__init__.py create mode 100644 iti/db/base.py create mode 100644 iti/db/session.py create mode 100644 iti/events/__init__.py create mode 100644 iti/events/bus.py create mode 100644 iti/exceptions.py create mode 100644 iti/health/__init__.py create mode 100644 iti/health/routes.py create mode 100644 iti/limiter/__init__.py create mode 100644 iti/logging_config.py create mode 100644 iti/responses/__init__.py create mode 100644 iti/responses/auto.py create mode 100644 iti/responses/envelope.py create mode 100644 iti/schemas.py rename iti/{applications/common => }/storage/__init__.py (100%) rename iti/{applications/common => }/storage/aliyun_oss.py (100%) rename iti/{applications/common => }/storage/huawei_obs.py (100%) rename iti/{applications/common => }/storage/interface.py (100%) rename iti/{applications/common => }/storage/local.py (100%) create mode 100644 iti/storage/manager.py rename iti/{applications/common => }/storage/minio_storage.py (100%) rename iti/{applications/common => }/storage/qiniu_kodo.py (100%) rename iti/{applications/common => }/storage/tencent_cos.py (100%) delete mode 100644 iti/templates/errors/400.html delete mode 100644 iti/templates/errors/403.html delete mode 100644 iti/templates/errors/404.html delete mode 100644 iti/templates/errors/500.html delete mode 100644 iti/templates/errors/footer.html delete mode 100644 iti/templates/errors/header.html delete mode 100644 migrations/versions/20260508_0433_7de264f96a03_framework_initial_schema.py delete mode 100644 tests/__init__.py create mode 100644 tests/test_app.py create mode 100644 tests/test_audit.py create mode 100644 tests/test_auth.py delete mode 100644 tests/test_http_utils.py delete mode 100644 tests/test_limiter.py create mode 100644 tests/test_responses.py create mode 100644 tests/test_storage.py diff --git a/README.md b/README.md index 08465d5..00afad5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ # iTi-Flask -iTi-Flask 是基于 APIFlask / Flask 的后端框架基座。 - -它只提供应用创建、配置、扩展、模块协议、服务调用、任务运行、迁移集成和基础工具。 -它不是业务系统。 -系统业务能力不在框架内。 -需要系统业务时,业务项目使用独立包 `iTi-System`。 - -## 技术栈 - -- Python 3.11+ -- APIFlask / Flask -- SQLAlchemy / Flask-Migrate -- Flask-JWT-Extended -- Flask-Limiter -- Flask-Caching -- Marshmallow -- httpx -- uv -- Copier +iTi-Flask 是 FastAPI 后端框架基座。 +名称保留历史包名,运行时已经不依赖 Flask/APIFlask。 + +它提供业务项目常用的通用能力: + +- FastAPI 应用工厂。 +- dataclass 配置和 `.env` 加载。 +- MySQL 默认数据库配置。 +- SQLAlchemy 2 和 Alembic。 +- JWT、权限依赖、错误处理、响应包装。 +- 用户 token / 服务 token 的统一 Actor 依赖。 +- 缓存、限流、事件总线。 +- 模块注册、权限元数据、菜单 seed 元数据。 +- 同步 HTTP 服务客户端。 +- 运行日志和审计事件 sender。 +- 单机轻量任务 runner。 +- `/health` 和 `/ready` 健康检查。 +- Copier 业务项目模板。 + +系统业务不在框架内。 +需要用户、角色、菜单、字典、文件、日志等能力时,业务项目额外注册 `iti-system`。 ## 安装 @@ -28,55 +30,42 @@ iTi-Flask 是基于 APIFlask / Flask 的后端框架基座。 uv sync --extra dev ``` -业务项目通过 Git tag 依赖框架: +业务项目依赖: ```toml dependencies = [ - "iti-flask @ git+ssh://git@example.com/iTi-Flask.git@v0.1.1", + "iti-flask @ git+ssh://git@your-git/iTi/iTi-Flask.git@v0.1.1", ] ``` -可选依赖按需启用: - -```bash -uv sync --extra dev --extra mysql --extra image --extra excel -``` - ## 应用工厂 -业务项目使用 `iti.applications.create_app()` 创建 Flask 应用: - ```python -from iti.applications import create_app +from iti import create_app from config import config -from my_app.models import import_models -from my_app.modules.example.module import ExampleModule +from my_app.modules.example import ExampleModule app = create_app( config_mapping=config, - model_imports=[import_models], modules=[ExampleModule()], ) ``` -`config_mapping` 用于传入业务项目自己的配置类。 -`model_imports` 用于让 Alembic 自动发现业务模型。 -`modules` 用于注册进程内业务模块。 - -## 业务项目生成 +运行: ```bash -uvx copier copy ./copier-template ../my-business-app +uv run uvicorn app:app --reload ``` -生成后进入业务项目: +## 业务项目生成 ```bash +uvx copier copy ./copier-template ../my-business-app cd ../my-business-app uv sync --extra dev -uv run python -m flask --app app.py db upgrade -uv run python -m flask --app app.py run --debug +uv run alembic upgrade head +uv run uvicorn app:app --reload ``` ## 文档 @@ -90,3 +79,5 @@ uv run python -m flask --app app.py run --debug - [数据库迁移](docs/MIGRATIONS.md) - [种子数据](docs/SEEDS.md) - [Copier 模板](docs/COPIER_TEMPLATE.md) +- [前端管理端接口契约](docs/FRONTEND_ADMIN_API_CONTRACT.md) +- [测试与部署方案](docs/TESTING_DEPLOYMENT.md) diff --git a/copier-template/README.md.jinja b/copier-template/README.md.jinja index 140d56d..57526bd 100644 --- a/copier-template/README.md.jinja +++ b/copier-template/README.md.jinja @@ -1,62 +1,47 @@ # {{ project_name }} -这是由 iTi-Flask Copier 模板生成的业务后端项目。 +FastAPI 业务后端项目。 +由 iTi-Flask Copier 模板生成。 -业务代码只扩展框架。 -不要复制、覆盖或修改框架内部实现。 +## 依赖 + +默认使用私有 Git 依赖。 +推荐固定 tag,也允许 branch。 +`file://` 只建议框架开发者本机验证模板时使用。 ## 初始化 ```bash uv sync --extra dev -uv run python -m flask --app app.py db upgrade +uv run alembic upgrade head ``` -## 开发 +{% if include_system %} +同步系统 migration 和 seed: ```bash -uv run python -m flask --app app.py run --debug -uv run --extra dev pytest +uv run iti-system migrations sync --target migrations/versions +uv run alembic upgrade head +uv run iti-system seed system app:app ``` +{% endif %} -## 数据库迁移 - -生成 migration: +## 开发 ```bash -uv run python -m flask --app app.py db migrate -m "alice add example table" +uv run uvicorn app:app --reload +uv run pytest -q ``` -升级数据库: +## 数据库迁移 ```bash -uv run python -m flask --app app.py db upgrade +uv run alembic revision --autogenerate -m "alice add example table" +uv run alembic upgrade head ``` 规则: - `migrations/versions` 必须提交。 -- migration message 第一个词写作者名,后面自由描述。 -- 生产只从 `main` 执行 `flask db upgrade`。 -- 框架底座不提供系统业务 migration。 - -## 系统业务 - -{% if include_system %} -本项目已引入 `iti-system`。 -初始化和维护命令以 `iTi-System` 文档为准。 -{% else %} -本项目未引入 `iti-system`。 -因此没有系统业务能力。 - -后续需要系统业务时,增加 `iti-system` 依赖,并在 `app.py` 注册: - -```python -from iti_system import create_system_module - -modules=[ - ExampleModule(), - create_system_module(), -] -``` -{% endif %} +- migration message 第一个词写作者名。 +- 生产只从 `main` 执行 `alembic upgrade head`。 diff --git a/copier-template/app.py.jinja b/copier-template/app.py.jinja index a374e15..c4d4f0a 100644 --- a/copier-template/app.py.jinja +++ b/copier-template/app.py.jinja @@ -1,9 +1,8 @@ -from iti.applications import create_app +from iti import create_app {% if include_system %}from iti_system import create_system_module {% endif -%} from config import config -from {{ project_slug }}.models import import_models from {{ project_slug }}.modules.example.module import ExampleModule @@ -14,12 +13,4 @@ modules = [ ] -app = create_app( - config_mapping=config, - model_imports=[import_models], - modules=modules, -) - - -if __name__ == "__main__": - app.run(debug=True) +app = create_app(config_mapping=config, modules=modules) diff --git a/copier-template/config.py.jinja b/copier-template/config.py.jinja index c346f7b..b11e963 100644 --- a/copier-template/config.py.jinja +++ b/copier-template/config.py.jinja @@ -1,33 +1,39 @@ from pathlib import Path -from iti.config import DevConfig as BaseDevConfig -from iti.config import ProdConfig as BaseProdConfig -from iti.config import TestConfig as BaseTestConfig +from iti.config import BaseConfig, DevConfig as BaseDevConfig, ProdConfig as BaseProdConfig BASE_DIR = Path(__file__).resolve().parent class DevConfig(BaseDevConfig): - BASE_DIR = BASE_DIR - SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE_DIR / 'runtime' / '{{ project_slug }}_dev.db'}" - FILE_STORAGE = { - **BaseDevConfig.FILE_STORAGE, - "LOCAL": { - **BaseDevConfig.FILE_STORAGE.get("LOCAL", {}), - "base_path": str(BASE_DIR / "runtime" / "uploads"), - }, - } - - -class TestConfig(BaseTestConfig): - BASE_DIR = BASE_DIR - SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + def __init__(self) -> None: + super().__init__() + self.app_name = "{{ project_name }}" + self.base_dir = BASE_DIR + self.file_storage["LOCAL"]["base_path"] = str(BASE_DIR / "runtime" / "uploads") + self.log_dir = str(BASE_DIR / "runtime" / "logs") + + +class TestConfig(BaseConfig): + def __init__(self) -> None: + super().__init__( + app_name="{{ project_name }}", + app_env="test", + testing=True, + database_url="sqlite+pysqlite:///:memory:", + base_dir=BASE_DIR, + ratelimit_enabled=False, + log_file_enabled=False, + ) class ProdConfig(BaseProdConfig): - BASE_DIR = BASE_DIR - pass + def __init__(self) -> None: + super().__init__() + self.app_name = "{{ project_name }}" + self.base_dir = BASE_DIR + self.log_dir = str(BASE_DIR / "runtime" / "logs") config = { diff --git a/copier-template/copier.yml b/copier-template/copier.yml index 1f06429..7703183 100644 --- a/copier-template/copier.yml +++ b/copier-template/copier.yml @@ -11,7 +11,7 @@ project_slug: framework_git: type: str help: iTi-Flask Git 地址 - default: git+ssh://git@example.com/iTi-Flask.git + default: git+ssh://git@your-git/iTi/iTi-Flask.git framework_tag: type: str @@ -26,7 +26,7 @@ include_system: system_git: type: str help: iTi-System Git 地址 - default: git+ssh://git@example.com/iTi-System.git + default: git+ssh://git@your-git/iTi/iTi-System.git system_tag: type: str diff --git a/copier-template/migrations/README.md.jinja b/copier-template/migrations/README.md.jinja index 134de20..04030a8 100644 --- a/copier-template/migrations/README.md.jinja +++ b/copier-template/migrations/README.md.jinja @@ -2,13 +2,17 @@ 本目录是业务项目唯一的 Alembic migration 流。 -升级数据库: - ```bash -python -m flask --app app.py db upgrade +uv run alembic revision --autogenerate -m "alice add example table" +uv run alembic upgrade head ``` `versions/` 下的 migration 文件必须提交到 Git。 -iTi-Flask 不提供系统业务 migration。 -如果项目依赖 `iti-system`,使用 `iti-system` 自己的迁移同步命令。 +{% if include_system %} +系统 migration 同步: + +```bash +uv run iti-system migrations sync --target migrations/versions +``` +{% endif %} diff --git a/copier-template/migrations/alembic.ini b/copier-template/migrations/alembic.ini index c4d8eba..a12b38c 100644 --- a/copier-template/migrations/alembic.ini +++ b/copier-template/migrations/alembic.ini @@ -1,10 +1,11 @@ # A generic, single database configuration. [alembic] +script_location = migrations file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s [loggers] -keys = root,sqlalchemy,alembic,flask_migrate +keys = root,sqlalchemy,alembic [handlers] keys = console @@ -27,10 +28,6 @@ level = INFO handlers = qualname = alembic -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate [handler_console] class = StreamHandler diff --git a/copier-template/migrations/env.py b/copier-template/migrations/env.py index 6806c6d..78585c7 100644 --- a/copier-template/migrations/env.py +++ b/copier-template/migrations/env.py @@ -1,67 +1,50 @@ -import logging +from __future__ import annotations + +import os from logging.config import fileConfig from alembic import context -from flask import current_app - -config = context.config -fileConfig(config.config_file_name) -logger = logging.getLogger("alembic.env") +from sqlalchemy import engine_from_config, pool +from config import config as app_config +from iti.db import Base -def get_engine(): - try: - return current_app.extensions["migrate"].db.get_engine() - except (TypeError, AttributeError): - return current_app.extensions["migrate"].db.engine +from {{ project_slug }}.models import import_models -def get_engine_url(): - try: - return get_engine().url.render_as_string(hide_password=False).replace("%", "%%") - except AttributeError: - return str(get_engine().url).replace("%", "%%") +import_models() +config = context.config -config.set_main_option("sqlalchemy.url", get_engine_url()) -target_db = current_app.extensions["migrate"].db +if config.config_file_name is not None: + fileConfig(config.config_file_name) +target_metadata = Base.metadata -def get_metadata(): - if hasattr(target_db, "metadatas"): - return target_db.metadatas[None] - return target_db.metadata +def get_url() -> str: + env_name = os.getenv("APP_ENV", os.getenv("ITI_ENV", "dev")) + config_cls = app_config.get(env_name, app_config["default"]) + return os.getenv("DATABASE_URL") or config_cls().database_url -def run_migrations_offline(): - url = config.get_main_option("sqlalchemy.url") - context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) with context.begin_transaction(): context.run_migrations() -def run_migrations_online(): - def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, "autogenerate", False): - script = directives[0] - if script.upgrade_ops.is_empty(): - directives[:] = [] - logger.info("No changes in schema detected.") - - conf_args = current_app.extensions["migrate"].configure_args - if conf_args.get("process_revision_directives") is None: - conf_args["process_revision_directives"] = process_revision_directives - - connectable = get_engine() - +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool) with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args, - ) - + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/copier-template/pyproject.toml.jinja b/copier-template/pyproject.toml.jinja index 1ee315f..c9c375f 100644 --- a/copier-template/pyproject.toml.jinja +++ b/copier-template/pyproject.toml.jinja @@ -17,6 +17,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=7.0.0", + "httpx>=0.27.0", ] [tool.setuptools.packages.find] diff --git a/copier-template/tests/test_example.py.jinja b/copier-template/tests/test_example.py.jinja index 153a38f..ef0dfc1 100644 --- a/copier-template/tests/test_example.py.jinja +++ b/copier-template/tests/test_example.py.jinja @@ -1,8 +1,10 @@ +from fastapi.testclient import TestClient + from app import app -def test_example_ping(): - client = app.test_client() - response = client.get("/example/ping") +def test_health(): + response = TestClient(app).get("/health") + assert response.status_code == 200 - assert response.json["data"]["pong"] is True + assert response.json() == {"status": "ok"} diff --git a/copier-template/{{ project_slug }}/models/example/example.py.jinja b/copier-template/{{ project_slug }}/models/example/example.py.jinja index bf962a2..b1ef80e 100644 --- a/copier-template/{{ project_slug }}/models/example/example.py.jinja +++ b/copier-template/{{ project_slug }}/models/example/example.py.jinja @@ -1,8 +1,10 @@ -from iti.applications.common.crud import BaseModelMixin -from iti.applications.extensions import db +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from iti.db import Base, IdMixin, TimestampMixin -class Example(BaseModelMixin): + +class Example(IdMixin, TimestampMixin, Base): __tablename__ = "biz_example" - name = db.Column(db.String(128), nullable=False, comment="名称") + name: Mapped[str] = mapped_column(String(128), nullable=False, comment="名称") diff --git a/copier-template/{{ project_slug }}/modules/example/module.py.jinja b/copier-template/{{ project_slug }}/modules/example/module.py.jinja index ab26f4b..a7a254a 100644 --- a/copier-template/{{ project_slug }}/modules/example/module.py.jinja +++ b/copier-template/{{ project_slug }}/modules/example/module.py.jinja @@ -1,17 +1,16 @@ -from .routes import bp -from iti.applications.common.enums import MenuTypeEnum -from iti.modules import ModuleMenuSeed, ModulePermission, get_module_registry +from iti.modules import ModuleMenuSeed, ModulePermission + +from .routes import router class ExampleModule: name = "example" def register_routes(self, app): - app.register_blueprint(bp, url_prefix="/example") + app.include_router(router) def register_permissions(self, app): - registry = get_module_registry(app) - registry.register_permission( + app.state.iti_modules.register_permission( ModulePermission( code="example:item:list", name="示例列表", @@ -20,12 +19,11 @@ class ExampleModule: ) def register_menu_seed(self, app): - registry = get_module_registry(app) - registry.register_menu_seed( + app.state.iti_modules.register_menu_seed( ModuleMenuSeed( id="example-menu-root", name="Example", - type=MenuTypeEnum.MENU.value, + type="menu", path="/example", component="/example/list", auth_code="example:item:list", diff --git a/copier-template/{{ project_slug }}/modules/example/routes.py.jinja b/copier-template/{{ project_slug }}/modules/example/routes.py.jinja index cabc03f..61efa3a 100644 --- a/copier-template/{{ project_slug }}/modules/example/routes.py.jinja +++ b/copier-template/{{ project_slug }}/modules/example/routes.py.jinja @@ -1,10 +1,12 @@ -from apiflask import APIBlueprint +from fastapi import APIRouter, Depends -from iti.applications.common.utils import success +from iti.auth import require_permission +from iti.responses import ok -bp = APIBlueprint("example", __name__, tag="Example") +router = APIRouter(prefix="/example", tags=["example"]) -@bp.get("/ping") + +@router.get("/ping", dependencies=[Depends(require_permission("example:item:list"))]) def ping(): - return success({"pong": True}) + return ok({"pong": True}) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d23c10f..ed9dc22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,109 +1,118 @@ # 架构 -iTi-Flask 是框架基座。 -它提供后端项目的通用工程能力,不内置具体业务。 +iTi-Flask 是 FastAPI 框架基座。 +它约束业务项目的工程方式,但不接管业务代码。 + +## 分层 + +- `iti.app`:应用工厂,组装 FastAPI、错误处理、模块、服务客户端、任务 runner、健康检查。 +- `iti.config`:dataclass 配置,默认 dev/test/prod 均使用 MySQL。 +- `iti.db`:SQLAlchemy 2 `Base`、session、Alembic metadata。 +- `iti.auth`:JWT、Principal、Actor、用户权限依赖、服务 token 依赖。 +- `iti.responses`:自动 envelope、`@raw_response`、响应工具。 +- `iti.modules`:模块协议、权限元数据、菜单 seed 元数据。 +- `iti.service_client`:同步 HTTP JSON 服务客户端。 +- `iti.tasks`:单进程轻量任务注册和调度。 +- `iti.audit`:审计事件、diff、脱敏和异步 HTTP sender,不拥有日志表。 +- `iti.logging_config`:运行日志配置。 +- `iti.cache`、`iti.limiter`、`iti.events`:常用通用能力。 +- `iti.health`:`/health` 和 `/ready`。 -## 包定位 +## 应用创建流程 -框架负责: +`create_app()` 按顺序执行: -- 应用工厂。 -- 配置加载。 -- APIFlask 集成。 -- SQLAlchemy 和 Flask-Migrate 初始化。 -- JWT、缓存、限流、日志、错误处理。 -- 模块注册协议。 -- HTTP JSON 服务客户端。 -- 单进程任务运行器。 -- Copier 业务项目模板。 -- 可选 SPA 静态目录承载。 -- 默认 HTML 错误页。 +1. 解析配置。 +2. 创建 `FastAPI`。 +3. 写入 `app.state.config/cache/limiter/permission_provider`。 +4. 配置 SQLAlchemy engine 和 sessionmaker。 +5. 注册中间件、错误处理和运行日志。 +6. 初始化服务客户端。 +7. 初始化任务 runner。 +8. 初始化审计 sender。 +9. 初始化模块并运行 `init_app`、`register_tasks`。 +10. 注册健康检查。 +11. 注册模块路由、权限元数据和菜单 seed 元数据。 +12. 安装自动 envelope。 + +## API 约定 + +业务 JSON API 默认返回 HTTP 200 envelope: -框架不负责: +```json +{"data": {}, "code": 200, "message": "成功"} +``` -- 系统业务。 -- 业务表和业务路由。 -- 业务 seed。 -- 业务前端构建产物。 +业务错误也包装进 envelope: -需要系统业务时,业务项目额外依赖 `iTi-System`。 +```json +{"data": null, "code": 403, "message": "权限不足"} +``` -## 应用创建流程 +成功时 `code` 固定为 `200`。 +业务错误保留原始业务码,例如 `403`、`404`、`429`。 -`create_app()` 会按顺序完成这些工作: +服务间 API 也默认使用 envelope。 +框架服务客户端会自动识别 envelope 和 HTTP status。 +如果响应体是 envelope,则优先按 `code` 判断业务结果。 +框架仍允许 raw 响应: -1. 解析配置。 -2. 创建 `APIFlask` 应用。 -3. 初始化日志、插件、HTTP、JSON、Moment。 -4. 初始化数据库、JWT、迁移、限流、缓存、事件总线。 -5. 导入业务模型。 -6. 初始化服务客户端和任务运行器。 -7. 注册模块的 `init_app` 和 `register_commands`。 -8. 注册可选 SPA 路由。 -9. 注册模块路由、权限元数据和菜单元数据。 -10. 初始化服务层。 - -## 扩展组件 - -常用扩展从 `iti.applications.extensions` 引入: - -```python -from iti.applications.extensions import db, migrate, jwt, limiter -from iti.applications.extensions import cache_simple, cache_redis, eventbus -``` +- `@raw_response` +- `raw_response_paths` +- `Response` / `FileResponse` / `StreamingResponse` -框架还提供: +默认 raw: -- `iti.applications.common.utils.success` -- `iti.applications.common.utils.fail` -- `iti.applications.common.utils.page` -- `iti.applications.common.utils.pagination_builder` +- `/health` +- `/ready` +- `/docs` +- `/openapi.json` +- `/redoc` -后台 API 默认使用响应 envelope: +## 鉴权 -```json -{"data": {}, "code": 200, "message": "成功"} -``` +用户接口使用 JWT。 +服务间调用使用静态服务 token。 -服务间 API 应使用真实 HTTP 状态码。 +双入口接口使用 `require_actor()`: -## 默认错误页 +- 用户 token 走权限码。 +- 服务 token 只校验可信服务。 +- 任一通过即可。 -框架内置 `403`、`404`、`500` HTML 错误页。 +## 审计 -请求更偏向 HTML 时返回模板页。 -请求更偏向 JSON 时返回: +`iti-flask` 只产生和发送审计事件。 +它不写 `sys_log`。 -```json -{"data": null, "code": 404, "message": "Not Found"} -``` +审计事件通过 `service_client` 发到配置的 `audit_service_name`。 +当前常见接收方是注册了 `iti-system` 的主业务项目。 -## SPA 静态目录 +审计发送默认异步、不阻塞业务。 +diff 由业务显式提供 before / after 快照。 -SPA 承载默认关闭。 +## 运行日志 -业务项目开启后,框架从 `FRONTEND_PATH` 读取 `index.html` 和静态文件: +运行日志由框架配置。 +默认控制台输出。 +生产可开启滚动文件: -```python -FRONTEND_ENABLED = True -FRONTEND_PATH = "frontend/dist" -``` +- `runtime/logs/app.log` +- `runtime/logs/error.log` -`FRONTEND_PATH` 可以是绝对路径。 -相对路径按业务项目配置里的 `BASE_DIR` 解析。 +请求摘要默认写入 app log。 +摘要包含 method、path、HTTP status、业务 code、duration、actor、ip 和 trace id。 -## 扩展框架 +## 系统包边界 -业务项目通过模块扩展框架: +`iti-system` 只承载系统域业务: -- 注册业务蓝图。 -- 注册 CLI 命令。 -- 声明权限元数据。 -- 声明菜单 seed 元数据。 -- 增加业务模型。 -- 增加业务配置。 -- 配置服务客户端。 -- 注册任务。 +- auth。 +- user / role / menu / dept。 +- config / dict。 +- file / upload。 +- log 查询和审计接收。 +- user attributes。 -框架问题在框架仓库修复,再发布新 tag。 -业务项目不复制框架源码,也不覆盖框架 import path。 +业务项目单独使用 `iti-flask` 时,仍可使用鉴权依赖、权限 provider、数据库、迁移、缓存、限流、事件和任务。 +加入 `iti-system` 时,只替换权限 provider 并增加系统域路由。 diff --git a/docs/AUDIT.md b/docs/AUDIT.md new file mode 100644 index 0000000..5d7f8a7 --- /dev/null +++ b/docs/AUDIT.md @@ -0,0 +1,76 @@ +# 审计 + +iTi-Flask 只提供审计事件工具和异步发送器。 +它不写 `sys_log`。 + +`sys_log` 由注册了 `iti-system` 的业务项目接收并入库。 + +## 配置 + +```python +class DevConfig(BaseDevConfig): + def __init__(self) -> None: + super().__init__() + self.audit_enabled = True + self.audit_service_name = "audit" + self.services = { + "audit": { + "base_url": "http://hsyh-mes-phase2.local", + "token": "change-me", + } + } +``` + +接收方需要把同一个 token 配进 `service_tokens`。 + +```python +self.service_tokens = {"hsyh-erp": "change-me"} +``` + +## 操作日志 + +业务显式提供 before / after 快照。 +框架负责 diff、脱敏和异步发送。 + +```python +from fastapi import Request +from iti.audit import audit_operation + + +def update_order(order_id: str, request: Request): + before = {"qty": 1} + after = {"qty": 2} + audit_operation( + request, + title="修改生产订单", + target_type="mo", + target_id=order_id, + before=before, + after=after, + ) +``` + +## 登录日志 + +系统包登录接口已调用 `audit_login()`。 +普通业务项目如需自定义登录,也使用同一个工具。 + +```python +from iti.audit import audit_login + + +def login(request): + audit_login(request, success=True, desc="admin") +``` + +## 接收入口 + +`iti-system` 提供: + +```http +POST /internal/audit/events +POST /internal/audit/login +POST /internal/audit/operation +``` + +这些接口使用框架服务 token 鉴权。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a10cce3..47cf5c9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,138 +1,97 @@ # 配置 -iTi-Flask 内置 `dev`、`test`、`prod` 三套配置。 +配置类是 dataclass。 默认环境是 `dev`。 ## 环境选择 ```bash -FLASK_ENV=dev uv run python -m flask --app app.py run --debug +ITI_ENV=dev uv run uvicorn app:app --reload +ITI_ENV=prod uv run uvicorn app:app ``` -`create_app(config_name="test")` 可以直接指定环境。 - -业务项目通常传入自己的配置映射: +也可以显式传入: ```python -from iti.config import DevConfig as BaseDevConfig -from iti.config import ProdConfig as BaseProdConfig -from iti.config import TestConfig as BaseTestConfig - - -class DevConfig(BaseDevConfig): - SQLALCHEMY_DATABASE_URI = "sqlite:///runtime/app_dev.db" - +from iti import create_app -class TestConfig(BaseTestConfig): - SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" - - -class ProdConfig(BaseProdConfig): - pass - - -config = { - "dev": DevConfig, - "test": TestConfig, - "prod": ProdConfig, - "default": DevConfig, -} +app = create_app(config_name="test", config_mapping=config) ``` -## env 文件 - -框架会从当前工作目录加载第一个存在的 env 文件: - -1. `.env.local` -2. `.env.` -3. `.env` - -也可以用 `ITI_ENV_DIR` 指定查找目录: - -```bash -ITI_ENV_DIR=/path/to/app FLASK_ENV=prod uv run python -m flask --app app.py run -``` - -## 常用配置项 - -| 配置项 | 说明 | -| --- | --- | -| `SECRET_KEY` | Flask 密钥 | -| `JWT_SECRET_KEY` | JWT 密钥 | -| `DATABASE_URL` | 数据库连接串 | -| `REDIS_URL` | 生产限流存储地址 | -| `FRONTEND_ENABLED` | 是否启用 SPA 承载 | -| `FRONTEND_PATH` | SPA 构建目录 | -| `SERVICES` | 服务客户端配置 | -| `TASKS_ENABLED` | 是否启动任务调度线程 | - -## 数据库 +## 默认数据库 -默认使用 SQLite。 - -开发环境默认数据库: +dev/test/prod 默认都使用 MySQL。 ```text -runtime/iti-flask_dev.db +mysql+pymysql://root:password@127.0.0.1:3306/iti_dev?charset=utf8mb4 +mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4 +mysql+pymysql://root:password@127.0.0.1:3306/iti_prod?charset=utf8mb4 ``` -测试环境使用内存数据库: +可用环境变量覆盖: -```text -sqlite:///:memory: +```bash +DATABASE_URL=mysql+pymysql://user:pass@host:3306/app?charset=utf8mb4 +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=password +MYSQL_DATABASE=iti_dev ``` -生产环境优先读取 `DATABASE_URL`。 -`postgres://` 会自动转换为 `postgresql://`。 +单元测试可以传入 SQLite 配置。 +这不改变默认配置。 -## 限流 - -默认启用 Flask-Limiter。 +## env 文件 -开发环境: +框架会从当前目录加载第一个存在的文件: -```python -RATELIMIT_ENABLED = True -RATELIMIT_STORAGE_URL = "memory://" -RATELIMIT_DEFAULT = "1000 per hour" -``` - -测试环境禁用限流。 +1. `.env.local` +2. `.env.` +3. `.env` -生产环境默认从 `REDIS_URL` 读取存储地址: +`ITI_ENV_DIR` 可指定查找目录。 -```python -RATELIMIT_DEFAULT = "100 per hour" -``` +## 常用字段 -路由级限流: +| 字段 | 说明 | +| --- | --- | +| `app_name` | FastAPI 标题 | +| `debug` | debug 标记 | +| `secret_key` | 框架密钥 | +| `jwt_secret_key` | JWT 密钥 | +| `database_url` | SQLAlchemy URL | +| `cors_origins` | CORS origin 列表 | +| `health_enabled` | 是否注册 `/health` 和 `/ready` | +| `ready_check_db` | `/ready` 是否执行 DB ping | +| `response_envelope_http_status` | envelope 错误时使用的 HTTP 状态码,默认 200 | +| `response_envelope_enabled` | 是否启用自动 envelope | +| `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*` | +| `ratelimit_enabled` | 是否启用内存限流 | +| `cache_default_timeout` | 默认缓存秒数 | +| `file_storage` | 文件存储配置 | +| `services` | 服务客户端配置 | +| `service_tokens` | 可信服务 token | +| `tasks_enabled` | 是否启动单机任务调度线程 | +| `log_dir` | 运行日志目录 | +| `log_file_enabled` | 是否写滚动日志文件 | +| `audit_enabled` | 是否发送审计事件 | +| `audit_service_name` | 审计接收服务名,默认 `audit` | + +## 业务项目覆盖 ```python -from iti.applications.extensions import limiter - - -@bp.get("/reports") -@limiter.limit("10 per minute") -def list_reports(): - return {"data": []} -``` - -## 缓存 +from dataclasses import dataclass -框架初始化两个缓存配置: - -- `CACHE_SIMPLE` -- `CACHE_REDIS` +from iti.config import DevConfig as BaseDevConfig -本地默认启用 `SimpleCache`。 -Redis 缓存默认关闭。 -## 文件存储 +class DevConfig(BaseDevConfig): + def __init__(self) -> None: + super().__init__() + self.app_name = "my-app" + self.ready_check_db = True -本地文件默认写入: -```text -runtime/uploads +config = {"dev": DevConfig, "default": DevConfig} ``` - -业务项目可以覆盖 `FILE_STORAGE["LOCAL"]["base_path"]`。 diff --git a/docs/COPIER_TEMPLATE.md b/docs/COPIER_TEMPLATE.md index da28baf..b93180c 100644 --- a/docs/COPIER_TEMPLATE.md +++ b/docs/COPIER_TEMPLATE.md @@ -1,26 +1,19 @@ # Copier 模板 -`copier-template` 用于生成业务后端项目。 -模板只生成项目骨架,不复制框架源码。 +`copier-template` 生成 FastAPI 业务后端骨架。 +模板只引用框架包,不复制框架源码。 -## 生成项目 - -在 iTi-Flask 仓库根目录执行: +## 生成 ```bash uvx copier copy ./copier-template ../my-business-app -``` - -进入生成后的项目: - -```bash cd ../my-business-app uv sync --extra dev -uv run python -m flask --app app.py db upgrade -uv run python -m flask --app app.py run --debug +uv run alembic upgrade head +uv run uvicorn app:app --reload ``` -## 模板参数 +## 参数 | 参数 | 说明 | | --- | --- | @@ -32,26 +25,26 @@ uv run python -m flask --app app.py run --debug | `system_git` | iTi-System Git 地址 | | `system_tag` | iTi-System Git tag | -## 生成内容 +默认推荐私有 Git tag。 +允许改成 branch 或 `file://`,但多人协作项目不建议依赖本机路径。 -模板会生成: +## 生成内容 - `app.py` - `config.py` - `pyproject.toml` - `migrations/` -- 业务 Python 包。 -- 示例模块。 -- 示例模型。 -- 示例测试。 - -## 扩展方式 +- 示例 FastAPI 模块 +- 示例 SQLAlchemy 模型 +- 示例测试 -业务项目扩展框架时,只改业务项目自己的文件: +## 系统业务 -- 在 `modules/` 下新增业务模块。 -- 在 `models/` 下新增业务模型。 -- 在 `config.py` 中覆盖配置。 -- 在 `app.py` 中注册模块和模型导入函数。 +选择 `include_system=true` 时,模板会注册 `create_system_module()`。 +生成后执行: -框架升级通过更新 `pyproject.toml` 中的 Git tag 完成。 +```bash +uv run iti-system migrations sync --target migrations/versions +uv run alembic upgrade head +uv run iti-system seed system app:app +``` diff --git a/docs/FRONTEND_ADMIN_API_CONTRACT.md b/docs/FRONTEND_ADMIN_API_CONTRACT.md new file mode 100644 index 0000000..3efc837 --- /dev/null +++ b/docs/FRONTEND_ADMIN_API_CONTRACT.md @@ -0,0 +1,189 @@ +# 前端管理端接口契约 + +本轮不改前端。 +这份文档用于后续管理端接口适配。 + +## 响应包装 + +管理端 API 默认 HTTP 200。 + +成功: + +```json +{"data": {}, "code": 200, "message": "成功"} +``` + +失败: + +```json +{"data": null, "code": 403, "message": "权限不足"} +``` + +字段输出使用 camelCase。 + +## 认证 + +登录: + +```http +POST /auth/loginByPassword +POST /auth/loginByCode +POST /auth/register +POST /auth/refresh +POST /auth/logout +GET /auth/codes +``` + +登录响应核心字段: + +```json +{ + "accessToken": "...", + "tokenType": "Bearer", + "expiresIn": 86400, + "refreshToken": "...", + "refreshExpiresIn": 2592000, + "user": {} +} +``` + +后续请求: + +```http +Authorization: Bearer +``` + +## 系统接口 + +用户: + +```http +GET /sys/user/current +GET /sys/user/list +GET /sys/user/page +POST /sys/user +PUT /sys/user/{id} +DELETE /sys/user/{id} +PUT /sys/user/password +``` + +角色: + +```http +GET /sys/role/list +GET /sys/role/page +POST /sys/role +PUT /sys/role/{id} +DELETE /sys/role/{id} +``` + +菜单: + +```http +GET /sys/menu/list +GET /sys/menu/tree +GET /sys/menu/exists +POST /sys/menu +PUT /sys/menu/{id} +DELETE /sys/menu/{id} +``` + +部门: + +```http +GET /sys/dept/list +GET /sys/dept/page +GET /sys/dept/tree +POST /sys/dept +PUT /sys/dept/{id} +DELETE /sys/dept/{id} +``` + +配置: + +```http +GET /sys/config/list +GET /sys/config/page +POST /sys/config +PUT /sys/config/{id} +DELETE /sys/config/{id} +``` + +字典: + +```http +GET /sys/dict/type/page +GET /sys/dict/type +POST /sys/dict/type +PUT /sys/dict/type/{id} +DELETE /sys/dict/type/{id} +GET /sys/dict/data/page +GET /sys/dict/data/list +GET /sys/dict/data/{id} +GET /sys/dict/data +POST /sys/dict/data +PUT /sys/dict/data/{id} +DELETE /sys/dict/data/{id} +DELETE /sys/dict/data/batch +``` + +日志: + +```http +GET /sys/log/page +DELETE /sys/log/{id} +DELETE /sys/log/batch +``` + +文件: + +```http +POST /upload +POST /upload/chunk/init +POST /upload/chunk/upload +POST /upload/chunk/merge +DELETE /upload/chunk/{uploadId} +GET /upload/chunk/{uploadId}/progress +POST /upload/chunk/cleanup +GET /sys/file/{fileId} +DELETE /sys/file/{fileId} +POST /sys/file/{fileId}/restore +DELETE /sys/file/{fileId}/permanent +POST /sys/file/{fileId}/share +DELETE /sys/file/{fileId}/share +GET /file/{fileId}/download +GET /file/{fileId}/preview +GET /file/{fileId}/thumbnail +GET /file/share/{shareCode} +GET /file/share/{shareCode}/download +``` + +用户扩展属性: + +```http +GET /sys/user-attributes/current +PUT /sys/user-attributes/current +GET /sys/user-attributes/{userId} +PUT /sys/user-attributes/{userId} +GET /sys/user-attributes/{userId}/{group}/{key} +PUT /sys/user-attributes/{userId}/{group}/{key} +DELETE /sys/user-attributes/{userId}/{group} +DELETE /sys/user-attributes/{userId}/{group}/{key} +POST /sys/user-attributes/{userId}/batch +``` + +## 分页 + +分页响应: + +```json +{ + "items": [], + "page": { + "page": 1, + "size": 10, + "pages": 1, + "total": 0 + } +} +``` diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 0c32db8..aaf491a 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -1,75 +1,49 @@ # 数据库迁移 -iTi-Flask 只初始化 Flask-Migrate。 -业务表由业务项目自己维护 migration。 +使用原生 Alembic。 +不再使用 Flask-Migrate。 -## 基本规则 +## 规则 -- 每个业务项目只保留一条 Alembic migration 流。 -- `migrations/versions` 必须提交到 Git。 -- 运行时数据库文件不提交。 -- 已发布的 migration 不回头修改。 -- 生产只执行 `db upgrade`。 +- 每个业务项目只有一条 Alembic migration 流。 +- `migrations/versions` 必须提交。 +- 已发布 migration 不回改。 +- 生产只执行 `alembic upgrade head`。 +- `iti-system` 的 migration 通过 CLI 同步进业务项目 migration 流。 -升级数据库: +## 命令 ```bash -uv run python -m flask --app app.py db upgrade +uv run alembic revision --autogenerate -m "alice add workorder priority" +uv run alembic upgrade head +uv run alembic current +uv run alembic heads ``` -生成 migration: +多个 head: ```bash -uv run python -m flask --app app.py db migrate -m "alice add workorder priority" +uv run alembic merge heads -m "alice merge heads before release" +uv run alembic upgrade head ``` -## 文件命名 +## 模型发现 -模板已配置 migration 文件名格式: +Alembic `env.py` 使用 `iti.db.Base.metadata`。 +业务模型只要继承 `iti.db.Base`,并在 `env.py` 或应用导入链中被 import,即可参与 autogenerate。 -```ini -file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s -``` - -message 第一个词写作者名,后面写变更说明。 - -生成示例: - -```text -20260508_1430_9f8a7c6d2e1a_alice_add_workorder_priority.py -``` - -## 多个 head - -合并分支后检查: +```python +from iti.db import Base -```bash -uv run python -m flask --app app.py db heads -uv run python -m flask --app app.py db current -uv run python -m flask --app app.py db upgrade +class Example(Base): + __tablename__ = "example" ``` -出现多个 head 时合并: +## 同步 iTi-System ```bash -uv run python -m flask --app app.py db merge heads -m "alice merge heads before release" -uv run python -m flask --app app.py db upgrade +uv run iti-system migrations sync --target migrations/versions +uv run alembic upgrade head ``` -发布前应保持一个 head。 - -## 模型导入 - -业务项目通过 `model_imports` 让 Alembic 看到模型: - -```python -from iti.applications import create_app -from my_app.models import import_models - -app = create_app(model_imports=[import_models]) -``` - -## 手工变更 - -不要直接修改生产库结构。 -发生紧急手工变更后,应补 migration,并让目标库回到一致的 Alembic 版本。 +业务项目可在同步后继续新增自己的 migration。 diff --git a/docs/MODULES.md b/docs/MODULES.md index 2e13c1e..b74dac3 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -1,34 +1,18 @@ # 模块协议 -模块用于业务项目内的代码边界。 -模块运行在同一个 Flask 进程内,不是独立服务。 +模块是同一个 FastAPI 进程内的业务边界。 +它不是独立服务。 -## 适用场景 - -适合做模块: - -- 业务项目内部的业务域。 -- 需要独立路由、service、schema 的功能。 -- 共享同一个部署单元和数据库的功能。 - -不适合做模块: - -- 需要独立部署的能力。 -- 有独立数据源的能力。 -- 多个项目跨进程复用的能力。 - -这类能力应通过 HTTP JSON 服务提供。 - -## 注册模块 +## 注册 ```python -from iti.applications import create_app -from my_app.modules.example.module import ExampleModule +from iti import create_app +from my_app.modules.example import ExampleModule app = create_app(modules=[ExampleModule()]) ``` -模块类: +## 模块类 ```python class ExampleModule: @@ -37,85 +21,55 @@ class ExampleModule: def init_app(self, app): pass - def register_commands(self, app): - pass - def register_routes(self, app): - pass + app.include_router(router) def register_permissions(self, app): pass def register_menu_seed(self, app): pass + + def register_tasks(self, app): + pass ``` 执行顺序: 1. `init_app` -2. `register_commands` +2. `register_tasks` 3. `register_routes` 4. `register_permissions` 5. `register_menu_seed` -## 权限元数据 +## 路由 + +使用 FastAPI 原生 `APIRouter`。 ```python -from iti.modules import ModulePermission, get_module_registry +from fastapi import APIRouter +from iti.responses import ok +router = APIRouter(prefix="/example", tags=["example"]) -def register_permissions(self, app): - registry = get_module_registry(app) - registry.register_permission( - ModulePermission( - code="example:item:list", - name="示例列表", - description="查看示例数据", - ) - ) -``` -框架只收集权限元数据。 -授权写库由业务项目或系统业务包处理。 +@router.get("/ping") +def ping(): + return ok({"pong": True}) +``` -## 菜单元数据 +## 权限元数据 ```python -from iti.applications.common.enums import MenuTypeEnum -from iti.modules import ModuleMenuSeed, get_module_registry - - -def register_menu_seed(self, app): - registry = get_module_registry(app) - registry.register_menu_seed( - ModuleMenuSeed( - id="example-menu-root", - name="Example", - type=MenuTypeEnum.MENU.value, - path="/example", - component="/example/list", - auth_code="example:item:list", - meta={"title": "示例模块", "icon": "carbon:application"}, - sort=100, - ) +from iti.modules import ModulePermission + +def register_permissions(self, app): + app.state.iti_modules.register_permission( + ModulePermission("example:item:list", "示例列表") ) ``` -框架只收集菜单元数据。 -是否写入数据库由业务项目决定。 - -## 边界 - -模块可以: - -- 注册蓝图。 -- 注册 CLI 命令。 -- 声明权限和菜单元数据。 -- 使用业务项目自己的 model。 - -模块不应: - -- 修改框架内部实现。 -- 直接导入其它模块的内部 model 或 service。 -- 自建独立 migration 流。 -- 在框架包里写业务数据。 +框架负责收集元数据。 +具体授权由 `PermissionProvider` 决定。 +单独使用 `iti-flask` 时可注入自己的 provider。 +使用 `iti-system` 时由系统包提供数据库 provider。 diff --git a/docs/README.md b/docs/README.md index a2e7174..59f00e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,13 @@ iTi-Flask 文档只描述框架自身。 - [配置](CONFIGURATION.md) - [模块协议](MODULES.md) - [服务客户端](SERVICE_CLIENT.md) +- [审计](AUDIT.md) - [任务运行器](TASKS.md) - [数据库迁移](MIGRATIONS.md) - [种子数据](SEEDS.md) - [Copier 模板](COPIER_TEMPLATE.md) +- [前端管理端接口契约](FRONTEND_ADMIN_API_CONTRACT.md) +- [测试与部署方案](TESTING_DEPLOYMENT.md) ## 常用命令 diff --git a/docs/SEEDS.md b/docs/SEEDS.md index ff1e23a..b542788 100644 --- a/docs/SEEDS.md +++ b/docs/SEEDS.md @@ -1,53 +1,34 @@ # 种子数据 -iTi-Flask 不提供框架 seed。 -业务 seed 由业务项目维护。 +框架不写业务 seed。 +业务项目和 `iti-system` 各自维护自己的 seed。 ## 规则 -seed 代码必须: - - 幂等。 -- 可重复执行。 - 按唯一键 upsert。 - 不删除用户数据。 -- 不替代 migration 修改表结构。 +- 不替代 migration。 +- 可重复执行。 -seed 适合写: +## iTi-System -- 业务默认配置。 -- 业务字典。 -- 演示数据。 +系统包提供: -seed 不适合写: +```bash +uv run iti-system seed system app:app +``` -- 框架内部数据。 -- 其它包的数据。 -- 运行时数据库快照。 +它会写入默认角色、管理员、系统菜单、字典和配置。 -## 业务命令 +## 业务项目 -业务项目可以在模块中注册自己的 CLI 命令: +业务项目可以写普通 Python 函数: ```python -import click - - -class ExampleModule: - name = "example" - - def register_commands(self, app): - @click.command("seed-example") - def seed_example(): - click.echo("seeded") - - app.cli.add_command(seed_example) -``` - -执行: - -```bash -uv run python -m flask --app app.py seed-example +def seed_example(db): + ... ``` -需要系统业务 seed 时,查看 `iTi-System` 文档。 +也可以暴露 click 命令。 +框架不要求固定 seed DSL。 diff --git a/docs/SERVICE_CLIENT.md b/docs/SERVICE_CLIENT.md index b445874..07e8f81 100644 --- a/docs/SERVICE_CLIENT.md +++ b/docs/SERVICE_CLIENT.md @@ -14,6 +14,8 @@ iTi-Flask 提供同步 HTTP JSON 服务客户端。 - 可选熔断。 - `X-Trace-Id` 透传。 - 结构化调用日志。 +- envelope 自动识别。 +- HTTP status 自动识别。 - 测试用 mock transport。 不支持: @@ -28,29 +30,32 @@ iTi-Flask 提供同步 HTTP JSON 服务客户端。 ## 配置 ```python -SERVICES = { - "inventory": { - "base_url": "http://inventory.local", - "token": "change-me", - "timeout": { - "connect": 1.0, - "read": 5.0, - "write": 5.0, - "pool": 1.0, - }, - "retry": { - "attempts": 2, - "backoff": 0.2, - "statuses": [502, 503, 504], - "methods": ["GET", "HEAD", "OPTIONS"], - }, - "circuit_breaker": { - "enabled": False, - "fail_max": 5, - "reset_timeout": 30, - }, - } -} +class DevConfig(BaseDevConfig): + def __init__(self) -> None: + super().__init__() + self.services = { + "inventory": { + "base_url": "http://inventory.local", + "token": "change-me", + "timeout": { + "connect": 1.0, + "read": 5.0, + "write": 5.0, + "pool": 1.0, + }, + "retry": { + "attempts": 2, + "backoff": 0.2, + "statuses": [502, 503, 504], + "methods": ["GET", "HEAD", "OPTIONS"], + }, + "circuit_breaker": { + "enabled": False, + "fail_max": 5, + "reset_timeout": 30, + }, + } + } ``` `base_url` 必填。 @@ -65,7 +70,7 @@ Authorization: Bearer change-me ```python from iti.service_client import service_client -inventory = service_client("inventory") +inventory = service_client(app, "inventory") item = inventory.get("/items/{id}", path={"id": "A001"}) created = inventory.post("/items", json={"name": "demo"}) @@ -82,8 +87,7 @@ inventory.post("/jobs", json={"kind": "sync"}, retry=True) ## Trace ID -客户端优先复用当前请求头里的 `X-Trace-Id`。 -没有请求上下文时自动生成。 +客户端会为每次调用生成 `X-Trace-Id`。 ## 错误 @@ -92,7 +96,17 @@ inventory.post("/jobs", json={"kind": "sync"}, retry=True) - `ServiceConfigError` - `ServiceUnavailableError` - `ServiceHTTPError` +- `ServiceBusinessError` -非 2xx 响应会抛出 `ServiceHTTPError`。 +响应体如果是 envelope: + +- `code == 200` 返回 `data`。 +- `code != 200` 抛出 `ServiceBusinessError`。 + +这条规则不依赖 HTTP status。 +也就是说,HTTP 401 + envelope `code=401` 会抛 `ServiceBusinessError`,调用方可统一处理业务 code。 + +非 envelope 的非 2xx 响应会抛出 `ServiceHTTPError`。 +envelope 只认同时包含 `data`、`code`、`message` 的 JSON object。 响应体为空时返回 `None`。 `expect_json=False` 时返回原始 `httpx.Response`。 diff --git a/docs/TASKS.md b/docs/TASKS.md index 9dc22db..4acb894 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -28,7 +28,10 @@ iTi-Flask 提供单进程任务注册表和运行器。 默认不启动调度线程。 ```python -TASKS_ENABLED = True +class DevConfig(BaseDevConfig): + def __init__(self) -> None: + super().__init__() + self.tasks_enabled = True ``` ## 注册任务 diff --git a/docs/TESTING_DEPLOYMENT.md b/docs/TESTING_DEPLOYMENT.md new file mode 100644 index 0000000..9c375d6 --- /dev/null +++ b/docs/TESTING_DEPLOYMENT.md @@ -0,0 +1,78 @@ +# 测试与部署方案 + +本轮开发验证不依赖 Docker,不依赖真实 MySQL。 +真实 MySQL 和 Docker Compose 属于集成验证阶段。 + +## 本地轻量验证 + +```bash +cd /root/Projects/iTi/iTi-Flask +uv run pytest -q + +cd /root/Projects/iTi/iTi-System +uv run pytest -q + +cd /root/Projects/iTi/hsyh-erp +uv run pytest -q +``` + +这些测试允许使用 SQLite 内存库,只验证框架行为、路由契约和模块组装。 + +## MySQL 集成验证 + +准备独立测试库: + +```sql +CREATE DATABASE iti_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE hsyh_erp_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +执行: + +```bash +export DATABASE_URL='mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4' +uv run alembic upgrade head +uv run iti-system migrations sync --target migrations/versions +uv run iti-system seed system app:app +uv run uvicorn app:app --reload +``` + +验证: + +```bash +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/ready +``` + +如启用 `ready_check_db=True`,`/ready` 会执行 `SELECT 1`。 + +## Docker Compose 健康检查 + +服务容器可使用: + +```yaml +healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"] + interval: 10s + timeout: 3s + retries: 3 +``` + +依赖数据库就绪的服务可检查 `/ready`。 + +## 生产运行 + +简单部署: + +```bash +uv run uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +多 worker: + +```bash +uv run gunicorn app:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 +``` + +`tasks_enabled=True` 时不要在多个 worker 同时启用调度。 +轻量任务只保证单进程内正常运行。 diff --git a/iti/__init__.py b/iti/__init__.py index 06a0ec0..4296124 100644 --- a/iti/__init__.py +++ b/iti/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> -# -# SPDX-License-Identifier: MIT +from iti.app import create_app + +__all__ = ["create_app"] diff --git a/iti/app.py b/iti/app.py index e8a501e..aff3dd3 100644 --- a/iti/app.py +++ b/iti/app.py @@ -1,6 +1,312 @@ -from iti.applications import create_app +from __future__ import annotations -app = create_app() +import logging +import time +import uuid +from collections.abc import Iterable, Mapping +from contextvars import ContextVar +from contextlib import asynccontextmanager +from dataclasses import asdict, is_dataclass +from functools import wraps +from inspect import isawaitable +from typing import Any -if __name__ == "__main__": - app.run(debug=True) +from fastapi import FastAPI, Request +from fastapi.dependencies.utils import ( + _should_embed_body_fields, + get_body_field, + get_dependant, + get_flat_dependant, + get_parameterless_sub_dependant, +) +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.routing import APIRoute +from fastapi.routing import request_response +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from starlette.responses import Response +from sqlalchemy.exc import SQLAlchemyError + +from iti.auth.permissions import StaticPermissionProvider +from iti.audit import init_audit +from iti.cache import CacheManager +from iti.config import BaseConfig, get_config +from iti.db import configure_db +from iti.exceptions import ItiError +from iti.health import router as health_router +from iti.limiter import SimpleLimiter +from iti.logging_config import configure_logging, log_extra +from iti.modules import init_modules +from iti.responses.auto import is_envelope_payload, is_raw_response_request +from iti.responses import fail +from iti.service_client import init_service_clients +from iti.tasks import init_task_runner + +logger = logging.getLogger("iti") +error_logger = logging.getLogger("iti.error") +_current_request: ContextVar[Request | None] = ContextVar("iti_current_request", default=None) + + +def create_app( + config_name: str | None = None, + *, + modules: Iterable[Any] | None = None, + config_mapping: Mapping[str, type[BaseConfig] | BaseConfig] | type[BaseConfig] | BaseConfig | None = None, + permission_provider: Any | None = None, +) -> FastAPI: + config = _resolve_config(config_name, config_mapping) + configure_logging(config) + + @asynccontextmanager + async def lifespan(app: FastAPI): + runner = getattr(app.state, "iti_task_runner", None) + audit_dispatcher = getattr(app.state, "audit_dispatcher", None) + if audit_dispatcher: + audit_dispatcher.start() + if runner and config.tasks_enabled: + runner.start() + yield + if runner: + runner.stop() + if audit_dispatcher: + audit_dispatcher.stop() + for client in getattr(app.state, "iti_service_clients", {}).values(): + client.close() + + app = FastAPI( + title=config.app_name, + debug=config.debug, + lifespan=lifespan, + ) + app.state.config = config + app.state.cache = CacheManager(default_timeout=config.cache_default_timeout) + app.state.limiter = SimpleLimiter(enabled=config.ratelimit_enabled) + app.state.permission_provider = permission_provider or StaticPermissionProvider() + + init_middlewares(app) + + if config.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=config.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + engine, sessionmaker = configure_db( + config.database_url, + echo=config.sqlalchemy_echo, + pool_pre_ping=config.sqlalchemy_pool_pre_ping, + ) + app.state.db_engine = engine + app.state.db_sessionmaker = sessionmaker + + init_error_handlers(app) + init_service_clients(app, config.services) + init_task_runner(app) + init_audit(app) + module_registry = init_modules(app, modules) + app.state.iti_modules = module_registry + if config.health_enabled: + app.include_router(health_router) + module_registry.run_phase("register_routes", app) + module_registry.run_phase("register_permissions", app) + module_registry.run_phase("register_menu_seed", app) + install_auto_envelope(app) + return app + + +def init_middlewares(app: FastAPI) -> None: + @app.middleware("http") + async def request_context_middleware(request: Request, call_next): + token = _current_request.set(request) + trace_id = request.headers.get("X-Trace-Id") or uuid.uuid4().hex + request_id = request.headers.get("X-Request-Id") or uuid.uuid4().hex + request.state.trace_id = trace_id + request.state.request_id = request_id + started_at = time.perf_counter() + response: Response | None + try: + response = await call_next(request) + except Exception: + request.state.response_code = getattr(request.state, "response_code", 500) + _log_request(request, started_at, 500) + _current_request.reset(token) + raise + response.headers.setdefault("X-Trace-Id", trace_id) + response.headers.setdefault("X-Request-Id", request_id) + _log_request(request, started_at, response.status_code) + _current_request.reset(token) + return response + + +def _log_request(request: Request, started_at: float, status_code: int) -> None: + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "request method=%s path=%s status=%s code=%s durationMs=%s ip=%s", + request.method, + request.url.path, + status_code, + getattr(request.state, "response_code", "-"), + duration_ms, + request.client.host if request.client else "-", + extra=log_extra(request), + ) + + +def _resolve_config( + config_name: str | None, + config_mapping: Mapping[str, type[BaseConfig] | BaseConfig] | type[BaseConfig] | BaseConfig | None, +) -> BaseConfig: + if config_mapping is None: + return get_config(config_name) + if isinstance(config_mapping, Mapping): + env_name = config_name or "dev" + value = config_mapping.get(env_name, config_mapping.get("default")) + if value is None: + return get_config(config_name) + return value() if isinstance(value, type) else value + return config_mapping() if isinstance(config_mapping, type) else config_mapping + + +def init_error_handlers(app: FastAPI) -> None: + @app.exception_handler(ItiError) + async def handle_iti_error(request: Request, exc: ItiError): + request.state.response_code = exc.code + return JSONResponse( + status_code=request.app.state.config.response_envelope_http_status, + content=fail(exc.message, code=exc.code, data=exc.data), + ) + + @app.exception_handler(RequestValidationError) + async def handle_validation_error(request: Request, exc: RequestValidationError): + request.state.response_code = 422 + return JSONResponse( + status_code=request.app.state.config.response_envelope_http_status, + content=fail("参数验证错误", code=422, data=exc.errors()), + ) + + @app.exception_handler(SQLAlchemyError) + async def handle_db_error(request: Request, exc: SQLAlchemyError): + request.state.response_code = 500 + error_logger.exception("database error", extra=log_extra(request)) + return JSONResponse( + status_code=request.app.state.config.response_envelope_http_status, + content=fail("数据库错误", code=500, data=str(exc)), + ) + + @app.exception_handler(Exception) + async def handle_exception(request: Request, exc: Exception): + request.state.response_code = 500 + error_logger.exception("server error", extra=log_extra(request)) + return JSONResponse( + status_code=request.app.state.config.response_envelope_http_status, + content=fail("服务器错误", code=500, data=str(exc)), + ) + + +def to_plain_data(value: Any) -> Any: + if is_dataclass(value): + return asdict(value) + return value + + +def install_auto_envelope(app: FastAPI) -> None: + config = app.state.config + if not config.response_envelope_enabled: + return + raw_paths = tuple(config.raw_response_paths) + for route in app.routes: + if not isinstance(route, APIRoute): + continue + if getattr(route, "__iti_envelope_installed__", False): + continue + if _is_route_raw(route, raw_paths): + continue + original_call = route.dependant.call + if original_call is None: + continue + route.endpoint = _wrap_endpoint_with_envelope(original_call) + _rebuild_route_dependant(route) + setattr(route, "__iti_envelope_installed__", True) + + +def _is_route_raw(route: APIRoute, raw_paths: Iterable[str]) -> bool: + endpoint = route.endpoint + if getattr(endpoint, "__iti_raw_response__", False): + return True + for path in route.path_format, route.path: + request = _PathOnlyRequest(path) + if is_raw_response_request(request, raw_paths): + return True + return False + + +def _wrap_endpoint_with_envelope(func): + @wraps(func) + async def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + if isawaitable(result): + result = await result + if isinstance(result, Response): + return result + payload = _to_jsonable(result) + if is_envelope_payload(payload): + _mark_response_code(args, kwargs, payload["code"]) + return payload + _mark_response_code(args, kwargs, 200) + return {"data": payload, "code": 200, "message": "成功"} + + return wrapper + + +def _to_jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump(by_alias=True) + if is_dataclass(value): + return asdict(value) + return value + + +def _mark_response_code(args: tuple[Any, ...], kwargs: dict[str, Any], code: int) -> None: + request = _request_from_call(args, kwargs) or _current_request.get() + if request is not None: + request.state.response_code = code + + +def _request_from_call(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request | None: + for value in list(args) + list(kwargs.values()): + if isinstance(value, Request): + return value + return None + + +def _rebuild_route_dependant(route: APIRoute) -> None: + route.dependant = get_dependant( + path=route.path_format, + call=route.endpoint, + scope="function", + ) + for depends in route.dependencies[::-1]: + route.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant(depends=depends, path=route.path_format), + ) + route._flat_dependant = get_flat_dependant(route.dependant) + route._embed_body_fields = _should_embed_body_fields(route._flat_dependant.body_params) + route.body_field = get_body_field( + flat_dependant=route._flat_dependant, + name=route.unique_id, + embed_body_fields=route._embed_body_fields, + ) + route.app = request_response(route.get_route_handler()) + + +class _PathOnlyRequest: + def __init__(self, path: str) -> None: + self.url = type("URL", (), {"path": path})() + self.scope = {"endpoint": None} diff --git a/iti/applications/__init__.py b/iti/applications/__init__.py deleted file mode 100644 index b44e5fb..0000000 --- a/iti/applications/__init__.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import warnings -from collections.abc import Mapping -from apiflask import APIFlask - -from iti.applications.common.utils.schema import custom_schema_name_resolver -from iti.applications.service import init_services -from iti.modules import init_modules -from iti.service_client import init_service_clients -from iti.tasks import init_task_runner -from .extensions import init_exts -from .routes import init_routes -from ..config import get_config - - -def create_app(config_name=None, modules=None, config_mapping=None, model_imports=None): - """ - 应用工厂函数 - - Args: - config_name: 配置名称 ('dev', 'test', 'prod') - 如果为 None,则从环境变量 FLASK_ENV 读取 - modules: 进程内业务模块列表 - config_mapping: 业务项目配置映射,格式与 iti.config.config 一致 - model_imports: 业务模型导入函数列表。用于让 Flask-Migrate 看到业务模型 - - Returns: - Flask 应用实例 - - docs_ui: The UI of API documentation, one of `swagger-ui` (default), `redoc`, - `elements`, `rapidoc`, and `rapipdf`. - """ - # 忽略 apispec 的 schema 名称冲突警告 - warnings.filterwarnings( - "ignore", - message="Multiple schemas resolved to the name", - category=UserWarning, - module="apispec.ext.marshmallow.openapi", - ) - - app = APIFlask( - __name__.split(".")[0], - title="iTi-Flask", - version="1.0.0", - json_errors=True, - docs_ui="elements", - ) - - # 加载配置 - config_obj = _resolve_config(config_name, config_mapping) - app.config.from_object(config_obj) - - # 配置自定义 schema 名称解析器 - # 参考:https://zh.apiflask.com/schema/#%E6%A8%A1%E5%BC%8F%E5%90%8D%E7%A7%B0%E8%A7%A3%E6%9E%90%E5%99%A8 - # 用于解决循环引用和嵌套 schema 导致的命名冲突警告 - app.schema_name_resolver = custom_schema_name_resolver - - # 确保必要的目录存在 - _ensure_directories(app) - - # 注册框架 CLI - from iti.cli import iti_cli - - app.cli.add_command(iti_cli, "iti") - - # 使用第三方JWT,自定义Security,避免doc无法传递header - # 等同于 SECURITY_SCHEMES 配置 - app.security_schemes = { - "JWT": { - "type": "apiKey", - "in": "header", - "name": "Authorization", - } - } - - # 保护doc文档(鉴权后才可访问) - # app.config['SPEC_DECORATORS'] = [jwt_required()] - # app.config['DOCS_DECORATORS'] = [jwt_required()] - - # 初始化扩展 - init_exts(app) - - # 导入业务模型,确保 Alembic autogenerate 能看到业务表。 - for import_models in model_imports or []: - import_models() - - # 初始化可配置服务客户端与任务系统 - init_service_clients(app) - init_task_runner(app) - - # 初始化业务模块。模块路由会在系统路由之后注册。 - module_registry = init_modules(app, modules) - - # 初始化路由 - init_routes(app) - - module_registry.run_phase("register_routes", app) - module_registry.run_phase("register_permissions", app) - module_registry.run_phase("register_menu_seed", app) - - # 初始化Services - init_services(app) - - # 打印当前环境信息 - env = config_name or os.getenv("FLASK_ENV", "dev") - print(f"🚀 应用启动 - 环境: {env}") - print(f"📊 数据库: {app.config.get('SQLALCHEMY_DATABASE_URI')}") - - return app - - -def _resolve_config(config_name=None, config_mapping=None): - if config_mapping is None: - return get_config(config_name) - - env_name = config_name or os.getenv("FLASK_ENV", "dev") - if isinstance(config_mapping, Mapping): - return config_mapping.get( - env_name, config_mapping.get("default", get_config(config_name)) - ) - - return config_mapping - - -def _ensure_directories(app): - """确保必要的目录存在""" - # 数据库目录(SQLite) - db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") - if "sqlite:///" in db_uri and not db_uri.endswith(":memory:"): - db_path = db_uri.replace("sqlite:///", "") - db_dir = os.path.dirname(db_path) - if db_dir and not os.path.exists(db_dir): - os.makedirs(db_dir, exist_ok=True) - - file_storage_config = app.config.get("FILE_STORAGE", {}) - local_config = file_storage_config.get("LOCAL", {}) - local_path = local_config.get("base_path") - if local_path: - os.makedirs(local_path, exist_ok=True) diff --git a/iti/applications/common/__init__.py b/iti/applications/common/__init__.py deleted file mode 100644 index a5a87d4..0000000 --- a/iti/applications/common/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .logger import setup_logger -from .filter import ModelFilter diff --git a/iti/applications/common/crud.py b/iti/applications/common/crud.py deleted file mode 100644 index ce8f2e3..0000000 --- a/iti/applications/common/crud.py +++ /dev/null @@ -1,229 +0,0 @@ -from sqlalchemy.orm import Mapped, mapped_column -from iti.applications.extensions import db, ma -import datetime -from marshmallow import Schema -from marshmallow_sqlalchemy import SQLAlchemyAutoSchema -import uuid -from flask import has_request_context -from flask_jwt_extended import current_user, verify_jwt_in_request -from typing import Optional - - -class IdModelMixin(object): - """ - ID模型混入类,自动生成36位UUID作为主键 - """ - - id: Mapped[str] = mapped_column( - db.String(36), - primary_key=True, - default=lambda: str(uuid.uuid4().hex), - comment="标识", - sort_order=1, - ) - - -class AuditModelMixin(object): - """ - 审计模型混入类 - """ - - def get_current_user_identity(): - if not has_request_context(): - return None - verify_jwt_in_request(True) - return current_user.id if current_user else None - - created_by: Mapped[Optional[str]] = mapped_column( - db.String(36), - comment="创建人", - sort_order=51, - nullable=True, - index=True, - default=get_current_user_identity, - ) - updated_by: Mapped[Optional[str]] = mapped_column( - db.String(36), - comment="更新人", - sort_order=61, - nullable=True, - index=True, - default=get_current_user_identity, - onupdate=get_current_user_identity, - ) - - -class TimeModelMixin(object): - """ - 时间模型混入类 - """ - - created_at: Mapped[datetime.datetime] = mapped_column( - db.DateTime, - default=datetime.datetime.now, - comment="创建时间", - sort_order=50, - ) - updated_at: Mapped[datetime.datetime] = mapped_column( - db.DateTime, - default=datetime.datetime.now, - onupdate=datetime.datetime.now, - comment="更新时间", - sort_order=60, - ) - - -class RemarkModelMixin(object): - """ - 备注模型混入类 - """ - - remark: Mapped[Optional[str]] = mapped_column( - db.String(255), - comment="备注", - sort_order=100, - nullable=True, - ) - - -class BaseModelMixin(db.Model, IdModelMixin, TimeModelMixin, RemarkModelMixin, AuditModelMixin): - """ - 基础模型混入类 - """ - - __abstract__ = True - __table_args__ = { - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_general_ci", - } - - -class BaseWithoutIdModelMixin(db.Model, TimeModelMixin, RemarkModelMixin): - """ - 基础模型混入类,不包含ID - """ - - __abstract__ = True - __table_args__ = { - "mysql_charset": "utf8mb4", - "mysql_collate": "utf8mb4_general_ci", - } - - -class LogicalDeleteModelMixin(object): - """ - 逻辑删除混入类 - 示例: - class Test(db.Model, LogicalDeleteMixin): - __tablename__ = 'admin_test' - id = db.Column(db.Integer, primary_key=True, comment='角色ID') - - # 软删除 - Test.query.filter_by(id=1).soft_delete() - - # 查询所有未删除的记录 - Test.query.logic_all() - """ - - deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column( - db.DateTime, - comment="删除时间", - sort_order=70, - nullable=True, - ) - - -def auto_model_jsonify(data, model: db.Model): - """ - 自动序列化模型数据为 JSON 格式,无需手动定义 Schema。 - - 示例: - power_data = curd.auto_model_jsonify(data=dept, model=Dept) - - :param data: 需要序列化的 SQLAlchemy 查询结果。 - :param model: SQLAlchemy 模型类。 - :return: 返回序列化后的 JSON 数据。 - """ - - def get_model(): - return model - - class AutoSchema(SQLAlchemyAutoSchema): - class Meta(Schema): - model = get_model() - include_fk = True # 包含外键 - include_relationships = True # 包含关联关系 - load_instance = True # 反序列化时加载为模型实例 - - common_schema = AutoSchema(many=True) # 支持序列化多个对象 - output = common_schema.dump(data) - return output - - -def model_to_dicts(schema: ma.Schema, data): - """ - 使用指定的 Schema 序列化 SQLAlchemy 查询结果。 - - :param schema: Marshmallow Schema 类。 - :param data: SQLAlchemy 查询结果。 - :return: 返回序列化后的数据,返回字典。 - """ - common_schema = schema(many=True) # 支持序列化多个对象 - output = common_schema.dump(data) - return output - - -def get_one_by_id(model: db.Model, id): - """ - 根据 ID 查询单个记录。 - - :param model: SQLAlchemy 模型类。 - :param id: 记录的主键 ID。 - :return: 返回查询到的记录,如果未找到则返回 None。 - """ - return model.query.filter_by(id=id).first() - - -def delete_one_by_id(model: db.Model, id): - """ - 根据 ID 删除单个记录。 - - :param model: SQLAlchemy 模型类。 - :param id: 记录的主键 ID。 - :return: 返回删除操作影响的行数。 - """ - r = model.query.filter_by(id=id).delete() - db.session.commit() - return r - - -def enable_status(model: db.Model, id): - """ - 启用指定 ID 的记录。 - - :param model: SQLAlchemy 模型类。 - :param id: 记录的主键 ID。 - :return: 如果操作成功返回 True,否则返回 False。 - """ - enable = 1 - role = model.query.filter_by(id=id).update({"enable": enable}) - if role: - db.session.commit() - return True - return False - - -def disable_status(model: db.Model, id): - """ - 停用指定 ID 的记录。 - - :param model: SQLAlchemy 模型类。 - :param id: 记录的主键 ID。 - :return: 如果操作成功返回 True,否则返回 False。 - """ - enable = 0 - role = model.query.filter_by(id=id).update({"enable": enable}) - if role: - db.session.commit() - return True - return False diff --git a/iti/applications/common/storage/manager.py b/iti/applications/common/storage/manager.py deleted file mode 100644 index 49b75bd..0000000 --- a/iti/applications/common/storage/manager.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import os -from typing import Dict, Optional, Union - -from flask import current_app - -from .interface import StorageInterface -from .local import LocalStorage -from ..enums import StorageTypeEnum - - -class StorageManager: - """存储管理器,负责根据类型创建存储实例""" - - _instances: Dict[str, StorageInterface] = {} - - @classmethod - def get_storage(cls, storage_type: Optional[Union[str, StorageTypeEnum]] = None) -> StorageInterface: - """ - 获取存储实例(单例模式) - - Args: - storage_type: 存储类型(支持字符串或 StorageTypeEnum),为None时使用默认类型 - - Returns: - 存储实例 - """ - # 标准化存储类型为字符串 - storage_type_str = cls._normalize_storage_type(storage_type) - - if storage_type_str not in cls._instances: - config = current_app.config.get("FILE_STORAGE", {}) - cls._instances[storage_type_str] = cls._create_storage(storage_type_str, config) - return cls._instances[storage_type_str] - - @staticmethod - def _normalize_storage_type(storage_type: Optional[Union[str, StorageTypeEnum]]) -> str: - """ - 标准化存储类型为字符串 - - Args: - storage_type: 存储类型(字符串或 StorageTypeEnum) - - Returns: - 存储类型字符串 - """ - # 如果未指定,使用默认类型 - if storage_type is None: - config = current_app.config.get("FILE_STORAGE", {}) - return config.get("DEFAULT_STORAGE_TYPE", StorageTypeEnum.LOCAL.value) - - # 如果是 enum,转换为字符串 - if isinstance(storage_type, StorageTypeEnum): - return storage_type.value - - # 已经是字符串,直接返回 - return storage_type - - @staticmethod - def _create_storage(storage_type: str, config: dict) -> StorageInterface: - """ - 创建存储实例 - - Args: - storage_type: 存储类型 - config: 配置字典 - - Returns: - 存储实例 - """ - if storage_type == StorageTypeEnum.LOCAL.value: - local_config = config.get("LOCAL", {}) - if not local_config.get("base_path"): - local_config["base_path"] = os.path.join( - current_app.config.get("BASE_DIR", current_app.root_path), "runtime", "uploads" - ) - return LocalStorage(local_config) - - elif storage_type == StorageTypeEnum.ALIYUN_OSS.value: - from .aliyun_oss import AliyunOSSStorage - oss_config = config.get("ALIYUN_OSS", {}) - return AliyunOSSStorage(oss_config) - - elif storage_type == StorageTypeEnum.TENCENT_COS.value: - from .tencent_cos import TencentCOSStorage - cos_config = config.get("TENCENT_COS", {}) - return TencentCOSStorage(cos_config) - - elif storage_type == StorageTypeEnum.QINIU_KODO.value: - from .qiniu_kodo import QiniuKodoStorage - kodo_config = config.get("QINIU_KODO", {}) - return QiniuKodoStorage(kodo_config) - - elif storage_type == StorageTypeEnum.AWS_S3.value: - # AWS S3 可以后续添加 - raise NotImplementedError("AWS S3 适配器尚未实现") - - elif storage_type == StorageTypeEnum.HUAWEI_OBS.value: - from .huawei_obs import HuaweiOBSStorage - obs_config = config.get("HUAWEI_OBS", {}) - return HuaweiOBSStorage(obs_config) - - elif storage_type == StorageTypeEnum.MINIO.value: - from .minio_storage import MinIOStorage - minio_config = config.get("MINIO", {}) - return MinIOStorage(minio_config) - - raise ValueError(f"未支持的存储类型: {storage_type}") - diff --git a/iti/applications/common/utils/__init__.py b/iti/applications/common/utils/__init__.py deleted file mode 100644 index 2ed18a6..0000000 --- a/iti/applications/common/utils/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -通用工具模块 -""" - -from .http import success, fail, page, pagination_builder -from .schema import ( - Pagination, - PaginationSchema, - pagination_fields, - pagination_schema_fields, - page_schema, - condition_schema, - BaseSchema, - custom_schema_name_resolver -) -from .tree import ( - build_tree_from_list, - flatten_tree, - find_node_by_id, - get_node_path, - filter_tree_by_condition, - get_tree_depth, - TreeKeyConfig, - default_key_config, -) -from .str import camel_case -from .time import parse_datetime_string diff --git a/iti/applications/common/utils/cache.py b/iti/applications/common/utils/cache.py deleted file mode 100644 index ed1f8a6..0000000 --- a/iti/applications/common/utils/cache.py +++ /dev/null @@ -1,54 +0,0 @@ -import time - -cache_dict = {} - - -def cache_set_internal(key, value, expired=5): - """ - 程序内部实现的记录缓存,用于简单、体量不大的缓存记录,在程序结束后销毁。对于高速、体量大的环境请配置 Redis 等服务自行记录。 - 记录缓存,存储键值对,并记录当前时间作为缓存的时间戳。 - - :param key: 键 - :param value: 值 - :param expired: 过期时间(秒),默认5秒 - """ - if value is None: - return - cache_dict[key] = {"value": value, "expired_time": time.time() + expired} - - -def cache_get_internal(key): - """ - 获取缓存,根据键从缓存中获取值,并检查是否过期。 - - :param key: 键 - :return: 如果缓存存在且未过期,返回缓存的值;否则返回 None - """ - if key in cache_dict: - cache_item = cache_dict[key] - if time.time() < cache_item["expired_time"]: - return cache_item["value"] - else: - # 如果缓存已过期,删除该缓存 - del cache_dict[key] - return None - - -def cache_auto_internal(key, call, expired=5): - """ - 如果缓存存在直接返回缓存内容,缓存不存在或者过期执行 call 函数,并取得返回值记录并返回。 - - :param key: 键 - :param call: 获取新值的地方 - :param expired: 过期时间(秒),默认5秒 - """ - - data = cache_get_internal(key) - - if data is not None: - return data - - data = call() - cache_set_internal(key, data, expired) - - return data diff --git a/iti/applications/common/utils/http.py b/iti/applications/common/utils/http.py deleted file mode 100644 index 9a132ab..0000000 --- a/iti/applications/common/utils/http.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -HTTP 响应包装工具 - -提供统一的 API 响应格式包装函数和分页工具 -""" - -from typing import Any, Optional, Union -from flask import request -from urllib.parse import urlencode -import math - - -# ==================== 包装函数 ==================== - - -def success(data: Any = None, message: str = "成功", code: int = 200) -> dict: - """ - 成功响应包装 - - Args: - data: 返回数据 - message: 提示信息(默认 "成功") - code: 业务状态码(默认 200) - - Returns: - 符合 BaseResponse 格式的字典 - - Example: - >>> return success({'id': 1, 'name': 'test'}) - {'data': {'id': 1, 'name': 'test'}, 'code': 200, 'message': '成功'} - - >>> return success([1, 2, 3], message='查询成功') - {'data': [1, 2, 3], 'code': 200, 'message': '查询成功'} - """ - return {"data": data, "code": code, "message": message} - - -def fail(message: str = "操作失败", code: int = 500, data: Any = None) -> dict: - """ - 失败响应包装(HTTP 状态码保持 200,由前端根据 code 判断) - - Args: - message: 错误信息 - code: 业务错误码(默认 500) - data: 额外数据(如验证错误详情) - - Returns: - 符合 BaseResponse 格式的字典 - - Example: - >>> return fail('参数错误', code=400) - {'data': None, 'code': 400, 'message': '参数错误'} - - >>> return fail('未找到资源', code=404) - {'data': None, 'code': 404, 'message': '未找到资源'} - - >>> return fail('验证失败', code=422, data={'username': ['必填项']}) - {'data': {'username': ['必填项']}, 'code': 422, 'message': '验证失败'} - """ - return {"data": data, "code": code, "message": message} - - -def page( - items: Union[list, Any], - pagination: Union[dict, Any, None] = None, - message: str = "成功", - code: int = 200, -) -> dict: - """ - 分页响应包装(智能识别多种输入方式) - - 支持三种调用方式: - - 1. 传入 SQLAlchemy Pagination 对象(自动解析) - >>> db_pagination = User.query.paginate(page=1, per_page=10) - >>> return page(db_pagination) - - 2. 传入数据列表 + 分页信息字典 - >>> users = [{'id': 1}, {'id': 2}] - >>> pagination_info = pagination_builder(None, page=1, size=10, total=100) - >>> return page(users, pagination_info) - - 3. 传入数据列表 + SQLAlchemy Pagination 对象 - >>> db_pagination = User.query.paginate(page=1, per_page=10) - >>> users = UserSchema(many=True).dump(db_pagination.items) - >>> return page(users, db_pagination) - - Args: - items: 数据列表 或 SQLAlchemy Pagination 对象 - pagination: 分页信息字典 或 SQLAlchemy Pagination 对象 或 None - message: 提示信息 - code: 状态码 - - Returns: - 符合 BaseResponse 格式的分页数据 - - Example: - { - 'data': { - 'items': [...], - 'page': { - 'page': 1, - 'size': 10, - 'pages': 10, - 'total': 100, - 'current': '...', - 'next': '...', - 'prev': None, - 'first': '...', - 'last': '...' - } - }, - 'code': 200, - 'message': '成功' - } - """ - # 方式1: items 是 SQLAlchemy Pagination 对象 - if pagination is None and hasattr(items, "items") and hasattr(items, "total"): - pagination_obj = items - items = pagination_obj.items - pagination = pagination_builder(pagination_obj) - - # 方式2: pagination 是 SQLAlchemy Pagination 对象 - elif ( - pagination is not None - and hasattr(pagination, "items") - and hasattr(pagination, "total") - ): - pagination_obj = pagination - pagination = pagination_builder(pagination_obj) - - # 方式3: pagination 已经是字典,直接使用 - # items 已经是列表,直接使用 - - return { - "data": {"items": items, "page": pagination}, - "code": code, - "message": message, - } - - -# ==================== 分页构建器(参考 APIFlask helpers.py)==================== - - -def pagination_builder( - pagination: Any, - *, - page: Optional[int] = None, - size: Optional[int] = None, - total: Optional[int] = None, - pages: Optional[int] = None, -) -> dict: - """ - 构建分页信息(参考 APIFlask 实现) - - 支持两种调用方式: - - 1. 传入 SQLAlchemy Pagination 对象(自动解析) - >>> db_pagination = User.query.paginate(page=1, per_page=10) - >>> pagination_info = pagination_builder(db_pagination) - - 2. 传入自定义参数(手动构建) - >>> pagination_info = pagination_builder( - ... None, - ... page=1, - ... size=10, - ... total=100 - ... ) - - Args: - pagination: SQLAlchemy Pagination 对象 或 None - page: 当前页码(手动模式) - size: 每页数量(手动模式) - total: 总记录数(手动模式) - pages: 总页数(可选,自动计算) - - Returns: - 符合 PaginationSchema 的字典 - - Example: - # 自动模式 - >>> db_pagination = User.query.paginate(page=1, per_page=10) - >>> info = pagination_builder(db_pagination) - {'page': 1, 'size': 10, 'pages': 10, 'total': 100, ...} - - # 手动模式 - >>> info = pagination_builder(None, page=1, size=20, total=200) - {'page': 1, 'size': 20, 'pages': 10, 'total': 200, ...} - """ - # 自动模式:从 SQLAlchemy Pagination 对象提取 - if pagination is not None and hasattr(pagination, "page"): - page = pagination.page - size = pagination.per_page - pages = pagination.pages - total = pagination.total - has_prev = pagination.has_prev - has_next = pagination.has_next - prev_num = pagination.prev_num if has_prev else None - next_num = pagination.next_num if has_next else None - - # 手动模式:使用传入的参数 - else: - page = page or 1 - size = size or 10 - total = total or 0 - - # 自动计算总页数 - if pages is None: - pages = math.ceil(total / size) if size > 0 else 0 - - # 计算上下页 - has_prev = page > 1 - has_next = page < pages - prev_num = page - 1 if has_prev else None - next_num = page + 1 if has_next else None - - # 生成分页 URL(参考 APIFlask 实现) - current_url = _generate_page_url(page, size) - next_url = _generate_page_url(next_num, size) if has_next else None - prev_url = _generate_page_url(prev_num, size) if has_prev else None - first_url = _generate_page_url(1, size) - last_url = _generate_page_url(pages, size) if pages > 0 else None - - return { - "page": page, - "size": size, # ✅ 使用 size 代替 per_page - "pages": pages, - "total": total, - "current": current_url, - "next": next_url, - "prev": prev_url, - "first": first_url, - "last": last_url, - } - - -def _generate_page_url(page_num: Optional[int], page_size: int) -> Optional[str]: - """ - 生成分页 URL(内部辅助函数) - - 参考 APIFlask 的 URL 生成逻辑 - - Args: - page_num: 页码 - page_size: 每页数量 - - Returns: - 完整的分页 URL 或 None - """ - if page_num is None: - return None - - try: - # 获取当前请求的 URL 和查询参数 - base_url = request.base_url - args = request.args.copy() - - # 更新分页参数 - args["page"] = page_num - args["size"] = page_size # ✅ 使用 size 参数名 - - # 构建完整 URL - if args: - return f"{base_url}?{urlencode(args)}" - return base_url - except RuntimeError: - # 在请求上下文之外调用时返回 None - return None diff --git a/iti/applications/common/utils/schema.py b/iti/applications/common/utils/schema.py deleted file mode 100644 index 2cb3159..0000000 --- a/iti/applications/common/utils/schema.py +++ /dev/null @@ -1,189 +0,0 @@ -from dataclasses import field -from marshmallow_dataclass import dataclass -from apiflask.fields import Integer, URL, Field -from marshmallow import fields -from apiflask import Schema -from .str import camel_case - - -# ==================== Schema 定义 ==================== -class BaseSchema(Schema): - """ - 基础 Schema 扩展 - 1. 有序返回 - 2. 未知字段不报错 - """ - - class Meta: - ordered = True - unknown = "INCLUDE" - - def on_bind_field(self, field_name: str, field_obj: Field) -> None: - """ - 绑定字段时处理 - 1.统一驼峰命名返回(小驼峰) - """ - if field_obj.data_key is None: - field_obj.data_key = camel_case(field_name) - - pass - - -@dataclass(base_schema=Schema) -class Pagination: - page: int = field(default=1, metadata={"metadata": {"description": "当前页码"}}) - size: int = field(default=10, metadata={"metadata": {"description": "每页数量"}}) - pages: int = field(default=0, metadata={"metadata": {"description": "总页数"}}) - total: int = field(default=0, metadata={"metadata": {"description": "总记录数"}}) - current: str = field( - default="", - metadata={"metadata": {"description": "当前页URL"}, "dump_only": True}, - ) - next: str = field( - default="", - metadata={"metadata": {"description": "下一页URL"}, "dump_only": True}, - ) - prev: str = field( - default="", - metadata={"metadata": {"description": "上一页URL"}, "dump_only": True}, - ) - first: str = field( - default="", metadata={"metadata": {"description": "首页URL"}, "dump_only": True} - ) - last: str = field( - default="", metadata={"metadata": {"description": "末页URL"}, "dump_only": True} - ) - - -# 获取 Pagination 类中的所有字段 -pagination_fields = [field.name for field in Pagination.__dataclass_fields__.values()] - - -class PaginationSchema(Schema): - """自定义分页信息 Schema(与 APIFlask 保持一致)""" - - page = Integer(metadata={"description": "当前页码"}) - size = Integer(metadata={"description": "每页数量"}) # per_page → size - pages = Integer(metadata={"description": "总页数"}) - total = Integer(metadata={"description": "总记录数"}) - current = URL(metadata={"description": "当前页URL"}, dump_only=True) - next = URL(metadata={"description": "下一页URL"}, dump_only=True) - prev = URL(metadata={"description": "上一页URL"}, dump_only=True) - first = URL(metadata={"description": "首页URL"}, dump_only=True) - last = URL(metadata={"description": "末页URL"}, dump_only=True) - - -# 获取 PaginationSchema 类中的所有字段 -pagination_schema_fields = list(PaginationSchema._declared_fields.keys()) - - -def page_schema(item_schema_cls: type, *, schema_name: str | None = None) -> type: - """ - 根据传入的 Item OutSchema 生成通用分页负载 Schema: - { items: [Item], page: Pagination } - - - item_schema_cls: 基础 Schema - - schema_name: 可选,自定义 schema 名称 - """ - if not schema_name: - schema_name = f"PageItems[{getattr(item_schema_cls, '__name__', 'Item')}]" - - class _PageItemsSchema(Schema): - items = fields.Nested(item_schema_cls, many=True, required=True) - page = fields.Nested(PaginationSchema, required=True) - - _PageItemsSchema.__name__ = schema_name - return _PageItemsSchema - - -def condition_schema(base_schema_cls: type, control_config): - """ - 多字段控制动态Schema创建 - - Args: - base_schema_class: 基础 Schema 类 - control_config: 控制配置字典 - { - "withDataList": ["data_list"], # 当 withDataList=true 时包含 data_list - "withTimestamps": ["created_at", "updated_at"], # 当 withTimestamps=true 时包含时间字段 - "withStatus": ["status"], # 当 withStatus=true 时包含状态字段 - } - """ - - class _ConditionSchema(base_schema_cls): - def __init__(self, *args, **kwargs): - self._control_config = control_config - super().__init__(*args, **kwargs) - - def dump(self, obj, many=None, **kwargs): - from flask import request - - # 收集排除字段列表 - exclude_fields = [] - for control_field, target_fields in self._control_config.items(): - try: - control_value = ( - request.args.get(control_field, "false").lower() == "true" - ) - except Exception: - # 没有请求上下文时,默认不排除字段 - control_value = True - - if not control_value: - exclude_fields.extend(target_fields) - - # 如果有字段需要排除,创建临时 Schema - if exclude_fields: - temp_schema = base_schema_cls(exclude=exclude_fields) - return temp_schema.dump(obj, many=many, **kwargs) - else: - return super().dump(obj, many=many, **kwargs) - - return _ConditionSchema - - -def custom_schema_name_resolver(schema): - """ - 自定义 schema 名称解析器,解决循环引用导致的命名冲突 - - 根据 APIFlask 官方文档: - - 函数接收一个 schema 对象作为参数 - - 返回一个字符串作为 schema 的名称 - - 用于解决多个 schema 解析为相同名称的问题 - - 处理策略: - 1. 优先使用 Meta.name(如果定义) - 2. 自动移除 Schema 后缀 - 3. 为带有 exclude 参数的嵌套 schema 生成唯一名称 - """ - schema_class = schema.__class__ - - # 1. 优先检查是否在 Meta 中定义了 name - if hasattr(schema_class, "Meta") and hasattr(schema_class.Meta, "name"): - base_name = schema_class.Meta.name - else: - # 2. 使用类名,移除 Schema 后缀 - base_name = schema_class.__name__ - if base_name.endswith("Schema"): - base_name = base_name[:-6] - if schema.partial: # 为部分模式添加 "Update" 后缀 - base_name += "Update" - - # 3. 处理嵌套时的 exclude 参数 - # 当使用 Nested("SomeSchema", exclude=["field1", "field2"]) 时 - # apispec 会创建新的 schema 实例,需要为其生成唯一名称 - if hasattr(schema, "exclude") and schema.exclude: - # 将 exclude 的字段排序,确保相同的 exclude 组合生成相同的名称 - excluded_fields = sorted(schema.exclude) - # 生成简洁的后缀:首字母大写拼接 - # 例如:exclude=["children", "parent"] -> "ChildrenParent" - suffix = "".join([field.capitalize() for field in excluded_fields]) - return f"{base_name}Exclude{suffix}" - - # 4. 处理 only 参数(如果使用) - if hasattr(schema, "only") and schema.only: - only_fields = sorted(schema.only) - suffix = "".join([field.capitalize() for field in only_fields]) - return f"{base_name}Only{suffix}" - - return base_name diff --git a/iti/applications/common/utils/sqlalchemy.py b/iti/applications/common/utils/sqlalchemy.py deleted file mode 100644 index d8b8224..0000000 --- a/iti/applications/common/utils/sqlalchemy.py +++ /dev/null @@ -1,29 +0,0 @@ -from sqlalchemy.ext.declarative import DeclarativeBase - - -def is_sqlalchemy_model(obj): - """ - 判断对象是否为 SQLAlchemy 模型 - """ - if isinstance(obj, DeclarativeBase): - return True - - if hasattr(obj, "_sa_instance_state"): - return True - - if hasattr(obj, "__mapper__"): - return True - - return False - - -def is_orm_result(data): - """ - 判断数据是否为 ORM 查询结果 - """ - if isinstance(data, list): - if not data: - return False - return is_sqlalchemy_model(data[0]) - - return is_sqlalchemy_model(data) diff --git a/iti/applications/common/utils/sys_log_helper.py b/iti/applications/common/utils/sys_log_helper.py deleted file mode 100644 index 9504447..0000000 --- a/iti/applications/common/utils/sys_log_helper.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, Optional - - -def summarize_response_json(obj: dict, items_sample_size: int) -> dict: - """ - 将 {data:{items,page}} 结构中的 items 做摘要:保留数量与前 N 条示例。 - 其他结构原样返回。 - """ - try: - if isinstance(obj, dict) and isinstance(obj.get("data"), dict): - data = obj["data"] - if isinstance(data.get("items"), list): - items = data["items"] - return { - **obj, - "data": { - **data, - "items": { - "count": len(items), - "sample": items[:items_sample_size], - }, - }, - } - return obj - except Exception: - return obj - - -def safe_json_dumps(value: Any, ensure_ascii: bool = False, max_chars: Optional[int] = None) -> str: - """ - 安全转字符串:优先 JSON 编码,不可编码时退回 str();可选截断。 - """ - try: - text = json.dumps(value, ensure_ascii=ensure_ascii) - except Exception: - text = str(value) - if max_chars is not None and max_chars > 0: - return text[:max_chars] - return text - - -def truncate_text(text: Optional[str], max_chars: int) -> str: - if not text: - return "" - return text[:max_chars] - - diff --git a/iti/applications/common/utils/validate.py b/iti/applications/common/utils/validate.py deleted file mode 100644 index 2d09a1f..0000000 --- a/iti/applications/common/utils/validate.py +++ /dev/null @@ -1,240 +0,0 @@ -# XSS 过滤 -import validators -from markupsafe import escape -from validators import validator - - -def str_escape(s): - """ - 对字符串进行 XSS 过滤,返回转义后的安全字符串。 - - :param s: 需要转义的字符串。 - :return: 返回转义后的字符串,如果输入为空则返回 None。 - """ - if not s: - return None - return str(escape(s)) - - -def between(*args, **kwargs): - """ - 验证数字是否介于最小值和最大值之间。 - 适用于整数、浮点数、小数和日期等类型。 - - :param value: 需要验证的数字。 - :param min: 数字的最小值(可选)。 - :param max: 数字的最大值(可选)。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> between(5, min=2) - True - - >>> between(13.2, min=13, max=14) - True - - >>> between(500, max=400) - ValidationFailure(func=between, args=...) - """ - return validators.between(*args, **kwargs) - - -def domain(*args, **kwargs): - """ - 验证给定值是否为有效的域名。 - - :param value: 需要验证的域名字符串。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> domain('example.com') - True - - >>> domain('example.com/') - ValidationFailure(func=domain, ...) - """ - return validators.domain(*args, **kwargs) - - -def email(*args, **kwargs): - """ - 验证给定值是否为有效的电子邮件地址。 - - :param value: 需要验证的电子邮件地址。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> email('someone@example.com') - True - - >>> email('bogus@@') - ValidationFailure(func=email, ...) - """ - return validators.email(*args, **kwargs) - - -def iban(*args, **kwargs): - """ - 验证给定值是否为有效的 IBAN 代码。 - - :param value: 需要验证的 IBAN 代码。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> iban('DE29100500001061045672') - True - - >>> iban('123456') - ValidationFailure(func=iban, ...) - """ - return validators.iban(*args, **kwargs) - - -def ipv4(*args, **kwargs): - """ - 验证给定值是否为有效的 IPv4 地址。 - - :param value: 需要验证的 IPv4 地址。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> ipv4('123.0.0.7') - True - - >>> ipv4('900.80.70.11') - ValidationFailure(func=ipv4, args={'value': '900.80.70.11'}) - """ - return validators.ipv4(*args, **kwargs) - - -def ipv6(*args, **kwargs): - """ - 验证给定值是否为有效的 IPv6 地址。 - - :param value: 需要验证的 IPv6 地址。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> ipv6('abcd:ef::42:1') - True - - >>> ipv6('abc.0.0.1') - ValidationFailure(func=ipv6, args={'value': 'abc.0.0.1'}) - """ - return validators.ipv6(*args, **kwargs) - - -def length(*args, **kwargs): - """ - 验证给定字符串的长度是否在指定范围内。 - - :param value: 需要验证的字符串。 - :param min: 字符串的最小长度(可选)。 - :param max: 字符串的最大长度(可选)。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> length('something', min=2) - True - - >>> length('something', min=9, max=9) - True - - >>> length('something', max=5) - ValidationFailure(func=length, ...) - """ - return validators.length(*args, **kwargs) - - -def mac_address(*args, **kwargs): - """ - 验证给定值是否为有效的 MAC 地址。 - - :param value: 需要验证的 MAC 地址。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> mac_address('01:23:45:67:ab:CD') - True - - >>> mac_address('00:00:00:00:00') - ValidationFailure(func=mac_address, args={'value': '00:00:00:00:00'}) - """ - return validators.mac_address(*args, **kwargs) - - -def slug(*args, **kwargs): - """ - 验证给定值是否为有效的 Slug 格式。 - 有效的 Slug 只能包含字母数字字符、连字符和下划线。 - - :param value: 需要验证的字符串。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> slug('my.slug') - ValidationFailure(func=slug, args={'value': 'my.slug'}) - - >>> slug('my-slug-2134') - True - """ - return validators.slug(*args, **kwargs) - - -def url(*args, **kwargs): - """ - 验证给定值是否为有效的 URL。 - - :param value: 需要验证的 URL。 - :param public: 是否仅允许公共 URL(可选)。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> url('http://foobar.dk') - True - - >>> url('http://10.0.0.1') - True - - >>> url('http://foobar.d') - ValidationFailure(func=url, ...) - - >>> url('http://10.0.0.1', public=True) - ValidationFailure(func=url, ...) - """ - return validators.url(*args, **kwargs) - - -def uuid(*args, **kwargs): - """ - 验证给定值是否为有效的 UUID。 - - :param value: 需要验证的 UUID。 - :return: 如果验证成功返回 True,否则返回 ValidationFailure。 - - 示例: - >>> uuid('2bc1c94f-0deb-43e9-92a1-4775189ec9f8') - True - - >>> uuid('2bc1c94f 0deb-43e9-92a1-4775189ec9f8') - ValidationFailure(func=uuid, ...) - """ - return validators.uuid(*args, **kwargs) - - -@validator -def even(value): - """ - 验证给定值是否为偶数。 - - :param value: 需要验证的数字。 - :return: 如果是偶数返回 True,否则返回 ValidationFailure。 - - 示例: - >>> even(4) - True - - >>> even(5) - ValidationFailure(func=even, args={'value': 5}) - """ - return not (value % 2) diff --git a/iti/applications/extensions/__init__.py b/iti/applications/extensions/__init__.py deleted file mode 100644 index aa43046..0000000 --- a/iti/applications/extensions/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -from .error_views import init_error_views -from .limit import init_limiter, limiter -from .jwt import init_jwt, jwt -from .db import init_db, db, ma -from .migrate import init_migrate, migrate -from .plugins import init_plugin, broadcast_execute -from .encoder import init_encoder -from .moment import init_moment, moment -from .http import init_http -from .error_handler import init_error_handler -from .cache import init_cache, cache_simple, cache_redis -from .event_bus import init_eventbus, eventbus -from iti.applications.common.logger import init_logger - - -def init_exts(app) -> None: - # 日志 - init_logger(app) - - # 插件 - init_plugin(app) - broadcast_execute(app, "event_begin") - - # http - init_http(app) - - # json - init_encoder(app) - init_moment(app) - - # flask 扩展 - init_db(app) - init_jwt(app) - init_migrate(app) - init_limiter(app) - init_cache(app) - init_eventbus(app) - - # 系统蓝图相关 - init_error_views(app) - init_error_handler(app) diff --git a/iti/applications/extensions/cache.py b/iti/applications/extensions/cache.py deleted file mode 100644 index 1abca8f..0000000 --- a/iti/applications/extensions/cache.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask_caching import Cache - -cache_simple = Cache() -cache_redis = Cache() - - -def init_cache(app): - simpleConfig = app.config.get("CACHE_SIMPLE", None) - redisConfig = app.config.get("CACHE_REDIS", None) - if simpleConfig is not None and simpleConfig.get("ENABLED", False): - app.logger.info(f"初始化简单缓存: {simpleConfig}") - cache_simple.init_app(app, config=simpleConfig) - if redisConfig is not None and redisConfig.get("ENABLED", False): - app.logger.info(f"初始化 Redis 缓存: {redisConfig}") - cache_redis.init_app(app, config=redisConfig) diff --git a/iti/applications/extensions/db.py b/iti/applications/extensions/db.py deleted file mode 100644 index f0cf262..0000000 --- a/iti/applications/extensions/db.py +++ /dev/null @@ -1,124 +0,0 @@ -from flask_sqlalchemy import SQLAlchemy -from flask_sqlalchemy.query import Query as BaseQuery -from flask_marshmallow import Marshmallow -import datetime -import os -from marshmallow import fields -from marshmallow.validate import ( - URL, - Email, - Range, - Length, - Equal, - Regexp, - Predicate, - NoneOf, - OneOf, - ContainsOnly, -) -from iti.applications.common.utils import fail -from sqlalchemy import MetaData - -URL.default_message = "无效的链接" -Email.default_message = "无效的邮箱地址" -Range.message_min = "不能小于{min}" -Range.message_max = "不能小于{max}" -Range.message_all = "不能超过{min}和{max}这个范围" -Length.message_min = "长度不得小于{min}位" -Length.message_max = "长度不得大于{max}位" -Length.message_all = "长度不能超过{min}和{max}这个范围" -Length.message_equal = "长度必须等于{equal}位" -Equal.default_message = "必须等于{other}" -Regexp.default_message = "非法输入" -Predicate.default_message = "非法输入" -NoneOf.default_message = "非法输入" -OneOf.default_message = "无效的选择" -ContainsOnly.default_message = "一个或多个无效的选择" - -fields.Field.default_error_messages = { - "required": "缺少必要数据", - "null": "数据不能为空", - "validator_failed": "非法数据", -} - -fields.Str.default_error_messages = {"invalid": "不是合法文本"} -fields.Int.default_error_messages = {"invalid": "不是合法整数"} -fields.Number.default_error_messages = {"invalid": "不是合法数字"} -fields.Boolean.default_error_messages = {"invalid": "不是合法布尔值"} - - -class Query(BaseQuery): - def soft_delete(self): - """ - 软删除查询 - """ - return self.update({"deleted_at": datetime.datetime.now()}) - - def logic_all(self): - """ - 逻辑未删除查询 - """ - return self.filter_by(deleted_at=None).all() - - def all_json(self, schema: Marshmallow().Schema): - """ - 查询结果转换为 JSON - """ - return schema(many=True).dump(self.all()) - - -naming_convention = { - "ix": "ix_%(column_0_label)s", - "uq": "uq_%(table_name)s_%(column_0_name)s", - "ck": "ck_%(table_name)s_%(column_0_name)s", - "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", - "pk": "pk_%(table_name)s", -} - -db = SQLAlchemy( - query_class=Query, metadata=MetaData(naming_convention=naming_convention), -) -ma = Marshmallow() - - -def init_db(app) -> None: - """ - 初始化数据库 - """ - db.init_app(app) - ma.init_app(app) - - # db错误处理 - _handle_db_error(app) - - if os.environ.get("WERKZEUG_RUN_MAIN") == "true": - with app.app_context(): - try: - db.engine.connect() - except Exception as e: - exit(f"数据库连接失败: {e}") - - -def _handle_db_error(app): - from sqlalchemy.exc import SQLAlchemyError - - show_error_details = app.config.get("SQLALCHEMY_SHOW_ERROR_DETAILS", False) - - @app.errorhandler(SQLAlchemyError) - def handle_sqlalchemy_db_error(error): - """ - SQLAlchemy 数据库错误处理 - """ - app.logger.error(f"数据库错误: {error}") - data = { - "code": error.code if hasattr(error, "code") else 500, - } - if show_error_details: - data["args"] = error.args if hasattr(error, "args") else None - data["statement"] = error.statement if hasattr(error, "statement") else None - data["params"] = error.params if hasattr(error, "params") else None - return fail( - "数据库错误", - code=500, - data=data, - ) diff --git a/iti/applications/extensions/encoder.py b/iti/applications/extensions/encoder.py deleted file mode 100644 index e204a35..0000000 --- a/iti/applications/extensions/encoder.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -JSON 序列化配置 - -Flask 3.x 使用 JSONProvider 替代 JSONEncoder -支持通过配置文件控制序列化行为 -""" - -from datetime import datetime, date -from flask.json.provider import DefaultJSONProvider - - -class CustomJSONProvider(DefaultJSONProvider): - """ - 自定义 JSON Provider (Flask 3.x) - - 功能: - - 处理 datetime 和 date 类型 - - 支持配置文件控制序列化参数 - """ - - def __init__(self, app): - """ - 初始化 JSON Provider - - 从 app.config 读取配置: - - JSON_ENSURE_ASCII: 是否转义非 ASCII 字符 - - JSON_SORT_KEYS: 是否对字典 key 排序 - - JSON_INDENT: 缩进空格数(None 为不缩进) - - JSON_SEPARATORS: 分隔符元组 - """ - super().__init__(app) - - # 从配置读取设置 - self._ensure_ascii = app.config.get('JSON_ENSURE_ASCII', False) - self._sort_keys = app.config.get('JSON_SORT_KEYS', False) - self._indent = app.config.get('JSON_INDENT', None) - self._separators = app.config.get('JSON_SEPARATORS', (',', ':')) - - def default(self, obj): - """ - 序列化自定义对象 - - Args: - obj: 要序列化的对象 - - Returns: - 序列化后的 Python 基本类型 - """ - # 处理 datetime 类型 - if isinstance(obj, datetime): - return obj.strftime("%Y-%m-%d %H:%M:%S") - - # 处理 date 类型 - elif isinstance(obj, date): - return obj.strftime("%Y-%m-%d") - - # 其他类型调用父类处理 - return super().default(obj) - - def dumps(self, obj, **kwargs): - """ - 序列化为 JSON 字符串 - - 合并配置文件和运行时参数 - """ - # 使用配置文件的默认值 - kwargs.setdefault('ensure_ascii', self._ensure_ascii) - kwargs.setdefault('sort_keys', self._sort_keys) - - if self._indent is not None: - kwargs.setdefault('indent', self._indent) - - kwargs.setdefault('separators', self._separators) - - return super().dumps(obj, **kwargs) - - -def init_encoder(app): - """ - 初始化 JSON 编码器 - - 配置: - - 设置自定义 JSONProvider - - 从配置文件读取序列化参数 - - 支持环境差异化配置 - """ - # 设置自定义 Provider - app.json_provider_class = CustomJSONProvider - - # 直接设置 app.json 的属性(Flask 3.x 推荐方式) - app.json.ensure_ascii = app.config.get('JSON_ENSURE_ASCII', False) - app.json.sort_keys = app.config.get('JSON_SORT_KEYS', False) diff --git a/iti/applications/extensions/error_handler.py b/iti/applications/extensions/error_handler.py deleted file mode 100644 index ec1afdc..0000000 --- a/iti/applications/extensions/error_handler.py +++ /dev/null @@ -1,101 +0,0 @@ -from apiflask import APIFlask, HTTPError -from apiflask.exceptions import _ValidationError -from iti.applications.common.utils import fail -from iti.applications.common.exceptions.biz_exp import BizException -from iti.applications.common.exceptions.permission import PermissionDeniedException - - -def init_error_handler(app: APIFlask): - """ - 全局异常处理 - """ - - @app.errorhandler(Exception) - def handle_exception(error): - """ - 未处理的异常处理 - """ - try: - # 安全地获取错误信息 - error_msg = str(error) - error_type = type(error).__name__ - app.logger.error( - f"服务器错误 [{error_type}]: {error_msg}", - exc_info=True # 使用 exc_info 代替 stack_info,更安全 - ) - except Exception as log_error: - # 如果日志记录失败,使用最基本的方式记录 - try: - app.logger.error(f"服务器错误(日志记录失败): {type(error).__name__}") - except: - pass # 完全失败则放弃日志记录 - - # 安全地返回错误信息 - try: - error_data = str(error) - except: - error_data = type(error).__name__ - - return fail(message="服务器错误", code=500, data=error_data) - - @app.errorhandler(400) - def handle_400(error): - """ - 参数错误 - """ - try: - app.logger.error(f"参数错误: {error}", exc_info=True) - except: - app.logger.error("参数错误(日志记录失败)") - - return fail( - message=error.data.message - if error.data and "message" in error.data - else "参数错误", - code=400, - data=str(error), - ) - - @app.errorhandler(BizException) - def handle_biz_exception(error: BizException): - """ - 业务异常处理 - """ - try: - app.logger.error(f"业务异常: {error}") - except: - app.logger.error("业务异常(日志记录失败)") - return fail(error.message, code=error.code, data=error.data) - - @app.errorhandler(PermissionDeniedException) - def handle_permission_denied_exception(error: PermissionDeniedException): - """ - 权限不足异常处理 - """ - try: - app.logger.error(f"权限不足: {error}") - except: - app.logger.error("权限不足(日志记录失败)") - return fail(message=error.message, code=error.code) - - @app.error_processor - def handler_http_error(error: HTTPError): - """ - http异常处理 - """ - try: - if isinstance(error, _ValidationError): - app.logger.error(f"参数验证错误: {error.detail}") - error.message = "参数验证错误" - else: - app.logger.error( - f"HTTP异常: {error.message} {error.status_code} {error.detail} {error.headers} {error.extra_data}" - ) - except: - app.logger.error("HTTP异常(日志记录失败)") - - return ( - fail(message=error.message, code=error.status_code, data=error.detail), - 200, - error.headers, - ) diff --git a/iti/applications/extensions/error_views.py b/iti/applications/extensions/error_views.py deleted file mode 100644 index 71d3fb8..0000000 --- a/iti/applications/extensions/error_views.py +++ /dev/null @@ -1,31 +0,0 @@ -from flask import request, render_template - -from iti.applications.common.utils import fail - - -def _wants_html() -> bool: - return ( - request.accept_mimetypes.accept_html - and request.accept_mimetypes["text/html"] - >= request.accept_mimetypes["application/json"] - ) - - -def init_error_views(app): - @app.errorhandler(403) - def forbidden(error): - if not _wants_html(): - return fail(message="Forbidden", code=403), 200 - return render_template("errors/403.html"), 403 - - @app.errorhandler(404) - def page_not_found(error): - if not _wants_html(): - return fail(message="Not Found", code=404), 200 - return render_template("errors/404.html"), 404 - - @app.errorhandler(500) - def internal_server_error(error): - if not _wants_html(): - return fail(message="Internal Server Error", code=500), 200 - return render_template("errors/500.html"), 500 diff --git a/iti/applications/extensions/event_bus.py b/iti/applications/extensions/event_bus.py deleted file mode 100644 index dc91dce..0000000 --- a/iti/applications/extensions/event_bus.py +++ /dev/null @@ -1,10 +0,0 @@ -from .eventbus import EventBus - -eventbus = EventBus() - - -def init_eventbus(app): - """ - 初始化事件总线 - """ - eventbus.init_app(app) diff --git a/iti/applications/extensions/eventbus/__init__.py b/iti/applications/extensions/eventbus/__init__.py deleted file mode 100644 index 5282cbc..0000000 --- a/iti/applications/extensions/eventbus/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .event_bus import EventBus -from .event_middleware import EventMiddleware -from .event_handler import ( - BaseEventHandler, - FlaskEventHandler, -) diff --git a/iti/applications/extensions/eventbus/event_bus.py b/iti/applications/extensions/eventbus/event_bus.py deleted file mode 100644 index 88045ce..0000000 --- a/iti/applications/extensions/eventbus/event_bus.py +++ /dev/null @@ -1,477 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor -import threading -import asyncio -import inspect -from functools import wraps -from .event_middleware import EventMiddleware -from iti.applications.common import setup_logger - -logger = setup_logger(__name__) - - -class EventBus: - """ - 事件总线 - """ - - def __init__(self, max_workers: int = 10): - self._handlers: dict[str, list[dict]] = {} - self._middlewares: list[EventMiddleware] = [] - self._executor = ThreadPoolExecutor( - thread_name_prefix="EventBusExecutor", max_workers=max_workers - ) - self._lock = threading.Lock() - self._stats = { - "errors": 0, - "events_emitted": 0, - "events_processed": 0, - } - - def init_app(self, app): - """ - 初始化事件总线 - """ - self._app = app - - def on(self, event_name: str, order: int = 0, async_mode: bool = False): - """ - 注册事件处理器(装饰器形式) - """ - - def decorator(func): - self._register_handler(event_name, func, order, async_mode) - return func - - return decorator - - def register_handler( - self, event_name: str, handler_func, order: int = 0, async_mode: bool = False - ): - """ - 手动注册事件处理器 - - Args: - event_name: 事件名称 - handler_func: 处理器函数 - order: 执行顺序(数字越小越先执行) - async_mode: 是否为异步模式 - """ - self._register_handler(event_name, handler_func, order, async_mode) - - def _register_handler( - self, event_name: str, handler_func, order: int = 0, async_mode: bool = False - ): - """ - 内部方法:注册事件处理器 - """ - with self._lock: - if event_name not in self._handlers: - self._handlers[event_name] = [] - - # 分析函数签名并缓存 - sig_info = self._analyze_signature(handler_func) - - # 包装上下文 - wrapped_func = self._wrap_context(handler_func, async_mode) - - # 添加处理器 - self._handlers[event_name].append( - { - "func": wrapped_func, - "orginal_func": handler_func, - "order": order, - "async_mode": async_mode, - "name": handler_func.__name__, - "sig_info": sig_info, # 缓存签名信息 - } - ) - - # 排序(按order升序) - self._handlers[event_name].sort(key=lambda x: x["order"]) - - def _analyze_signature(self, func): - """ - 分析函数签名,返回参数接受信息 - - Returns: - dict: 包含以下信息: - - max_positional: 最大位置参数数量(不含self) - - accepts_var_positional: 是否接受*args - - accepts_var_keyword: 是否接受**kwargs - """ - try: - sig = inspect.signature(func) - params = list(sig.parameters.values()) - - # 检查是否接受可变参数 - accepts_var_positional = any( - p.kind == inspect.Parameter.VAR_POSITIONAL for p in params - ) - accepts_var_keyword = any( - p.kind == inspect.Parameter.VAR_KEYWORD for p in params - ) - - # 计算最大位置参数数量(排除 self/cls) - positional_params = [ - p - for p in params - if p.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - and p.name not in ("self", "cls") - ] - max_positional = len(positional_params) - - return { - "max_positional": max_positional, - "accepts_var_positional": accepts_var_positional, - "accepts_var_keyword": accepts_var_keyword, - } - except Exception as e: - logger.warning(f"无法分析函数签名 {func.__name__}: {e},将使用默认行为") - # 如果无法分析签名,返回保守的默认值(接受所有参数) - return { - "max_positional": float("inf"), - "accepts_var_positional": True, - "accepts_var_keyword": True, - } - - def _adapt_args(self, sig_info, args, kwargs): - """ - 根据函数签名信息适配参数 - - Args: - sig_info: 函数签名信息 - args: 原始位置参数 - kwargs: 原始关键字参数 - - Returns: - tuple: (adapted_args, adapted_kwargs) - """ - # 如果接受可变位置参数,直接返回所有参数 - if sig_info["accepts_var_positional"]: - adapted_args = args - else: - # 只传递函数能接受的参数数量 - max_pos = sig_info["max_positional"] - adapted_args = args[:max_pos] if max_pos != float("inf") else args - - # 如果接受可变关键字参数,直接返回所有 kwargs - if sig_info["accepts_var_keyword"]: - adapted_kwargs = kwargs - else: - # 这里可以进一步过滤 kwargs,但通常不需要 - # 因为多余的 kwargs 会在调用时报错 - adapted_kwargs = kwargs - - return adapted_args, adapted_kwargs - - def _auto_merge_orm_objects(self, items): - """ - 自动将 ORM 对象 merge 到当前线程的 session - - Args: - items: 参数列表或字典值 - - Returns: - 处理后的参数(ORM 对象已 merge) - """ - from sqlalchemy import inspect as sa_inspect - from sqlalchemy.orm.exc import UnmappedInstanceError - from werkzeug.local import LocalProxy - - result = [] - for item in items: - # 跳过 LocalProxy 对象(如 current_user),避免触发 JWT 上下文检查 - if isinstance(item, LocalProxy): - result.append(item) - continue - - # 检测是否是 SQLAlchemy ORM 对象 - if hasattr(item, "__table__"): - try: - from iti.applications.extensions import db - - # 检查对象状态 - state = sa_inspect(item) - - # 如果对象是 persistent 或 detached,merge 到新 session - if state.persistent or state.detached: - merged = db.session.merge(item, load=False) - result.append(merged) - else: - # transient 或其他状态,直接传递 - result.append(item) - - except (UnmappedInstanceError, Exception) as e: - logger.warning(f"merge ORM 对象失败: {e},使用原对象") - result.append(item) - - elif isinstance(item, (list, tuple)): - # 递归处理集合 - merged_items = self._auto_merge_orm_objects(item) - result.append(type(item)(merged_items)) - - elif isinstance(item, dict): - # 递归处理字典 - result.append( - {k: self._auto_merge_orm_objects([v])[0] for k, v in item.items()} - ) - else: - # 基础类型,直接传递 - result.append(item) - - return result - - def _wrap_context(self, func, async_mode: bool = False): - """ - 包装上下文 - - 注意: - - sync_mode: 在当前上下文中同步执行,需要 app_context 包装 - - async_mode: 在线程池中异步执行,app_context 在 _run_async_handler 中统一处理 - """ - if async_mode: - # async 模式:不包装 app_context,只转换为 async 函数(用于 asyncio.run) - if asyncio.iscoroutinefunction(func): - # 已经是 async 函数 - async def async_wrapper(*args, **kwargs): - return await func(*args, **kwargs) - else: - # 普通函数,包装为 async(不 await) - async def async_wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return async_wrapper - else: - # sync 模式:包装 app_context - @self._with_context - def sync_wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return sync_wrapper - - def _with_context(self, func): - """ - 包装同步上下文 - """ - - @wraps(func) - def wrapper(*args, **kwargs): - with self._app.app_context(): - return func(*args, **kwargs) - - return wrapper - - def emit(self, event_name: str, *args, **kwargs): - """ - 发布事件 - """ - try: - with self._lock: - self._stats["events_emitted"] += 1 - - if event_name not in self._handlers: - return - - # 执行中间件 - processed_args = args - processed_kwargs = kwargs - for middleware in self._middlewares: - try: - processed_args, processed_kwargs = middleware( - event_name, processed_args, processed_kwargs - ) - except Exception as e: - logger.error( - f"事件中间件错误: {e}, 事件: {event_name}, args: {processed_args}, kwargs: {processed_kwargs}", - exc_info=True, - ) - - # 分离同步、异步事件,分别发布 - sync_handlers = [ - h for h in self._handlers[event_name] if not h["async_mode"] - ] - async_handlers = [h for h in self._handlers[event_name] if h["async_mode"]] - - # 先发布异步事件 - if async_handlers: - for handler in async_handlers: - try: - # 根据函数签名适配参数 - adapted_args, adapted_kwargs = self._adapt_args( - handler["sig_info"], processed_args, processed_kwargs - ) - - self._executor.submit( - self._run_async_handler, - handler["func"], - adapted_args, - adapted_kwargs, - ) - logger.info( - f"异步事件处理器: {handler['name']} 已执行, args: {adapted_args}, kwargs: {adapted_kwargs}" - ) - except Exception as e: - logger.error( - f"异步事件处理器错误: {e}, 事件: {event_name}, args: {processed_args}, kwargs: {processed_kwargs}", - exc_info=True, - ) - - # 再发布同步事件 - if sync_handlers: - for handler in sync_handlers: - try: - # 根据函数签名适配参数 - adapted_args, adapted_kwargs = self._adapt_args( - handler["sig_info"], processed_args, processed_kwargs - ) - - handler["func"](*adapted_args, **adapted_kwargs) - except Exception as e: - logger.error( - f"同步事件处理器错误: {e}, 事件: {event_name}, args: {processed_args}, kwargs: {processed_kwargs}", - exc_info=True, - ) - except Exception as e: - with self._lock: - self._stats["errors"] += 1 - logger.error( - f"事件发布失败: {e}, 事件: {event_name}, args: {args}, kwargs: {kwargs}", - exc_info=True, - ) - - def _run_async_handler(self, func, args, kwargs): - """ - 在线程池中运行异步处理器 - 自动将 ORM 对象 merge 到当前线程的 session - """ - try: - with self._app.app_context(): - # 自动 merge ORM 对象到当前线程的 session - merged_args = self._auto_merge_orm_objects(args) - merged_kwargs = { - k: self._auto_merge_orm_objects([v])[0] for k, v in kwargs.items() - } - - asyncio.run(func(*merged_args, **merged_kwargs)) - except Exception as e: - logger.error( - f"异步事件处理器错误: {e}, args: {args}, kwargs: {kwargs}", - exc_info=True, - ) - - def get_stats(self): - """ - 获取统计信息 - """ - with self._lock: - return self._stats - - def get_handlers(self, event_name: str): - """ - 获取事件处理器 - """ - return self._handlers[event_name] - - def clear_handlers(self, event_name: str): - """ - 清除指定事件的所有处理器 - """ - with self._lock: - if event_name in self._handlers: - del self._handlers[event_name] - - def remove_handler(self, event_name: str, handler_name: str): - """ - 移除指定名称的处理器 - - Args: - event_name: 事件名称 - handler_name: 处理器函数名称 - """ - with self._lock: - if event_name in self._handlers: - self._handlers[event_name] = [ - handler - for handler in self._handlers[event_name] - if handler["name"] != handler_name - ] - - # 如果该事件没有处理器了,删除事件 - if not self._handlers[event_name]: - del self._handlers[event_name] - - def get_handler_count(self, event_name: str = None): - """ - 获取处理器数量 - - Args: - event_name: 事件名称,如果为None则返回所有事件的处理器总数 - - Returns: - int: 处理器数量 - """ - with self._lock: - if event_name: - return len(self._handlers.get(event_name, [])) - else: - return sum(len(handlers) for handlers in self._handlers.values()) - - def list_handlers(self, event_name: str = None): - """ - 列出所有处理器信息 - - Args: - event_name: 事件名称,如果为None则列出所有事件 - - Returns: - dict: 处理器信息字典 - """ - with self._lock: - if event_name: - return { - event_name: [ - { - "name": handler["name"], - "order": handler["order"], - "async_mode": handler["async_mode"], - "max_positional": handler["sig_info"]["max_positional"], - "accepts_var_args": handler["sig_info"][ - "accepts_var_positional" - ], - } - for handler in self._handlers.get(event_name, []) - ] - } - else: - return { - event: [ - { - "name": handler["name"], - "order": handler["order"], - "async_mode": handler["async_mode"], - "max_positional": handler["sig_info"]["max_positional"], - "accepts_var_args": handler["sig_info"][ - "accepts_var_positional" - ], - } - for handler in handlers - ] - for event, handlers in self._handlers.items() - } - - def middleware(self, middleware: EventMiddleware): - """ - 注册中间件 - """ - self._middlewares.append(middleware) - return middleware - - def shutdown(self): - """ - 关闭事件总线 - """ - self._executor.shutdown(wait=False) diff --git a/iti/applications/extensions/eventbus/event_handler.py b/iti/applications/extensions/eventbus/event_handler.py deleted file mode 100644 index 257bb77..0000000 --- a/iti/applications/extensions/eventbus/event_handler.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import ABC, abstractmethod -from iti.applications.common import setup_logger -from flask import current_app - -logger = setup_logger(__name__) - - -class BaseEventHandler(ABC): - """ - 事件处理器基类 - """ - - def __init__(self, order: int = 0, async_mode: bool = False): - self.order = order - self.async_mode = async_mode - - @abstractmethod - def handle(self, data: any) -> any: - """ - 处理事件 - """ - pass - - def before_handle(self, data: any) -> any: - """ - 处理事件前 - """ - return data - - def after_handle(self, data: any) -> any: - """ - 处理事件后 - """ - return data - - def on_error(self, error: Exception, data: any) -> None: - """ - 处理事件错误 - """ - logger.error(f"事件处理错误: {error}, 数据: {data}", exc_info=True) - - -class FlaskEventHandler(BaseEventHandler): - """Flask 事件处理器基类""" - - def __init__(self, order: int = 0, async_mode: bool = False): - super().__init__(order, async_mode) - self._app = None - - @property - def app(self): - """获取 Flask 应用实例""" - if self._app is None: - self._app = current_app - return self._app - - def handle(self, data: any) -> any: - """处理事件""" - try: - data = self.before_handle(data) - result = self._do_handle(data) - result = self.after_handle(result) - return result - except Exception as e: - self.on_error(e, data) - raise - - def _do_handle(self, data: any) -> any: - """实际处理逻辑""" - pass diff --git a/iti/applications/extensions/eventbus/event_middleware.py b/iti/applications/extensions/eventbus/event_middleware.py deleted file mode 100644 index 671a07b..0000000 --- a/iti/applications/extensions/eventbus/event_middleware.py +++ /dev/null @@ -1,19 +0,0 @@ -class EventMiddleware: - """ - 事件中间件基类 - """ - - def __call__(self, event_name: str, args: tuple, kwargs: dict) -> tuple: - """ - 处理事件 - 返回处理后的 (args, kwargs) - """ - return args, kwargs - - def on_error( - self, error: Exception, event_name: str, args: tuple, kwargs: dict - ) -> None: - """ - 处理事件错误 - """ - pass diff --git a/iti/applications/extensions/http.py b/iti/applications/extensions/http.py deleted file mode 100644 index 8e9c118..0000000 --- a/iti/applications/extensions/http.py +++ /dev/null @@ -1,29 +0,0 @@ -from flask import request -from apiflask import Schema -from apiflask.fields import Integer, String, Field - - -class BaseResponse(Schema): - data = Field() - code = Integer() - message = String() - - -def init_http(app): - app.config["BASE_RESPONSE_SCHEMA"] = BaseResponse - app.config["BASE_RESPONSE_DATA_KEY"] = "data" - - @app.before_request - def before_request(): - # print("请求地址:" + str(request.path)) - # print("请求方法:" + str(request.method)) - # print("---请求headers--start--") - # print(str(request.headers).rstrip()) - # print("---请求headers--end----") - # print("GET参数:" + str(request.args)) - # print("POST参数:" + str(request.form)) - - url = request.path # 当前请求的URL - passUrl = ["/login"] - if url in passUrl: - pass diff --git a/iti/applications/extensions/jwt.py b/iti/applications/extensions/jwt.py deleted file mode 100644 index d2d7b3a..0000000 --- a/iti/applications/extensions/jwt.py +++ /dev/null @@ -1,30 +0,0 @@ -from flask_jwt_extended import JWTManager -from iti.applications.common.utils import fail - -jwt = JWTManager() - - -def init_jwt(app) -> None: - """ - 初始化 JWT - """ - jwt.init_app(app) - - # 自定义错误消息 - @jwt.unauthorized_loader - def unauthorized_loader(_callback): - return fail("缺少令牌参数 Authorization Bearer", code=401), 401 - - @jwt.invalid_token_loader - def invalid_token_loader(_callback): - return fail("无效的令牌", code=401, data=str(_callback)), 401 - - @jwt.expired_token_loader - def expired_token_loader(_header, _payload): - return fail("令牌已过期", code=401), 401 - - @jwt.user_identity_loader - def user_identity_loader(user): - if user is None or not hasattr(user, "id"): - return None - return user.id diff --git a/iti/applications/extensions/limit.py b/iti/applications/extensions/limit.py deleted file mode 100644 index 830c9c0..0000000 --- a/iti/applications/extensions/limit.py +++ /dev/null @@ -1,40 +0,0 @@ -from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request -from flask import request -from flask_limiter import Limiter -from iti.applications.common.utils import fail - - -def get_user_identifier(): - """ - 获取用户标识符 - 如果用户已登录,则返回用户ID - 如果用户未登录,则返回请求的IP地址 - """ - verify_jwt_in_request(optional=True, verify_type=False) - identity = get_jwt_identity() - if identity is not None: - return identity - return request.remote_addr - - -# 全局 limiter 实例。路由模块会在导入期使用 @limiter.limit。 -limiter = Limiter(key_func=get_user_identifier) - - -def init_limiter(app) -> None: - """ - 初始化限流器 - 从 Flask 配置中读取限流设置 - """ - storage_uri = app.config.get( - "RATELIMIT_STORAGE_URL", - app.config.get("RATELIMIT_STORAGE_URI", "memory://"), - ) - limiter.enabled = app.config.get("RATELIMIT_ENABLED", True) - limiter._storage_uri = storage_uri - app.config["RATELIMIT_STORAGE_URI"] = storage_uri - limiter.init_app(app) - - @app.errorhandler(429) - def handle_rate_limit_exceeded(e): - return fail(message="请求过于频繁,请稍后再试", code=429) diff --git a/iti/applications/extensions/migrate.py b/iti/applications/extensions/migrate.py deleted file mode 100644 index 5f8a4bf..0000000 --- a/iti/applications/extensions/migrate.py +++ /dev/null @@ -1,11 +0,0 @@ -from flask_migrate import Migrate -from .db import db - -migrate = Migrate() - - -def init_migrate(app) -> None: - """ - 初始化迁移 - """ - migrate.init_app(app, db) diff --git a/iti/applications/extensions/moment.py b/iti/applications/extensions/moment.py deleted file mode 100644 index 26483b9..0000000 --- a/iti/applications/extensions/moment.py +++ /dev/null @@ -1,8 +0,0 @@ -from flask_moment import Moment - - -moment = Moment() - - -def init_moment(app): - moment.init_app(app) diff --git a/iti/applications/extensions/plugins.py b/iti/applications/extensions/plugins.py deleted file mode 100644 index 5ecbd7c..0000000 --- a/iti/applications/extensions/plugins.py +++ /dev/null @@ -1,62 +0,0 @@ -import importlib -from flask import Blueprint -import os -import json - -plugin_bp = Blueprint("plugin", __name__, url_prefix="/plugin") - -PLUGIN_ENABLE_FOLDERS = [] -PLUGIN_IMPORTLIB = [] - - -def init_plugin(app) -> None: - """ - 初始化运行时插件。 - - 插件是部署时可选扩展,不是业务项目主模块。业务主模块应通过 - create_app(modules=[...]) 显式注册。 - """ - global PLUGIN_ENABLE_FOLDERS - app.register_blueprint(plugin_bp) - - # 载入插件 PLUGIN_ENABLE_FOLDERS 是插件文件夹名 - PLUGIN_ENABLE_FOLDERS = app.config.get("PLUGIN_ENABLE_FOLDERS", []) - - for folder in PLUGIN_ENABLE_FOLDERS: - plugin_info = { - "plugin_name": folder, - } - - try: - if os.path.exists("plugins/" + folder + "/__init__.json"): - with open( - "plugins/" + folder + "/__init__.json", "r", encoding="utf-8" - ) as f: - plugin_info = json.loads(f.read()) - # 将插件全部载入 - PLUGIN_IMPORTLIB.append(importlib.import_module("plugins." + folder)) - print(f" * Plugin: Loaded plugin: {plugin_info['plugin_name']} ...") - except BaseException as e: - info = ( - f" * Plugin: Crash a error when loading {plugin_info['plugin_name'] if len(plugin_info) != 0 else 'plugin'} :" - + "\n" - ) - app.logger.error(info) - app.logger.exception(e) - - -def broadcast_execute(app, function_name): - for plugin in PLUGIN_IMPORTLIB: - try: - # 初始化完成事件 - try: - getattr(plugin, function_name)(app) - except AttributeError: # 没有插件启用事件就不调用 - pass - - except BaseException as e: - app.logger.exception(e) - - if function_name == "event_finish": - with app.app_context(): - broadcast_execute(app, "event_context") diff --git a/iti/applications/routes/__init__.py b/iti/applications/routes/__init__.py deleted file mode 100644 index 819be61..0000000 --- a/iti/applications/routes/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from iti.applications.extensions import broadcast_execute -from iti.applications.routes.front import bp as frontend_bp - - -def init_routes(app): - # 前端路由注册(可选) - if app.config.get("FRONTEND_ENABLED", False): - app.register_blueprint(frontend_bp) - - # 插件初始化 - broadcast_execute(app, "event_init") diff --git a/iti/applications/routes/front.py b/iti/applications/routes/front.py deleted file mode 100644 index 902f596..0000000 --- a/iti/applications/routes/front.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -from pathlib import Path - -from apiflask import APIBlueprint -from flask import abort, current_app, send_from_directory - -bp = APIBlueprint("front", __name__, tag="前端") - - -def _get_frontend_path(): - frontend_path = current_app.config.get("FRONTEND_PATH") - if not frontend_path: - abort(404) - - path = Path(frontend_path) - if not path.is_absolute(): - base_dir = Path(current_app.config.get("BASE_DIR", os.getcwd())) - path = base_dir / path - return path.resolve() - - -@bp.get("/") -def index(): - """渲染前端 SPA 入口页面""" - frontend_path = _get_frontend_path() - index_path = frontend_path / "index.html" - if not index_path.exists(): - abort(404) - return send_from_directory(frontend_path, "index.html") - - -@bp.get("/") -def fallback(fallback): - """兜底: 避免history模式下的影响""" - frontend_path = _get_frontend_path() - target_path = frontend_path / fallback - if target_path.exists() and target_path.is_file(): - return send_from_directory(frontend_path, fallback) - - index_path = frontend_path / "index.html" - if not index_path.exists(): - abort(404) - return send_from_directory(frontend_path, "index.html") diff --git a/iti/applications/service/__init__.py b/iti/applications/service/__init__.py deleted file mode 100644 index d1a35e0..0000000 --- a/iti/applications/service/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -def init_services(app) -> None: - """初始化Services""" - return None - diff --git a/iti/audit.py b/iti/audit.py new file mode 100644 index 0000000..e0a26e4 --- /dev/null +++ b/iti/audit.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import logging +import queue +import threading +from collections.abc import Callable +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from functools import wraps +from inspect import isawaitable +from typing import Any + +from fastapi import Request + +from iti.auth import Actor +from iti.service_client import ServiceClientError, service_client + + +logger = logging.getLogger("iti.audit") +SENSITIVE_KEYS = {"password", "token", "authorization", "secret", "refreshToken", "refresh_token"} + + +@dataclass(frozen=True) +class AuditEvent: + type: str + title: str + success: bool = True + actor_id: str | None = None + actor_type: str | None = None + method: str | None = None + path: str | None = None + ip: str | None = None + user_agent: str | None = None + target_type: str | None = None + target_id: str | None = None + diff: dict[str, Any] | None = None + desc: str | None = None + error: str | None = None + trace_id: str | None = None + occurred_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + def payload(self) -> dict[str, Any]: + return {key: value for key, value in asdict(self).items() if value is not None} + + +class AuditDispatcher: + def __init__(self, app) -> None: + self.app = app + config = app.state.config + self.enabled = bool(config.audit_enabled) + self.service_name = config.audit_service_name + self.batch_size = max(int(config.audit_batch_size), 1) + self.flush_interval = float(config.audit_flush_interval_seconds) + self._queue: queue.Queue[AuditEvent] = queue.Queue(maxsize=config.audit_queue_size) + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if not self.enabled: + return + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=3) + + def emit(self, event: AuditEvent) -> None: + if not self.enabled: + return + try: + self._queue.put_nowait(event) + except queue.Full: + try: + self._queue.get_nowait() + self._queue.put_nowait(event) + except queue.Empty: + logger.warning("audit queue full and event dropped") + + def _loop(self) -> None: + while not self._stop.is_set(): + batch = self._drain() + if batch: + self._send(batch) + self._stop.wait(self.flush_interval) + batch = self._drain() + if batch: + self._send(batch) + + def _drain(self) -> list[AuditEvent]: + batch: list[AuditEvent] = [] + for _ in range(self.batch_size): + try: + batch.append(self._queue.get_nowait()) + except queue.Empty: + break + return batch + + def _send(self, batch: list[AuditEvent]) -> None: + try: + client = service_client(self.app, self.service_name) + client.post("/internal/audit/events", json={"events": [item.payload() for item in batch]}) + except ServiceClientError as exc: + logger.warning("audit send failed: %s", exc) + + +def init_audit(app) -> AuditDispatcher: + dispatcher = AuditDispatcher(app) + app.state.audit_dispatcher = dispatcher + return dispatcher + + +def audit_operation( + request: Request, + *, + title: str, + target_type: str | None = None, + target_id: str | None = None, + before: dict[str, Any] | None = None, + after: dict[str, Any] | None = None, + success: bool = True, + desc: str | None = None, + error: str | None = None, +) -> None: + dispatcher = getattr(request.app.state, "audit_dispatcher", None) + if dispatcher is None: + return + actor = getattr(request.state, "actor", None) + dispatcher.emit( + AuditEvent( + type="operation", + title=title, + success=success, + actor_id=getattr(actor, "id", None), + actor_type=getattr(actor, "type", None), + method=request.method, + path=request.url.path, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + target_type=target_type, + target_id=target_id, + diff=build_diff(before, after) if before is not None or after is not None else None, + desc=desc, + error=error, + trace_id=getattr(request.state, "trace_id", None), + ) + ) + + +def operation_log( + title: str, + *, + target_type: str | None = None, +) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + request = _find_request(args, kwargs) + try: + result = func(*args, **kwargs) + if isawaitable(result): + result = await result + if request is not None: + audit_operation( + request, + title=title, + target_type=target_type, + ) + return result + except Exception as exc: + if request is not None: + audit_operation( + request, + title=title, + target_type=target_type, + success=False, + error=str(exc), + ) + raise + + return async_wrapper + + return decorator + + +def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request | None: + for value in list(args) + list(kwargs.values()): + if isinstance(value, Request): + return value + return None + + +def audit_login( + request: Request, + *, + title: str = "登录", + actor: Actor | None = None, + success: bool = True, + desc: str | None = None, + error: str | None = None, +) -> None: + dispatcher = getattr(request.app.state, "audit_dispatcher", None) + if dispatcher is None: + return + actor = actor or getattr(request.state, "actor", None) + dispatcher.emit( + AuditEvent( + type="login", + title=title, + success=success, + actor_id=getattr(actor, "id", None), + actor_type=getattr(actor, "type", None), + method=request.method, + path=request.url.path, + ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + desc=desc, + error=error, + trace_id=getattr(request.state, "trace_id", None), + ) + ) + + +def build_diff(before: dict[str, Any] | None, after: dict[str, Any] | None) -> dict[str, Any]: + before = before or {} + after = after or {} + keys = sorted(set(before) | set(after)) + changes = {} + for key in keys: + old = before.get(key) + new = after.get(key) + if old != new: + changes[key] = {"before": sanitize_value(key, old), "after": sanitize_value(key, new)} + return changes + + +def sanitize(value: Any) -> Any: + if isinstance(value, dict): + result = {} + for key, item in value.items(): + if str(key) in SENSITIVE_KEYS: + result[key] = "***" + else: + result[key] = sanitize(item) + return result + if isinstance(value, list): + return [sanitize(item) for item in value] + return value + + +def sanitize_value(key: str, value: Any) -> Any: + if key in SENSITIVE_KEYS: + return "***" + return sanitize(value) diff --git a/iti/auth/__init__.py b/iti/auth/__init__.py new file mode 100644 index 0000000..e9c03d7 --- /dev/null +++ b/iti/auth/__init__.py @@ -0,0 +1,33 @@ +from .jwt import create_access_token, create_refresh_token, decode_token +from .permissions import ( + Actor, + Principal, + PermissionProvider, + StaticPermissionProvider, + get_principal, + get_service_actor, + require_actor, + require_permission, + require_service, + require_service_scope, + require_user, + set_permission_provider, +) + +__all__ = [ + "Actor", + "PermissionProvider", + "Principal", + "StaticPermissionProvider", + "create_access_token", + "create_refresh_token", + "decode_token", + "get_principal", + "get_service_actor", + "require_actor", + "require_permission", + "require_service", + "require_service_scope", + "require_user", + "set_permission_provider", +] diff --git a/iti/auth/jwt.py b/iti/auth/jwt.py new file mode 100644 index 0000000..e9c4a46 --- /dev/null +++ b/iti/auth/jwt.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import JWTError, jwt + +from iti.config import BaseConfig +from iti.exceptions import Unauthorized + + +def _create_token( + subject: str, + config: BaseConfig, + *, + token_type: str, + expires_seconds: int, + claims: dict[str, Any] | None = None, +) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": subject, + "type": token_type, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=expires_seconds)).timestamp()), + **(claims or {}), + } + return jwt.encode(payload, config.jwt_secret_key, algorithm=config.jwt_algorithm) + + +def create_access_token( + subject: str, + config: BaseConfig, + claims: dict[str, Any] | None = None, +) -> str: + return _create_token( + subject, + config, + token_type="access", + expires_seconds=config.jwt_access_token_expires_seconds, + claims=claims, + ) + + +def create_refresh_token( + subject: str, + config: BaseConfig, + claims: dict[str, Any] | None = None, +) -> str: + return _create_token( + subject, + config, + token_type="refresh", + expires_seconds=config.jwt_refresh_token_expires_seconds, + claims=claims, + ) + + +def decode_token(token: str, config: BaseConfig, *, token_type: str | None = None) -> dict: + try: + payload = jwt.decode(token, config.jwt_secret_key, algorithms=[config.jwt_algorithm]) + except JWTError as exc: + raise Unauthorized("无效的令牌") from exc + if token_type is not None and payload.get("type") != token_type: + raise Unauthorized("令牌类型错误") + return payload diff --git a/iti/auth/permissions.py b/iti/auth/permissions.py new file mode 100644 index 0000000..51d8836 --- /dev/null +++ b/iti/auth/permissions.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Protocol + +from fastapi import Depends, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from iti.exceptions import PermissionDenied, Unauthorized + +from .jwt import decode_token + + +bearer_scheme = HTTPBearer(auto_error=False) + + +@dataclass(frozen=True) +class Principal: + id: str + type: str = "user" + permissions: frozenset[str] = field(default_factory=frozenset) + roles: frozenset[str] = field(default_factory=frozenset) + scopes: frozenset[str] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class Actor: + id: str + type: str + principal: Principal | None = None + service_name: str | None = None + + +class PermissionProvider(Protocol): + def load_principal(self, principal_id: str, request: Request) -> Principal | None: + ... + + def has_permission(self, principal: Principal, code: str) -> bool: + ... + + def has_scope(self, principal: Principal, scope: str) -> bool: + ... + + +class StaticPermissionProvider: + def load_principal(self, principal_id: str, request: Request) -> Principal | None: + return Principal(id=principal_id) + + def has_permission(self, principal: Principal, code: str) -> bool: + return code in principal.permissions + + def has_scope(self, principal: Principal, scope: str) -> bool: + return scope in principal.scopes + + +permission_provider: PermissionProvider = StaticPermissionProvider() + + +def set_permission_provider(provider: PermissionProvider) -> None: + global permission_provider + permission_provider = provider + + +def get_principal( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), +) -> Principal | None: + if credentials is None: + return None + config = request.app.state.config + payload = decode_token(credentials.credentials, config, token_type="access") + principal_id = payload.get("sub") + if not principal_id: + raise Unauthorized("无效的令牌") + provider = getattr(request.app.state, "permission_provider", permission_provider) + principal = provider.load_principal(principal_id, request) + if principal is None: + raise Unauthorized("用户不存在或已失效") + request.state.principal = principal + request.state.actor = Actor(id=principal.id, type="user", principal=principal) + return principal + + +def require_user( + principal: Principal | None = Depends(get_principal), +) -> Principal: + if principal is None: + raise Unauthorized("缺少令牌参数 Authorization Bearer") + return principal + + +def require_permission(code: str) -> Callable: + def dependency( + request: Request, + principal: Principal = Depends(require_user), + ) -> Principal: + provider = getattr(request.app.state, "permission_provider", permission_provider) + if not provider.has_permission(principal, code): + raise PermissionDenied("权限不足", code=403) + return principal + + return dependency + + +def require_service_scope(scope: str) -> Callable: + def dependency( + request: Request, + principal: Principal = Depends(require_user), + ) -> Principal: + provider = getattr(request.app.state, "permission_provider", permission_provider) + if not provider.has_scope(principal, scope): + raise PermissionDenied("服务权限不足", code=403) + return principal + + return dependency + + +def get_service_actor( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), +) -> Actor | None: + if credentials is None: + return None + service_name = match_service_token( + getattr(request.app.state.config, "service_tokens", {}), + credentials.credentials, + ) + if service_name is None: + return None + actor = Actor(id=service_name, type="service", service_name=service_name) + request.state.actor = actor + return actor + + +def match_service_token(tokens: Mapping[str, str], token: str) -> str | None: + for name, expected in tokens.items(): + if expected and token == expected: + return name + return None + + +def require_service(service_name: str | None = None) -> Callable: + def dependency( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + ) -> Actor: + actor = get_service_actor(request, credentials) + if actor is None: + raise Unauthorized("无效的服务令牌") + if service_name is not None and actor.service_name != service_name: + raise PermissionDenied("服务权限不足", code=403) + return actor + + return dependency + + +def require_actor( + *, + permissions: list[str] | tuple[str, ...] | None = None, + allow_service: bool = False, + service_name: str | None = None, +) -> Callable: + required_permissions = tuple(permissions or ()) + + def dependency( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + ) -> Actor: + if credentials is None: + raise Unauthorized("缺少令牌参数 Authorization Bearer") + + service_actor = get_service_actor(request, credentials) + if service_actor is not None: + if not allow_service: + raise PermissionDenied("服务权限不足", code=403) + if service_name is not None and service_actor.service_name != service_name: + raise PermissionDenied("服务权限不足", code=403) + return service_actor + + principal = get_principal(request, credentials) + if principal is None: + raise Unauthorized("缺少令牌参数 Authorization Bearer") + provider = getattr(request.app.state, "permission_provider", permission_provider) + missing = [ + code + for code in required_permissions + if not provider.has_permission(principal, code) + ] + if missing: + raise PermissionDenied("权限不足", code=403) + actor = Actor(id=principal.id, type="user", principal=principal) + request.state.actor = actor + return actor + + return dependency diff --git a/iti/cache/__init__.py b/iti/cache/__init__.py new file mode 100644 index 0000000..da213ce --- /dev/null +++ b/iti/cache/__init__.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + + +@dataclass +class CacheItem: + value: Any + expires_at: float | None + + +class CacheManager: + def __init__(self, *, default_timeout: int = 300) -> None: + self.default_timeout = default_timeout + self._items: dict[str, CacheItem] = {} + + def get(self, key: str) -> Any: + item = self._items.get(key) + if item is None: + return None + if item.expires_at is not None and item.expires_at < time.time(): + self._items.pop(key, None) + return None + return item.value + + def set(self, key: str, value: Any, timeout: int | None = None) -> None: + timeout = self.default_timeout if timeout is None else timeout + expires_at = None if timeout <= 0 else time.time() + timeout + self._items[key] = CacheItem(value=value, expires_at=expires_at) + + def delete(self, key: str) -> None: + self._items.pop(key, None) + + def clear(self) -> None: + self._items.clear() diff --git a/iti/cli.py b/iti/cli.py index 00bdaa8..faaaa16 100644 --- a/iti/cli.py +++ b/iti/cli.py @@ -1,6 +1,38 @@ import click +from iti.config import get_config + @click.group() def iti_cli() -> None: """iTi-Flask framework commands.""" + + +@iti_cli.command("config") +@click.option("--env", "env_name", default=None, help="Config environment name.") +def show_config(env_name: str | None) -> None: + config = get_config(env_name) + click.echo(f"app_env={config.app_env}") + click.echo(f"database_url={config.database_url}") + click.echo(f"health_enabled={config.health_enabled}") + click.echo(f"ready_check_db={config.ready_check_db}") + + +@iti_cli.command("routes") +@click.argument("app_import") +def list_routes(app_import: str) -> None: + app = _load_app(app_import) + for route in app.routes: + methods = ",".join(sorted(getattr(route, "methods", []) or [])) + path = getattr(route, "path", "") + name = getattr(route, "name", "") + click.echo(f"{methods:20} {path:40} {name}") + + +def _load_app(app_import: str): + module_name, _, attr_name = app_import.partition(":") + if not module_name or not attr_name: + raise click.ClickException("app import must use module:attribute") + module = __import__(module_name, fromlist=[attr_name]) + app = getattr(module, attr_name) + return app() if callable(app) else app diff --git a/iti/applications/common/enums/__init__.py b/iti/common/enums/__init__.py similarity index 100% rename from iti/applications/common/enums/__init__.py rename to iti/common/enums/__init__.py diff --git a/iti/applications/common/enums/sys.py b/iti/common/enums/sys.py similarity index 100% rename from iti/applications/common/enums/sys.py rename to iti/common/enums/sys.py diff --git a/iti/applications/common/exceptions/biz_exp.py b/iti/common/exceptions/biz_exp.py similarity index 85% rename from iti/applications/common/exceptions/biz_exp.py rename to iti/common/exceptions/biz_exp.py index e5e4d32..2c43f03 100644 --- a/iti/applications/common/exceptions/biz_exp.py +++ b/iti/common/exceptions/biz_exp.py @@ -1,7 +1,7 @@ -from werkzeug.exceptions import HTTPException +from iti.exceptions import BizError -class BizException(HTTPException): +class BizException(BizError): def __init__(self, message: str = "操作失败", code: int = 500, data=None): self.message = message self.code = code diff --git a/iti/applications/common/exceptions/permission.py b/iti/common/exceptions/permission.py similarity index 67% rename from iti/applications/common/exceptions/permission.py rename to iti/common/exceptions/permission.py index c37cb16..c94759e 100644 --- a/iti/applications/common/exceptions/permission.py +++ b/iti/common/exceptions/permission.py @@ -1,4 +1,7 @@ -class PermissionDeniedException(Exception): +from iti.exceptions import PermissionDenied + + +class PermissionDeniedException(PermissionDenied): """ 权限拒绝异常 """ diff --git a/iti/applications/common/filter.py b/iti/common/filter.py similarity index 98% rename from iti/applications/common/filter.py rename to iti/common/filter.py index 59ba460..2572841 100644 --- a/iti/applications/common/filter.py +++ b/iti/common/filter.py @@ -1,5 +1,4 @@ from sqlalchemy import and_ -from iti.applications.extensions.db import db class ModelFilter: @@ -169,7 +168,7 @@ class ModelFilter: "type": self.type_between, } - def get_filter(self, model: db.Model): + def get_filter(self, model): """ 生成安全的SQLAlchemy过滤条件 diff --git a/iti/applications/common/logger.py b/iti/common/logger.py similarity index 100% rename from iti/applications/common/logger.py rename to iti/common/logger.py diff --git a/iti/applications/common/utils/str.py b/iti/common/strings.py similarity index 100% rename from iti/applications/common/utils/str.py rename to iti/common/strings.py diff --git a/iti/applications/common/utils/time.py b/iti/common/time.py similarity index 100% rename from iti/applications/common/utils/time.py rename to iti/common/time.py diff --git a/iti/applications/common/utils/tree.py b/iti/common/tree.py similarity index 100% rename from iti/applications/common/utils/tree.py rename to iti/common/tree.py diff --git a/iti/config.py b/iti/config.py index a6beb85..d00d9c2 100644 --- a/iti/config.py +++ b/iti/config.py @@ -1,297 +1,166 @@ +from __future__ import annotations + import os -from datetime import timedelta -import json +from dataclasses import dataclass, field from pathlib import Path from typing import Any +from dotenv import load_dotenv + -# 项目根目录 -BASE_DIR = os.path.abspath(os.path.dirname(__file__)) +BASE_DIR = Path(os.getenv("ITI_BASE_DIR", Path.cwd())).resolve() -def _get_bool_env(key: str, default: bool = False) -> bool: - """从环境变量获取布尔值 +def load_env_file(env_dir: str | os.PathLike | None = None) -> bool: + search_dir = Path(env_dir or os.getenv("ITI_ENV_DIR") or Path.cwd()).resolve() + env_name = os.getenv("APP_ENV", os.getenv("ITI_ENV", "dev")) + for name in (".env.local", f".env.{env_name}", ".env"): + path = search_dir / name + if path.exists(): + load_dotenv(path, override=False) + return True + return False - Args: - key: 环境变量名 - default: 默认值 - Returns: - 布尔值 - """ +def env_bool(key: str, default: bool = False) -> bool: value = os.getenv(key) if value is None: return default - return value.lower() in ("true", "1", "yes", "on") - - -def _load_env_file(env_dir: str | os.PathLike | None = None) -> bool: - """从项目目录加载 .env 文件。""" - try: - from dotenv import load_dotenv + return value.lower() in {"1", "true", "yes", "on"} - search_dir = Path(env_dir or os.getenv("ITI_ENV_DIR") or os.getcwd()).resolve() - env_name = os.getenv("FLASK_ENV", "dev") - env_files = [ - ".env.local", - f".env.{env_name}", - ".env", - ] - for env_file in env_files: - env_path = search_dir / env_file - if env_path.exists(): - load_dotenv(env_path, override=False) - return True - return False - except ImportError: - return False +def default_mysql_url(database: str) -> str: + return ( + f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:" + f"{os.getenv('MYSQL_PASSWORD', 'password')}@" + f"{os.getenv('MYSQL_HOST', '127.0.0.1')}:" + f"{os.getenv('MYSQL_PORT', '3306')}/{database}?charset=utf8mb4" + ) -# 在定义配置类之前加载环境变量 -_load_env_file() +load_env_file() +@dataclass(slots=True) class BaseConfig: - """基础配置类 - 所有环境共享的配置""" - - BASE_DIR = BASE_DIR - - # 应用配置 - SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production") - - # 数据库配置 - SQLALCHEMY_ENGINE_OPTIONS = { - "json_serializer": lambda obj: json.dumps(obj, ensure_ascii=False), - # "json_deserializer": json.loads, - } - SQLALCHEMY_TRACK_MODIFICATIONS = False - SQLALCHEMY_RECORD_QUERIES = True - SQLALCHEMY_ECHO = False - SQLALCHEMY_COMMIT_ON_TEARDOWN = True - SQLALCHEMY_SHOW_ERROR_DETAILS = True - SQLALCHEMY_DATABASE_URI = os.getenv( - "DATABASE_URL", f"sqlite:///{BASE_DIR}/runtime/iti-flask.db" + app_name: str = "iTi" + app_env: str = "dev" + debug: bool = False + testing: bool = False + base_dir: Path = BASE_DIR + + secret_key: str = field( + default_factory=lambda: os.getenv("SECRET_KEY", "dev-secret-key-change-me") ) + jwt_secret_key: str = field( + default_factory=lambda: os.getenv("JWT_SECRET_KEY", "dev-jwt-secret-change-me") + ) + jwt_algorithm: str = "HS256" + jwt_access_token_expires_seconds: int = 3600 + jwt_refresh_token_expires_seconds: int = 30 * 24 * 3600 - # JWT 配置 - JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-jwt-secret-key") - JWT_TOKEN_LOCATION = ["headers"] - JWT_HEADER_NAME = "Authorization" - JWT_HEADER_TYPE = "Bearer" - JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) - JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) - - # 跨域配置 - CORS_ORIGINS = ["*"] - - # 限流配置 - RATELIMIT_ENABLED = True - RATELIMIT_KEY_PREFIX = "iti_rate_limit_" - RATELIMIT_STORAGE_URL = "memory://" - RATELIMIT_STORAGE_URI = "memory://" - RATELIMIT_DEFAULT = "200 per hour" - - # JSON 序列化配置 - JSON_ENSURE_ASCII = False # 支持中文,不转义为 Unicode - JSON_SORT_KEYS = False # 不对 key 排序 - - # 文件上传配置 - MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200MB,Flask 上传大小限制 - - # 缓存配置 - CACHE_SIMPLE = { - # 类型 NullCache | SimpleCache | FileSystemCache | RedisCache | RedisSentinelCache | RedisClusterCache | UWSGICache | MemcachedCache | SASLMemcachedCache | SpreadSASLMemcachedCache - "ENABLED": True, - "CACHE_TYPE": "SimpleCache", - "CACHE_NO_NULL_WARNING": False, - "CACHE_ARGS": [], - "CACHE_OPTIONS": None, - "CACHE_DEFAULT_TIMEOUT": 0, - "CACHE_IGNORE_ERRORS": False, - "CACHE_THRESHOLD": 500, - "CACHE_KEY_PREFIX": "iti_cache_", - "CACHE_SOURCE_CHECK": False, - # # UWSGI 配置 - # "CACHE_UWSGI_NAME": "mycache@localhost:3031", - # # MEMCACHED 配置 - # "CACHE_MEMCACHED_SERVERS": ["localhost:11211"], - # "CACHE_MEMCACHED_USERNAME": "None", - # "CACHE_MEMCACHED_PASSWORD": "None", - # # REDIS 配置 - # "CACHE_REDIS_URL": "redis://localhost:6379/2", - # "CACHE_REDIS_HOST": "localhost", - # "CACHE_REDIS_PORT": 6379, - # "CACHE_REDIS_PASSWORD": None, - # "CACHE_REDIS_DB": 0, - # "CACHE_REDIS_SENTINELS": ["localhost:26379"], - # "CACHE_REDIS_SENTINEL_MASTER": "mymaster", - # "CACHE_REDIS_CLUSTER": "localhost:6379,localhost:6380,localhost:6381", - # # FILE-SYSTEM 配置 - # "CACHE_DIR": r"/tmp/iti_cache", - } - - # Redis缓存配置 - CACHE_REDIS = { - "ENABLED": False, - "CACHE_TYPE": "RedisCache", - "CACHE_NO_NULL_WARNING": False, - "CACHE_ARGS": [], - "CACHE_OPTIONS": None, - "CACHE_DEFAULT_TIMEOUT": 0, - "CACHE_IGNORE_ERRORS": False, - "CACHE_THRESHOLD": 500, - "CACHE_KEY_PREFIX": "iti_cache_", - "CACHE_SOURCE_CHECK": False, - # REDIS 配置 - # "CACHE_REDIS_URL": "redis://localhost:6379/0", - "CACHE_REDIS_HOST": "localhost", - "CACHE_REDIS_PORT": 6379, - "CACHE_REDIS_PASSWORD": None, - "CACHE_REDIS_DB": 0, - # "CACHE_REDIS_SENTINELS": ["localhost:26379"], - # "CACHE_REDIS_SENTINEL_MASTER": "mymaster", - # "CACHE_REDIS_CLUSTER": "localhost:6379,localhost:6380,localhost:6381", - } - - # 文件存储配置 - FILE_STORAGE = { - "DEFAULT_STORAGE_TYPE": "local", - "MAX_FILE_SIZE": 100 * 1024 * 1024 * 1024, # 100GB - "TUS_CHUNK_SIZE": 5 * 1024 * 1024, # 5MB - # 本地存储配置 - "LOCAL": { - "base_path": os.path.join(BASE_DIR, "runtime", "uploads"), - }, - # 阿里云OSS配置 - "ALIYUN_OSS": { - "access_key_id": os.getenv("ALIYUN_OSS_ACCESS_KEY_ID"), - "access_key_secret": os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET"), - "endpoint": os.getenv( - "ALIYUN_OSS_ENDPOINT" - ), # 例如:oss-cn-hangzhou.aliyuncs.com - "bucket": os.getenv("ALIYUN_OSS_BUCKET"), - }, - # 腾讯云COS配置 - "TENCENT_COS": { - "secret_id": os.getenv("TENCENT_COS_SECRET_ID"), - "secret_key": os.getenv("TENCENT_COS_SECRET_KEY"), - "region": os.getenv("TENCENT_COS_REGION"), # 例如:ap-guangzhou - "bucket": os.getenv("TENCENT_COS_BUCKET"), - }, - # 七牛云Kodo配置 - "QINIU_KODO": { - "access_key": os.getenv("QINIU_KODO_ACCESS_KEY"), - "secret_key": os.getenv("QINIU_KODO_SECRET_KEY"), - "bucket": os.getenv("QINIU_KODO_BUCKET"), - "domain": os.getenv("QINIU_KODO_DOMAIN"), # CDN域名 - }, - # 华为云OBS配置 - "HUAWEI_OBS": { - "access_key_id": os.getenv("HUAWEI_OBS_ACCESS_KEY_ID"), - "secret_access_key": os.getenv("HUAWEI_OBS_SECRET_ACCESS_KEY"), - "server": os.getenv( - "HUAWEI_OBS_SERVER" - ), # 例如:obs.cn-north-4.myhuaweicloud.com - "bucket": os.getenv("HUAWEI_OBS_BUCKET"), - }, - } - - # 是否启用业务项目自带 SPA 静态文件承载。 - # 框架不内置前端产物;启用后从业务项目 FRONTEND_PATH 读取文件。 - FRONTEND_ENABLED = _get_bool_env("FRONTEND_ENABLED", False) - FRONTEND_PATH = os.getenv("FRONTEND_PATH", "") - - # 服务调用配置。业务项目按需添加具体服务。 - SERVICES: dict[str, dict[str, Any]] = {} - - # 轻量任务配置。多进程部署时只应在一个专用实例启用。 - TASKS_ENABLED = False - + database_url: str = field( + default_factory=lambda: os.getenv( + "DATABASE_URL", default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_dev")) + ) + ) + sqlalchemy_echo: bool = False + sqlalchemy_pool_pre_ping: bool = True -class DevConfig(BaseConfig): - """开发环境配置""" + cors_origins: list[str] = field(default_factory=lambda: ["*"]) - DEBUG = True - TESTING = False + health_enabled: bool = True + ready_check_db: bool = False - # 开发环境数据库 - SQLALCHEMY_DATABASE_URI = os.getenv( - "DATABASE_URL", f"sqlite:///{BASE_DIR}/runtime/iti-flask_dev.db" + response_envelope_http_status: int = 200 + response_envelope_enabled: bool = True + raw_response_paths: list[str] = field( + default_factory=lambda: ["/health", "/ready", "/docs", "/openapi.json", "/redoc"] + ) + output_camel_case: bool = True + + ratelimit_enabled: bool = True + ratelimit_default: str = "1000 per hour" + + cache_enabled: bool = True + cache_default_timeout: int = 300 + + file_storage: dict[str, Any] = field( + default_factory=lambda: { + "DEFAULT_STORAGE_TYPE": "local", + "MAX_FILE_SIZE": 100 * 1024 * 1024 * 1024, + "TUS_CHUNK_SIZE": 5 * 1024 * 1024, + "LOCAL": { + "base_path": str(BASE_DIR / "runtime" / "uploads"), + }, + } ) - SQLALCHEMY_ECHO = True # 开发环境打印 SQL - SQLALCHEMY_SHOW_ERROR_DETAILS = True - - # JSON 配置(开发环境:格式化输出,方便调试) - JSON_INDENT = 2 # 缩进 2 个空格 - JSON_SEPARATORS = (", ", ": ") # 使用空格分隔 - - # JWT 开发环境配置 - JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=24) # 开发环境延长有效期 - # JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5) # 测试过期 - # 限流配置(开发环境较宽松) - RATELIMIT_ENABLED = True - RATELIMIT_DEFAULT = "1000 per hour" + services: dict[str, dict[str, Any]] = field(default_factory=dict) + service_tokens: dict[str, str] = field(default_factory=dict) + tasks_enabled: bool = False + log_level: str = "INFO" + log_dir: str = field(default_factory=lambda: str(BASE_DIR / "runtime" / "logs")) + log_file_enabled: bool = False + log_max_bytes: int = 50 * 1024 * 1024 + log_backup_count: int = 10 + log_json: bool = False -class TestConfig(BaseConfig): - """测试环境配置""" + audit_enabled: bool = False + audit_service_name: str = "audit" + audit_queue_size: int = 1000 + audit_batch_size: int = 20 + audit_flush_interval_seconds: float = 1.0 - DEBUG = False - TESTING = True - # 测试数据库(使用内存数据库) - SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" - SQLALCHEMY_ECHO = False - SQLALCHEMY_SHOW_ERROR_DETAILS = False +class DevConfig(BaseConfig): + def __init__(self) -> None: + super().__init__( + app_env="dev", + debug=True, + database_url=os.getenv( + "DATABASE_URL", + default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_dev")), + ), + sqlalchemy_echo=env_bool("SQLALCHEMY_ECHO", False), + jwt_access_token_expires_seconds=24 * 3600, + ratelimit_default="1000 per hour", + log_file_enabled=env_bool("LOG_FILE_ENABLED", False), + ) - # JSON 配置(测试环境:与生产一致) - JSON_INDENT = None # 不缩进 - JSON_SEPARATORS = (",", ":") # 紧凑分隔符 - # 限流配置 - RATELIMIT_ENABLED = False # 测试环境禁用限流 +class TestConfig(BaseConfig): + def __init__(self) -> None: + super().__init__( + app_env="test", + testing=True, + database_url=os.getenv( + "DATABASE_URL", + default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_test")), + ), + ratelimit_enabled=False, + log_file_enabled=False, + audit_enabled=False, + ) class ProdConfig(BaseConfig): - """生产环境配置""" - - DEBUG = False - TESTING = False - - # 生产环境必须使用环境变量 - SECRET_KEY = os.getenv("SECRET_KEY", "") - JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") - - # 生产环境数据库 - SQLALCHEMY_DATABASE_URI = os.getenv( - "DATABASE_URL", f"sqlite:///{BASE_DIR}/runtime/iti-flask_prod.db" - ) - - # 处理 PostgreSQL URL 格式 - if SQLALCHEMY_DATABASE_URI and SQLALCHEMY_DATABASE_URI.startswith("postgres://"): - SQLALCHEMY_DATABASE_URI = SQLALCHEMY_DATABASE_URI.replace( - "postgres://", "postgresql://", 1 + def __init__(self) -> None: + super().__init__( + app_env="prod", + debug=False, + database_url=os.getenv( + "DATABASE_URL", + default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_prod")), + ), + secret_key=os.getenv("SECRET_KEY", ""), + jwt_secret_key=os.getenv("JWT_SECRET_KEY", ""), + ratelimit_default="100 per hour", + log_file_enabled=env_bool("LOG_FILE_ENABLED", True), ) - SQLALCHEMY_SHOW_ERROR_DETAILS = False - # 限流配置(生产环境严格) - RATELIMIT_STORAGE_URL = os.getenv("REDIS_URL", "memory://") - RATELIMIT_DEFAULT = "100 per hour" - - # JSON 配置(生产环境:紧凑输出,减小体积) - JSON_INDENT = None # 不缩进 - JSON_SEPARATORS = (",", ":") # 紧凑分隔符 - - # 性能优化 - SQLALCHEMY_POOL_SIZE = 10 - SQLALCHEMY_POOL_TIMEOUT = 30 - SQLALCHEMY_POOL_RECYCLE = 3600 - - -# 配置字典 config = { "dev": DevConfig, "test": TestConfig, @@ -300,18 +169,7 @@ config = { } -def get_config(env_name=None): - """ - 根据环境名称获取配置类 - - Args: - env_name: 环境名称 ('dev', 'test', 'prod') - 如果为 None 则从环境变量 FLASK_ENV 读取 - - Returns: - 配置类 - """ - if env_name is None: - env_name = os.getenv("FLASK_ENV", "dev") - - return config.get(env_name, config["default"]) +def get_config(env_name: str | None = None) -> BaseConfig: + env_name = env_name or os.getenv("APP_ENV", os.getenv("ITI_ENV", "dev")) + config_cls = config.get(env_name, config["default"]) + return config_cls() diff --git a/iti/db/__init__.py b/iti/db/__init__.py new file mode 100644 index 0000000..5563b3e --- /dev/null +++ b/iti/db/__init__.py @@ -0,0 +1,13 @@ +from .base import AuditMixin, Base, IdMixin, TimestampMixin +from .session import configure_db, get_db, reset_db, session_scope + +__all__ = [ + "AuditMixin", + "Base", + "IdMixin", + "TimestampMixin", + "configure_db", + "get_db", + "reset_db", + "session_scope", +] diff --git a/iti/db/base.py b/iti/db/base.py new file mode 100644 index 0000000..148fc12 --- /dev/null +++ b/iti/db/base.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, MetaData, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +naming_convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(column_0_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=naming_convention) + + +class IdMixin: + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: uuid.uuid4().hex, + comment="标识", + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + nullable=False, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + onupdate=datetime.now, + nullable=False, + comment="更新时间", + ) + + +class AuditMixin: + created_by: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True, comment="创建人" + ) + updated_by: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True, comment="更新人" + ) diff --git a/iti/db/session.py b/iti/db/session.py new file mode 100644 index 0000000..7023ed2 --- /dev/null +++ b/iti/db/session.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager + +from fastapi import Request +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + + +engine: Engine | None = None +SessionLocal: sessionmaker[Session] | None = None + + +def configure_db( + database_url: str, + *, + echo: bool = False, + pool_pre_ping: bool = True, +) -> tuple[Engine, sessionmaker[Session]]: + global engine, SessionLocal + engine_kwargs = { + "echo": echo, + "pool_pre_ping": pool_pre_ping, + "future": True, + } + if database_url.startswith("sqlite"): + engine_kwargs["connect_args"] = {"check_same_thread": False} + if database_url in {"sqlite://", "sqlite:///:memory:", "sqlite+pysqlite:///:memory:"}: + engine_kwargs["poolclass"] = StaticPool + engine = create_engine( + database_url, + **engine_kwargs, + ) + SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + return engine, SessionLocal + + +def get_db(request: Request) -> Generator[Session, None, None]: + factory = getattr(request.app.state, "db_sessionmaker", None) + if factory is None: + raise RuntimeError("database is not configured") + db = factory() + try: + yield db + finally: + db.close() + + +@contextmanager +def session_scope() -> Generator[Session, None, None]: + if SessionLocal is None: + raise RuntimeError("database is not configured") + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + +def ping_database(db: Session) -> None: + db.execute(text("SELECT 1")) + + +def reset_db() -> None: + global engine, SessionLocal + if engine is not None: + engine.dispose() + engine = None + SessionLocal = None diff --git a/iti/events/__init__.py b/iti/events/__init__.py new file mode 100644 index 0000000..81484e7 --- /dev/null +++ b/iti/events/__init__.py @@ -0,0 +1,3 @@ +from .bus import EventBus, eventbus + +__all__ = ["EventBus", "eventbus"] diff --git a/iti/events/bus.py b/iti/events/bus.py new file mode 100644 index 0000000..1b808c8 --- /dev/null +++ b/iti/events/bus.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor + +logger = logging.getLogger("iti.events") + + +class EventBus: + def __init__(self, *, max_workers: int = 10) -> None: + self._handlers: dict[str, list[Callable]] = defaultdict(list) + self._executor = ThreadPoolExecutor(max_workers=max_workers) + + def on(self, event_name: str): + def decorator(func: Callable) -> Callable: + self.register_handler(event_name, func) + return func + + return decorator + + def register_handler(self, event_name: str, handler: Callable) -> None: + self._handlers[event_name].append(handler) + + def emit(self, event_name: str, *args, async_mode: bool = False, **kwargs) -> None: + for handler in list(self._handlers.get(event_name, [])): + if async_mode: + self._executor.submit(self._run_handler, handler, *args, **kwargs) + else: + self._run_handler(handler, *args, **kwargs) + + def _run_handler(self, handler: Callable, *args, **kwargs) -> None: + try: + handler(*args, **kwargs) + except Exception: + logger.exception("event handler failed: %s", handler) + + +eventbus = EventBus() diff --git a/iti/exceptions.py b/iti/exceptions.py new file mode 100644 index 0000000..11074ce --- /dev/null +++ b/iti/exceptions.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + + +class ItiError(Exception): + status_code = 500 + message = "服务器错误" + + def __init__( + self, + message: str | None = None, + *, + code: int | None = None, + status_code: int | None = None, + data: Any = None, + ) -> None: + self.message = message or self.message + self.code = code or status_code or self.status_code + self.status_code = status_code or self.status_code + self.data = data + super().__init__(self.message) + + +class BizError(ItiError): + status_code = 400 + message = "业务错误" + + +class PermissionDenied(ItiError): + status_code = 403 + message = "权限不足" + + +class Unauthorized(ItiError): + status_code = 401 + message = "未认证" diff --git a/iti/health/__init__.py b/iti/health/__init__.py new file mode 100644 index 0000000..1b2eccc --- /dev/null +++ b/iti/health/__init__.py @@ -0,0 +1,3 @@ +from .routes import router + +__all__ = ["router"] diff --git a/iti/health/routes.py b/iti/health/routes.py new file mode 100644 index 0000000..d368ff1 --- /dev/null +++ b/iti/health/routes.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from iti.db import get_db +from iti.db.session import ping_database + +router = APIRouter(tags=["health"]) + + +@router.get("/health", include_in_schema=False) +def health() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/ready", include_in_schema=False) +def ready(request: Request, db: Session = Depends(get_db)): + config = request.app.state.config + if config.ready_check_db: + try: + ping_database(db) + except Exception: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={"status": "error"}, + ) + return {"status": "ok"} diff --git a/iti/limiter/__init__.py b/iti/limiter/__init__.py new file mode 100644 index 0000000..5a4e29a --- /dev/null +++ b/iti/limiter/__init__.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import time +from collections import defaultdict, deque +from collections.abc import Callable + +from fastapi import Depends, Request + +from iti.exceptions import BizError + + +class SimpleLimiter: + def __init__(self, *, enabled: bool = True) -> None: + self.enabled = enabled + self._hits: dict[str, deque[float]] = defaultdict(deque) + + def limit(self, rule: str) -> Callable: + count, seconds = parse_rule(rule) + + def dependency(request: Request) -> None: + if not self.enabled: + return + client = request.client.host if request.client else "unknown" + key = f"{client}:{request.url.path}:{rule}" + now = time.time() + hits = self._hits[key] + while hits and hits[0] <= now - seconds: + hits.popleft() + if len(hits) >= count: + raise BizError("请求过于频繁,请稍后再试", code=429, status_code=429) + hits.append(now) + + return dependency + + +def parse_rule(rule: str) -> tuple[int, int]: + parts = rule.strip().split() + if len(parts) < 3 or parts[1] != "per": + raise ValueError(f"invalid rate limit rule: {rule}") + count = int(parts[0]) + unit = parts[2].lower() + if unit.startswith("second"): + seconds = 1 + elif unit.startswith("minute"): + seconds = 60 + elif unit.startswith("hour"): + seconds = 3600 + else: + raise ValueError(f"invalid rate limit unit: {unit}") + return count, seconds + + +def limit(rule: str) -> Callable: + def dependency(request: Request) -> None: + limiter = getattr(request.app.state, "limiter", SimpleLimiter()) + return limiter.limit(rule)(request) + + return Depends(dependency) diff --git a/iti/logging_config.py b/iti/logging_config.py new file mode 100644 index 0000000..61cac3e --- /dev/null +++ b/iti/logging_config.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Any + +from iti.config import BaseConfig + + +class SafeFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + for key in ("trace_id", "request_id", "actor_type", "actor_id", "response_code"): + if not hasattr(record, key): + setattr(record, key, "-") + return super().format(record) + + +def configure_logging(config: BaseConfig) -> None: + level = getattr(logging, config.log_level.upper(), logging.INFO) + formatter = SafeFormatter( + "%(asctime)s %(levelname)s %(name)s " + "trace=%(trace_id)s actor=%(actor_type)s:%(actor_id)s code=%(response_code)s - %(message)s" + ) + + root_logger = logging.getLogger("iti") + root_logger.setLevel(level) + root_logger.handlers.clear() + root_logger.propagate = False + + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + error_logger = logging.getLogger("iti.error") + error_logger.setLevel(logging.ERROR) + error_logger.handlers.clear() + error_logger.propagate = False + + if config.log_file_enabled: + log_dir = Path(config.log_dir) + log_dir.mkdir(parents=True, exist_ok=True) + + app_handler = RotatingFileHandler( + log_dir / "app.log", + encoding="utf-8", + maxBytes=config.log_max_bytes, + backupCount=config.log_backup_count, + ) + app_handler.setLevel(level) + app_handler.setFormatter(formatter) + root_logger.addHandler(app_handler) + + error_handler = RotatingFileHandler( + log_dir / "error.log", + encoding="utf-8", + maxBytes=config.log_max_bytes, + backupCount=config.log_backup_count, + ) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) + root_logger.addHandler(error_handler) + error_logger.addHandler(error_handler) + + +def log_extra(request: Any | None = None) -> dict[str, Any]: + if request is None: + return { + "trace_id": "-", + "request_id": "-", + "actor_type": "-", + "actor_id": "-", + "response_code": "-", + } + actor = getattr(request.state, "actor", None) + principal = getattr(request.state, "principal", None) + return { + "trace_id": getattr(request.state, "trace_id", "-"), + "request_id": getattr(request.state, "request_id", "-"), + "actor_type": getattr(actor, "type", None) or ("user" if principal else "-"), + "actor_id": getattr(actor, "id", None) or getattr(principal, "id", "-"), + "response_code": getattr(request.state, "response_code", "-"), + } diff --git a/iti/modules/base.py b/iti/modules/base.py index 45cdf18..6b3f763 100644 --- a/iti/modules/base.py +++ b/iti/modules/base.py @@ -1,8 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any -from typing import Protocol +from typing import Any, Protocol @dataclass(frozen=True) @@ -53,7 +52,7 @@ class ItiModule(Protocol): name: str def init_app(self, app) -> None: - """Initialize the module against the Flask app.""" + """Initialize the module against the FastAPI app.""" def register_routes(self, app) -> None: """Register module routes.""" @@ -64,5 +63,5 @@ class ItiModule(Protocol): def register_menu_seed(self, app) -> None: """Register menu seed metadata.""" - def register_commands(self, app) -> None: - """Register CLI commands.""" + def register_tasks(self, app) -> None: + """Register module tasks.""" diff --git a/iti/modules/registry.py b/iti/modules/registry.py index 0f5dd64..2de0ea7 100644 --- a/iti/modules/registry.py +++ b/iti/modules/registry.py @@ -71,10 +71,10 @@ class ModuleRegistry: def get_module_registry(app) -> ModuleRegistry: - registry = app.extensions.get("iti_modules") + registry = getattr(app.state, "iti_modules", None) if registry is None: registry = ModuleRegistry() - app.extensions["iti_modules"] = registry + app.state.iti_modules = registry return registry @@ -82,5 +82,5 @@ def init_modules(app, modules: Iterable[Any] | None = None) -> ModuleRegistry: registry = get_module_registry(app) registry.extend(modules) registry.run_phase("init_app", app) - registry.run_phase("register_commands", app) + registry.run_phase("register_tasks", app) return registry diff --git a/iti/responses/__init__.py b/iti/responses/__init__.py new file mode 100644 index 0000000..8d7c906 --- /dev/null +++ b/iti/responses/__init__.py @@ -0,0 +1,4 @@ +from .auto import raw_response +from .envelope import Envelope, fail, ok, page, pagination + +__all__ = ["Envelope", "fail", "ok", "page", "pagination", "raw_response"] diff --git a/iti/responses/auto.py b/iti/responses/auto.py new file mode 100644 index 0000000..23b94c8 --- /dev/null +++ b/iti/responses/auto.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import fnmatch +from collections.abc import Iterable +from typing import Any, Callable + +from fastapi import Request + + +RAW_RESPONSE_ATTR = "__iti_raw_response__" +ENVELOPE_FIELDS = {"data", "code", "message"} + + +def raw_response(func: Callable) -> Callable: + setattr(func, RAW_RESPONSE_ATTR, True) + return func + + +def is_envelope_payload(value: Any) -> bool: + return isinstance(value, dict) and ENVELOPE_FIELDS.issubset(value.keys()) + + +def is_raw_response_request(request: Request, raw_paths: Iterable[str]) -> bool: + endpoint = request.scope.get("endpoint") + if endpoint is not None and getattr(endpoint, RAW_RESPONSE_ATTR, False): + return True + + path = request.url.path + for pattern in raw_paths: + if pattern == path or fnmatch.fnmatch(path, pattern): + return True + return False diff --git a/iti/responses/envelope.py b/iti/responses/envelope.py new file mode 100644 index 0000000..0231a14 --- /dev/null +++ b/iti/responses/envelope.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import math +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict + +from iti.schemas import to_camel + +T = TypeVar("T") + + +class Envelope(BaseModel, Generic[T]): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + data: T | None = None + code: int = 200 + message: str = "成功" + + +def ok(data: Any = None, message: str = "成功", code: int = 200) -> dict[str, Any]: + return {"data": data, "code": code, "message": message} + + +def fail( + message: str = "操作失败", code: int = 500, data: Any = None +) -> dict[str, Any]: + return {"data": data, "code": code, "message": message} + + +def pagination( + *, + page: int = 1, + size: int = 10, + total: int = 0, + pages: int | None = None, +) -> dict[str, Any]: + pages = pages if pages is not None else math.ceil(total / size) if size > 0 else 0 + return { + "page": page, + "size": size, + "pages": pages, + "total": total, + } + + +def page( + items: list[Any], + page_info: dict[str, Any] | None = None, + message: str = "成功", + code: int = 200, +) -> dict[str, Any]: + return ok({"items": items, "page": page_info or pagination()}, message, code) diff --git a/iti/schemas.py b/iti/schemas.py new file mode 100644 index 0000000..cfcd8bc --- /dev/null +++ b/iti/schemas.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_serializer + + +def to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(item[:1].upper() + item[1:] for item in tail) + + +class ItiModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + from_attributes=True, + use_enum_values=True, + ) + + @field_serializer("*", when_used="json") + def serialize_datetime(self, value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + return value diff --git a/iti/service_client/__init__.py b/iti/service_client/__init__.py index bc0db56..3633ade 100644 --- a/iti/service_client/__init__.py +++ b/iti/service_client/__init__.py @@ -2,15 +2,17 @@ from .client import ServiceClient from .config import RetryConfig, ServiceConfig, TimeoutConfig from .errors import ( ServiceClientError, + ServiceBusinessError, ServiceConfigError, ServiceHTTPError, ServiceUnavailableError, ) -from .registry import init_service_clients, service_client +from .registry import init_service_clients, register_service_client, service_client __all__ = [ "RetryConfig", "ServiceClient", + "ServiceBusinessError", "ServiceClientError", "ServiceConfig", "ServiceConfigError", @@ -18,5 +20,6 @@ __all__ = [ "ServiceUnavailableError", "TimeoutConfig", "init_service_clients", + "register_service_client", "service_client", ] diff --git a/iti/service_client/client.py b/iti/service_client/client.py index 42230c6..e8b0742 100644 --- a/iti/service_client/client.py +++ b/iti/service_client/client.py @@ -5,10 +5,12 @@ import uuid from typing import Any import httpx -from flask import current_app, g, has_app_context, has_request_context, request from .config import ServiceConfig -from .errors import ServiceHTTPError, ServiceUnavailableError +from .errors import ServiceBusinessError, ServiceHTTPError, ServiceUnavailableError + + +ENVELOPE_FIELDS = {"data", "code", "message"} class ServiceClient: @@ -53,11 +55,6 @@ class ServiceClient: endpoint: str, *, path: dict[str, Any] | None = None, - path_params: dict[str, Any] | None = None, - path_values: dict[str, Any] | None = None, - path_: dict[str, Any] | None = None, - path_args: dict[str, Any] | None = None, - path_map: dict[str, Any] | None = None, params: dict[str, Any] | None = None, json: Any = None, headers: dict[str, str] | None = None, @@ -65,15 +62,12 @@ class ServiceClient: expect_json: bool = True, ) -> Any: method = method.upper() - values = path or path_params or path_values or path_ or path_args or path_map or {} - url = endpoint.format(**values) + url = endpoint.format(**(path or {})) self._raise_if_open() - attempts = self._attempts_for(method, retry) last_error: Exception | None = None for attempt in range(1, attempts + 1): - start = time.monotonic() try: response = self._client.request( method, @@ -82,26 +76,33 @@ class ServiceClient: json=json, headers=self._headers(headers), ) - elapsed_ms = int((time.monotonic() - start) * 1000) - self._log_call(method, url, response.status_code, elapsed_ms, attempt) - if response.status_code >= 400: - if self._should_retry_status(method, response.status_code, attempt, attempts): - time.sleep(self.config.retry.backoff * attempt) - continue - self._record_failure() - raise ServiceHTTPError( - self.config.name, response.status_code, response.text - ) - self._record_success() if not expect_json: + if response.status_code >= 400: + if self._should_retry_status(method, response.status_code, attempt, attempts): + time.sleep(self.config.retry.backoff * attempt) + continue + self._record_failure() + raise ServiceHTTPError( + self.config.name, response.status_code, response.text + ) + self._record_success() return response + if response.status_code >= 400 and self._should_retry_status( + method, response.status_code, attempt, attempts + ): + time.sleep(self.config.retry.backoff * attempt) + continue if not response.content: + if response.status_code >= 400: + self._record_failure() + raise ServiceHTTPError( + self.config.name, response.status_code, response.text + ) + self._record_success() return None - return response.json() + return self._parse_response(response) except (httpx.TimeoutException, httpx.TransportError) as exc: last_error = exc - elapsed_ms = int((time.monotonic() - start) * 1000) - self._log_call(method, url, "transport_error", elapsed_ms, attempt) if attempt < attempts: time.sleep(self.config.retry.backoff * attempt) continue @@ -123,23 +124,9 @@ class ServiceClient: result.setdefault("Content-Type", "application/json") if self.config.token: result.setdefault("Authorization", f"Bearer {self.config.token}") - trace_id = self._trace_id() - result.setdefault("X-Trace-Id", trace_id) + result.setdefault("X-Trace-Id", uuid.uuid4().hex) return result - def _trace_id(self) -> str: - if has_request_context(): - header_trace = request.headers.get("X-Trace-Id") - if header_trace: - return header_trace - if has_app_context(): - trace_id = getattr(g, "trace_id", None) - if trace_id: - return trace_id - g.trace_id = uuid.uuid4().hex - return g.trace_id - return uuid.uuid4().hex - def _attempts_for(self, method: str, retry: bool | None) -> int: if retry is False: return 1 @@ -182,22 +169,37 @@ class ServiceClient: if self._fail_count >= breaker.fail_max: self._opened_at = time.monotonic() - def _log_call( - self, - method: str, - url: str, - status: int | str, - elapsed_ms: int, - attempt: int, - ) -> None: - if not has_app_context(): - return - current_app.logger.info( - "service_call service=%s method=%s path=%s status=%s elapsed_ms=%s attempt=%s", + def _parse_response(self, response: httpx.Response) -> Any: + try: + payload = response.json() + except ValueError as exc: + if response.status_code >= 400: + self._record_failure() + raise ServiceHTTPError( + self.config.name, response.status_code, response.text + ) from exc + self._record_success() + raise + if not self._is_envelope(payload): + if response.status_code >= 400: + self._record_failure() + raise ServiceHTTPError( + self.config.name, response.status_code, response.text + ) + self._record_success() + return payload + code = int(payload["code"]) + if response.status_code < 400 and code == 200: + self._record_success() + return payload.get("data") + self._record_failure() + raise ServiceBusinessError( self.config.name, - method, - url, - status, - elapsed_ms, - attempt, + code, + str(payload.get("message") or ""), + payload.get("data"), + status_code=response.status_code, ) + + def _is_envelope(self, payload: Any) -> bool: + return isinstance(payload, dict) and ENVELOPE_FIELDS.issubset(payload.keys()) diff --git a/iti/service_client/errors.py b/iti/service_client/errors.py index b956148..d1dd974 100644 --- a/iti/service_client/errors.py +++ b/iti/service_client/errors.py @@ -21,3 +21,22 @@ class ServiceHTTPError(ServiceClientError): self.status_code = status_code self.body = body super().__init__(f"service {service} returned HTTP {status_code}") + + +class ServiceBusinessError(ServiceClientError): + """Raised for envelope responses with a non-success business code.""" + + def __init__( + self, + service: str, + code: int, + message: str, + data: object | None = None, + status_code: int | None = None, + ) -> None: + self.service = service + self.code = code + self.message = message + self.data = data + self.status_code = status_code + super().__init__(f"service {service} returned business code {code}: {message}") diff --git a/iti/service_client/registry.py b/iti/service_client/registry.py index 6df42b7..aaef050 100644 --- a/iti/service_client/registry.py +++ b/iti/service_client/registry.py @@ -3,21 +3,19 @@ from __future__ import annotations from typing import Any import httpx -from flask import current_app, has_app_context from .client import ServiceClient from .config import ServiceConfig from .errors import ServiceConfigError -def init_service_clients(app) -> None: - configs = app.config.get("SERVICES", {}) +def init_service_clients(app, configs: dict[str, dict[str, Any]] | None = None) -> None: clients: dict[str, ServiceClient] = {} - for name, value in configs.items(): + for name, value in (configs or {}).items(): if "base_url" not in value: raise ServiceConfigError(f"service {name} missing base_url") clients[name] = ServiceClient(ServiceConfig.from_mapping(name, value)) - app.extensions["iti_service_clients"] = clients + app.state.iti_service_clients = clients def register_service_client( @@ -28,15 +26,14 @@ def register_service_client( transport: httpx.BaseTransport | None = None, ) -> ServiceClient: client = ServiceClient(ServiceConfig.from_mapping(name, config), transport=transport) - clients = app.extensions.setdefault("iti_service_clients", {}) + clients = getattr(app.state, "iti_service_clients", {}) clients[name] = client + app.state.iti_service_clients = clients return client -def service_client(name: str) -> ServiceClient: - if not has_app_context(): - raise ServiceConfigError("service_client() requires an app context") - clients = current_app.extensions.get("iti_service_clients", {}) +def service_client(app, name: str) -> ServiceClient: + clients = getattr(app.state, "iti_service_clients", {}) client = clients.get(name) if client is None: raise ServiceConfigError(f"service client not configured: {name}") diff --git a/iti/applications/common/storage/__init__.py b/iti/storage/__init__.py similarity index 100% rename from iti/applications/common/storage/__init__.py rename to iti/storage/__init__.py diff --git a/iti/applications/common/storage/aliyun_oss.py b/iti/storage/aliyun_oss.py similarity index 100% rename from iti/applications/common/storage/aliyun_oss.py rename to iti/storage/aliyun_oss.py diff --git a/iti/applications/common/storage/huawei_obs.py b/iti/storage/huawei_obs.py similarity index 100% rename from iti/applications/common/storage/huawei_obs.py rename to iti/storage/huawei_obs.py diff --git a/iti/applications/common/storage/interface.py b/iti/storage/interface.py similarity index 100% rename from iti/applications/common/storage/interface.py rename to iti/storage/interface.py diff --git a/iti/applications/common/storage/local.py b/iti/storage/local.py similarity index 100% rename from iti/applications/common/storage/local.py rename to iti/storage/local.py diff --git a/iti/storage/manager.py b/iti/storage/manager.py new file mode 100644 index 0000000..333f107 --- /dev/null +++ b/iti/storage/manager.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional, Union + +from iti.common.enums import StorageTypeEnum + +from .interface import StorageInterface +from .local import LocalStorage + + +class StorageManager: + _instances: dict[str, StorageInterface] = {} + + @classmethod + def get_storage( + cls, + storage_type: Optional[Union[str, StorageTypeEnum]] = None, + *, + config: dict | None = None, + base_dir: str | os.PathLike | None = None, + ) -> StorageInterface: + config = config or {} + storage_type_str = cls._normalize_storage_type(storage_type, config) + if storage_type_str not in cls._instances: + cls._instances[storage_type_str] = cls._create_storage( + storage_type_str, + config, + base_dir=base_dir, + ) + return cls._instances[storage_type_str] + + @staticmethod + def _normalize_storage_type( + storage_type: Optional[Union[str, StorageTypeEnum]], + config: dict, + ) -> str: + if storage_type is None: + return config.get("DEFAULT_STORAGE_TYPE", StorageTypeEnum.LOCAL.value) + if isinstance(storage_type, StorageTypeEnum): + return storage_type.value + return storage_type + + @staticmethod + def _create_storage( + storage_type: str, + config: dict, + *, + base_dir: str | os.PathLike | None = None, + ) -> StorageInterface: + if storage_type == StorageTypeEnum.LOCAL.value: + local_config = dict(config.get("LOCAL", {})) + if not local_config.get("base_path"): + local_config["base_path"] = str(Path(base_dir or Path.cwd()) / "runtime" / "uploads") + return LocalStorage(local_config) + + if storage_type == StorageTypeEnum.ALIYUN_OSS.value: + from .aliyun_oss import AliyunOSSStorage + + return AliyunOSSStorage(config.get("ALIYUN_OSS", {})) + if storage_type == StorageTypeEnum.TENCENT_COS.value: + from .tencent_cos import TencentCOSStorage + + return TencentCOSStorage(config.get("TENCENT_COS", {})) + if storage_type == StorageTypeEnum.QINIU_KODO.value: + from .qiniu_kodo import QiniuKodoStorage + + return QiniuKodoStorage(config.get("QINIU_KODO", {})) + if storage_type == StorageTypeEnum.HUAWEI_OBS.value: + from .huawei_obs import HuaweiOBSStorage + + return HuaweiOBSStorage(config.get("HUAWEI_OBS", {})) + if storage_type == StorageTypeEnum.MINIO.value: + from .minio_storage import MinIOStorage + + return MinIOStorage(config.get("MINIO", {})) + if storage_type == StorageTypeEnum.AWS_S3.value: + raise NotImplementedError("AWS S3 适配器尚未实现") + raise ValueError(f"未支持的存储类型: {storage_type}") diff --git a/iti/applications/common/storage/minio_storage.py b/iti/storage/minio_storage.py similarity index 100% rename from iti/applications/common/storage/minio_storage.py rename to iti/storage/minio_storage.py diff --git a/iti/applications/common/storage/qiniu_kodo.py b/iti/storage/qiniu_kodo.py similarity index 100% rename from iti/applications/common/storage/qiniu_kodo.py rename to iti/storage/qiniu_kodo.py diff --git a/iti/applications/common/storage/tencent_cos.py b/iti/storage/tencent_cos.py similarity index 100% rename from iti/applications/common/storage/tencent_cos.py rename to iti/storage/tencent_cos.py diff --git a/iti/tasks/runner.py b/iti/tasks/runner.py index 863ad59..4ff995e 100644 --- a/iti/tasks/runner.py +++ b/iti/tasks/runner.py @@ -3,8 +3,6 @@ from __future__ import annotations import re import threading import time -import atexit - from .registry import TaskRegistry, task_registry @@ -78,12 +76,8 @@ def _parse_simple_cron(schedule: str) -> int | None: def init_task_runner(app, registry: TaskRegistry | None = None) -> TaskRunner: - runner = TaskRunner(registry or task_registry) - app.extensions["iti_task_registry"] = registry or task_registry - app.extensions["iti_task_runner"] = runner - if app.config.get("TASKS_ENABLED", False): - app.logger.info("starting single-process task runner") - runner.start() - atexit.register(runner.stop) - + registry = registry or task_registry + runner = TaskRunner(registry) + app.state.iti_task_registry = registry + app.state.iti_task_runner = runner return runner diff --git a/iti/templates/errors/400.html b/iti/templates/errors/400.html deleted file mode 100644 index f308d3e..0000000 --- a/iti/templates/errors/400.html +++ /dev/null @@ -1,307 +0,0 @@ - - - - - - 400 - 错误请求 - - - - - {% include 'errors/header.html' %} - -
- - -
- - -
-
400
-

错误请求

-

- 抱歉,服务器无法服务器无法理解您的请求。 - 可能的原因包括:请求参数错误、数据格式不正确正确或请求语法有误。 - 请检查您的请求内容并重试。 -

-
- - - {% include 'errors/footer.html' %} - - - - diff --git a/iti/templates/errors/403.html b/iti/templates/errors/403.html deleted file mode 100644 index d679969..0000000 --- a/iti/templates/errors/403.html +++ /dev/null @@ -1,313 +0,0 @@ - - - - - - 403 - 禁止访问 - - - - - {% include 'errors/header.html' %} - -
- - -
- - -
-
403
-

禁止访问

-

- 抱歉,您没有访问该资源的权限。 - 可能的原因包括:您未登录、登录状态已过期或您的账号没有足够的权限访问此内容。 - 请联系管理员获取更多帮助。 -

-
- - - {% include 'errors/footer.html' %} - - - - diff --git a/iti/templates/errors/404.html b/iti/templates/errors/404.html deleted file mode 100644 index 7ee3246..0000000 --- a/iti/templates/errors/404.html +++ /dev/null @@ -1,312 +0,0 @@ - - - - - - 404 - 页面未找到 - - - - - {% include 'errors/header.html' %} - -
- - -
- - -
-
404
-

页面未找到

-

- 抱歉,您访问的页面不存在或已被移除。 - 可能的原因包括:URL输入错误、页面已删除或移动到其他位置。 -

-
- - - {% include 'errors/footer.html' %} - - - - diff --git a/iti/templates/errors/500.html b/iti/templates/errors/500.html deleted file mode 100644 index 4bf5999..0000000 --- a/iti/templates/errors/500.html +++ /dev/null @@ -1,360 +0,0 @@ - - - - - - 500 - 服务器错误 - - - - - {% include 'errors/header.html' %} - -
- - -
- - -
-
500
-

服务器错误

-

- 抱歉,服务器遇到了意外错误。 - 这可能是由于服务器暂时过载、维护或内部故障导致的。 - 请稍后重试,或联系管理员报告此问题。 -

-
- -
-
- - - {% include 'errors/footer.html' %} - - - - diff --git a/iti/templates/errors/footer.html b/iti/templates/errors/footer.html deleted file mode 100644 index f5264fb..0000000 --- a/iti/templates/errors/footer.html +++ /dev/null @@ -1,89 +0,0 @@ - - - \ No newline at end of file diff --git a/iti/templates/errors/header.html b/iti/templates/errors/header.html deleted file mode 100644 index 59e684a..0000000 --- a/iti/templates/errors/header.html +++ /dev/null @@ -1,252 +0,0 @@ -
- -
- - -
-
- - - - diff --git a/migrations/alembic.ini b/migrations/alembic.ini index 3bc59ed..b3fd662 100644 --- a/migrations/alembic.ini +++ b/migrations/alembic.ini @@ -1,6 +1,7 @@ # A generic, single database configuration. [alembic] +script_location = migrations # template used to generate migration files file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s @@ -11,7 +12,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev) # Logging configuration [loggers] -keys = root,sqlalchemy,alembic,flask_migrate +keys = root,sqlalchemy,alembic [handlers] keys = console @@ -34,10 +35,6 @@ level = INFO handlers = qualname = alembic -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate [handler_console] class = StreamHandler diff --git a/migrations/env.py b/migrations/env.py index 4c97092..9c15c99 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,107 +1,50 @@ -import logging -from logging.config import fileConfig +from __future__ import annotations -from flask import current_app +import os +from logging.config import fileConfig from alembic import context +from sqlalchemy import engine_from_config, pool -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') - - -def get_engine(): - try: - # this works with Flask-SQLAlchemy<3 and Alchemical - return current_app.extensions['migrate'].db.get_engine() - except (TypeError, AttributeError): - # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine - - -def get_engine_url(): - try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') - except AttributeError: - return str(get_engine().url).replace('%', '%%') - +from iti.config import get_config +from iti.db import Base -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -config.set_main_option('sqlalchemy.url', get_engine_url()) -target_db = current_app.extensions['migrate'].db - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. +config = context.config -def get_metadata(): - if hasattr(target_db, 'metadatas'): - return target_db.metadatas[None] - return target_db.metadata +if config.config_file_name is not None: + fileConfig(config.config_file_name) +target_metadata = Base.metadata -def run_migrations_offline(): - """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. +def get_url() -> str: + return os.getenv("DATABASE_URL") or get_config().database_url - Calls to context.execute() here emit the given string to the - script output. - """ - url = config.get_main_option("sqlalchemy.url") +def run_migrations_offline() -> None: context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() -def run_migrations_online(): - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - - # this callback is used to prevent an auto-migration from being generated - # when there are no changes to the schema - # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html - def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): - script = directives[0] - if script.upgrade_ops.is_empty(): - directives[:] = [] - logger.info('No changes in schema detected.') - - conf_args = current_app.extensions['migrate'].configure_args - if conf_args.get("process_revision_directives") is None: - conf_args["process_revision_directives"] = process_revision_directives - - connectable = get_engine() +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/migrations/versions/20260508_0433_7de264f96a03_framework_initial_schema.py b/migrations/versions/20260508_0433_7de264f96a03_framework_initial_schema.py deleted file mode 100644 index 9592fa0..0000000 --- a/migrations/versions/20260508_0433_7de264f96a03_framework_initial_schema.py +++ /dev/null @@ -1,359 +0,0 @@ -"""framework initial schema - -Revision ID: 7de264f96a03 -Revises: -Create Date: 2026-05-08 04:33:59.055459 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '7de264f96a03' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('sys_config', - sa.Column('type', sa.String(length=64), nullable=False, comment='配置类型'), - sa.Column('name', sa.String(length=255), nullable=False, comment='配置名称'), - sa.Column('code', sa.String(length=128), nullable=False, comment='配置编码'), - sa.Column('value', sa.Text(), nullable=True, comment='配置值'), - sa.Column('desc', sa.Text(), nullable=True, comment='配置描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_config')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_config', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_config_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_config_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_dept', - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'), - sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父部门ID'), - sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - op.create_table('sys_dict_data', - sa.Column('type_code', sa.String(length=36), nullable=False, comment='类型编码'), - sa.Column('label', sa.String(length=255), nullable=False, comment='数据标签'), - sa.Column('code', sa.String(length=128), nullable=False, comment='数据编码'), - sa.Column('value', sa.Text(), nullable=True, comment='数据值'), - sa.Column('desc', sa.Text(), nullable=True, comment='数据描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_data')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_dict_data', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_dict_data_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_dict_data_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_dict_type', - sa.Column('type_name', sa.String(length=255), nullable=False, comment='类型名称'), - sa.Column('type_code', sa.String(length=128), nullable=False, comment='类型编码'), - sa.Column('desc', sa.Text(), nullable=True, comment='类型描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_type')), - sa.UniqueConstraint('type_code', name=op.f('uq_sys_dict_type_type_code')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_dict_type', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_dict_type_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_dict_type_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_file', - sa.Column('filename', sa.String(length=255), nullable=False, comment='原始文件名'), - sa.Column('file_key', sa.String(length=512), nullable=False, comment='存储路径'), - sa.Column('file_hash', sa.String(length=64), nullable=True, comment='文件哈希'), - sa.Column('mime_type', sa.String(length=128), nullable=True, comment='MIME类型'), - sa.Column('file_size', sa.BigInteger(), nullable=False, comment='文件大小(字节)'), - sa.Column('extension', sa.String(length=32), nullable=True, comment='文件扩展名'), - sa.Column('storage_type', sa.Enum('local', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'huawei_obs', 'aws_s3', 'minio', name='storagetypeenum'), nullable=False, comment='存储类型'), - sa.Column('storage_info', sa.JSON(), nullable=True, comment='存储信息(bucket/region/endpoint/meta等)'), - sa.Column('directory_id', sa.String(length=36), nullable=True, comment='所属目录ID'), - sa.Column('metadata', sa.JSON(), nullable=True, comment='扩展元数据'), - sa.Column('is_deleted', sa.Boolean(), nullable=False, comment='是否已删除(回收站)'), - sa.Column('deleted_at', sa.DateTime(), nullable=True, comment='删除时间'), - sa.Column('deleted_by', sa.String(length=36), nullable=True, comment='删除人ID'), - sa.Column('share_code', sa.String(length=64), nullable=True, comment='分享码'), - sa.Column('share_password', sa.String(length=64), nullable=True, comment='分享密码'), - sa.Column('share_expire_at', sa.DateTime(), nullable=True, comment='分享过期时间'), - sa.Column('share_count', sa.Integer(), nullable=False, comment='分享访问次数'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_file_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_file_directory_id'), ['directory_id'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_file_file_hash'), ['file_hash'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_file_file_key'), ['file_key'], unique=True) - batch_op.create_index(batch_op.f('ix_sys_file_share_code'), ['share_code'], unique=True) - batch_op.create_index(batch_op.f('ix_sys_file_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_file_directory', - sa.Column('name', sa.String(length=255), nullable=False, comment='目录名称'), - sa.Column('path', sa.String(length=1024), nullable=False, comment='完整路径'), - sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父目录ID'), - sa.Column('level', sa.Integer(), nullable=True, comment='层级'), - sa.Column('sort', sa.Integer(), nullable=True, comment='排序'), - sa.Column('icon', sa.String(length=128), nullable=True, comment='目录图标'), - sa.Column('color', sa.String(length=32), nullable=True, comment='颜色标记'), - sa.Column('description', sa.Text(), nullable=True, comment='目录描述'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file_directory')) - ) - with op.batch_alter_table('sys_file_directory', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_file_directory_created_by'), ['created_by'], unique=False) - batch_op.create_index('ix_sys_file_directory_path', ['path'], unique=False, mysql_length=255) - batch_op.create_index(batch_op.f('ix_sys_file_directory_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_log', - sa.Column('name', sa.String(length=100), nullable=True, comment='操作名称'), - sa.Column('method', sa.String(length=10), nullable=True, comment='请求方法'), - sa.Column('user_id', sa.String(length=36), nullable=True, comment='用户ID'), - sa.Column('path', sa.String(length=255), nullable=True, comment='请求路径'), - sa.Column('ip', sa.String(length=255), nullable=True, comment='IP地址'), - sa.Column('user_agent', sa.Text(), nullable=True, comment='用户代理'), - sa.Column('headers', sa.Text(), nullable=True, comment='请求头'), - sa.Column('query_params', sa.Text(), nullable=True, comment='请求参数'), - sa.Column('body_params', sa.Text(), nullable=True, comment='请求体参数'), - sa.Column('execution_time', sa.Float(), nullable=True, comment='执行时间(毫秒)'), - sa.Column('response', sa.Text(), nullable=True, comment='响应结果'), - sa.Column('exception', sa.Text(), nullable=True, comment='异常信息'), - sa.Column('success', sa.Boolean(), nullable=True, comment='是否成功'), - sa.Column('desc', sa.Text(), nullable=True, comment='描述'), - sa.Column('type', sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), nullable=False, comment='日志类型'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_log')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_log_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_log_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_menu', - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('name', sa.String(length=255), nullable=False, comment='菜单名称'), - sa.Column('type', sa.Enum('catalog', 'menu', 'button', 'embedded', 'link', name='menutypeenum'), nullable=False, comment='菜单类型'), - sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'), - sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'), - sa.Column('redirect', sa.String(length=255), nullable=True, comment='菜单重定向'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('auth_code', sa.String(length=128), nullable=True, comment='权限编码'), - sa.Column('meta', sa.JSON(), nullable=True, comment='菜单元数据'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父菜单ID'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_menu')), - sa.UniqueConstraint('name', name=op.f('uq_sys_menu_name')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - op.create_table('sys_role', - sa.Column('name', sa.String(length=64), nullable=False, comment='名称'), - sa.Column('code', sa.String(length=64), nullable=False, comment='编码'), - sa.Column('desc', sa.Text(), nullable=True, comment='描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_role')), - sa.UniqueConstraint('code', name=op.f('uq_sys_role_code')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_role', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_role_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_role_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_role_menu', - sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'), - sa.Column('menu_id', sa.String(length=36), nullable=False, comment='菜单ID'), - sa.PrimaryKeyConstraint('role_id', 'menu_id', name=op.f('pk_sys_role_menu')) - ) - op.create_table('sys_user', - sa.Column('username', sa.String(length=64), nullable=False, comment='用户名'), - sa.Column('phone', sa.String(length=13), nullable=True, comment='手机号'), - sa.Column('email', sa.String(length=255), nullable=True, comment='邮箱'), - sa.Column('password', sa.String(length=255), nullable=False, comment='密码'), - sa.Column('realname', sa.String(length=32), nullable=True, comment='真实姓名'), - sa.Column('desc', sa.Text(), nullable=True, comment='描述'), - sa.Column('avatar', sa.String(length=255), nullable=True, comment='头像'), - sa.Column('gender', sa.Enum('male', 'female', 'secure', name='genderenum'), nullable=False, comment='性别'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_user_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_user_updated_by'), ['updated_by'], unique=False) - - op.create_table('sys_user_dept', - sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'), - sa.Column('dept_id', sa.String(length=36), nullable=False, comment='部门ID'), - sa.PrimaryKeyConstraint('user_id', 'dept_id', name=op.f('pk_sys_user_dept')) - ) - op.create_table('sys_user_role', - sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'), - sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'), - sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_sys_user_role')) - ) - op.create_table('sys_user_attribute', - sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'), - sa.Column('attr_group', sa.String(length=64), nullable=False, comment='属性分组(如: erp, custom)'), - sa.Column('attr_key', sa.String(length=128), nullable=False, comment='属性键'), - sa.Column('attr_value', sa.Text(), nullable=True, comment='属性值'), - sa.Column('attr_type', sa.String(length=32), nullable=False, comment='值类型(string/int/float/bool/json/encrypted)'), - sa.Column('description', sa.String(length=255), nullable=True, comment='属性描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.ForeignKeyConstraint(['user_id'], ['sys_user.id'], name=op.f('fk_sys_user_attribute_user_id_sys_user'), ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user_attribute')), - sa.UniqueConstraint('user_id', 'attr_group', 'attr_key', name='uk_user_group_key') - ) - with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op: - batch_op.create_index('idx_user_group_key', ['user_id', 'attr_group', 'attr_key'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_user_attribute_attr_group'), ['attr_group'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_user_attribute_created_by'), ['created_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_user_attribute_updated_by'), ['updated_by'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_user_attribute_user_id'), ['user_id'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_user_attribute_user_id')) - batch_op.drop_index(batch_op.f('ix_sys_user_attribute_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_user_attribute_created_by')) - batch_op.drop_index(batch_op.f('ix_sys_user_attribute_attr_group')) - batch_op.drop_index('idx_user_group_key') - - op.drop_table('sys_user_attribute') - op.drop_table('sys_user_role') - op.drop_table('sys_user_dept') - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_user_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_user_created_by')) - - op.drop_table('sys_user') - op.drop_table('sys_role_menu') - with op.batch_alter_table('sys_role', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_role_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_role_created_by')) - - op.drop_table('sys_role') - op.drop_table('sys_menu') - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_log_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_log_created_by')) - - op.drop_table('sys_log') - with op.batch_alter_table('sys_file_directory', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_file_directory_updated_by')) - batch_op.drop_index('ix_sys_file_directory_path', mysql_length=255) - batch_op.drop_index(batch_op.f('ix_sys_file_directory_created_by')) - - op.drop_table('sys_file_directory') - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_file_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_file_share_code')) - batch_op.drop_index(batch_op.f('ix_sys_file_file_key')) - batch_op.drop_index(batch_op.f('ix_sys_file_file_hash')) - batch_op.drop_index(batch_op.f('ix_sys_file_directory_id')) - batch_op.drop_index(batch_op.f('ix_sys_file_created_by')) - - op.drop_table('sys_file') - with op.batch_alter_table('sys_dict_type', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_dict_type_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_dict_type_created_by')) - - op.drop_table('sys_dict_type') - with op.batch_alter_table('sys_dict_data', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_dict_data_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_dict_data_created_by')) - - op.drop_table('sys_dict_data') - op.drop_table('sys_dept') - with op.batch_alter_table('sys_config', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_config_updated_by')) - batch_op.drop_index(batch_op.f('ix_sys_config_created_by')) - - op.drop_table('sys_config') - # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index f09c3ca..4b53c68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "setuptools.build_meta" [project] name = "iti-flask" dynamic = ["version"] -description = "iTi-Flask is a lightweight APIFlask backend foundation for business applications." +description = "iTi-Flask is a lightweight FastAPI backend foundation for business applications." readme = "README.md" requires-python = ">=3.11" license = "MIT" -keywords = ["apiflask", "flask", "backend", "framework"] +keywords = ["fastapi", "backend", "framework"] authors = [{ name = "NoahLan", email = "6995syu@163.com" }] classifiers = [ "Development Status :: 4 - Beta", @@ -21,29 +21,23 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "flask>=3.1.0", - "apiflask>=2.4.0", - "flask-cors>=6.0.0", - "flask-sqlalchemy>=3.1.0", + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", "SQLAlchemy>=2.0.0", - "flask-migrate>=4.1.0", - "flask-marshmallow>=1.3.0", - "Flask-JWT-Extended>=4.7.0", - "Flask-Limiter>=4.0.0", - "flask-moment>=1.0.0", - "Flask-Caching>=2.3.0", - "validators>=0.35.0", - "marshmallow>=4.0.0", - "marshmallow-sqlalchemy>=1.4.0", - "marshmallow-dataclass>=8.7.0", - "Werkzeug>=3.1.0", + "alembic>=1.13.0", + "pydantic>=2.8.0", + "pydantic-settings>=2.4.0", + "python-jose[cryptography]>=3.3.0", + "passlib[argon2]>=1.7.4", + "python-multipart>=0.0.9", + "click>=8.1.0", "python-dotenv>=1.0.0", "httpx>=0.27.0", "tenacity>=8.2.0", + "PyMySQL>=1.1.0", ] [project.optional-dependencies] -mysql = ["PyMySQL>=1.1.0"] storage-aliyun = ["oss2>=2.19.1"] storage-tencent = ["cos-python-sdk-v5>=1.9.38"] storage-qiniu = ["qiniu>=7.17.0"] @@ -53,7 +47,7 @@ storage-minio = ["minio>=7.2.18"] erp = ["pyodbc>=5.3.0"] excel = ["pandas>=2.3.3", "openpyxl>=3.1.5"] image = ["Pillow>=12.0.0"] -prod = ["waitress>=2.1.0"] +prod = ["gunicorn>=22.0.0"] dev = [ "mypy>=1.0.0", "pytest>=7.0.0", @@ -73,16 +67,20 @@ version = { attr = "iti.__about__.__version__" } [tool.setuptools.packages.find] include = ["iti*"] +exclude = ["iti.runtime*"] [tool.setuptools.package-data] iti = [ - "templates/**/*.html", ] +[project.scripts] +iti = "iti.cli:iti_cli" + [tool.setuptools.exclude-package-data] iti = [ ".env*", "runtime/*", + "iti/runtime/*", "**/__pycache__/*", ] @@ -106,7 +104,11 @@ warn_unused_configs = true files = [ "iti/config.py", "iti/cli.py", + "iti/app.py", + "iti/auth", + "iti/db", "iti/modules", + "iti/responses", "iti/service_client", "iti/tasks", ] diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 06a0ec0..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> -# -# SPDX-License-Identifier: MIT diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..17fca7b --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter +from fastapi.testclient import TestClient +from starlette.responses import PlainTextResponse + +from iti import create_app +from iti.config import BaseConfig +from iti.exceptions import BizError +from iti.limiter import limit +from iti.responses import ok, raw_response + + +class RoutesModule: + name = "routes" + + def register_routes(self, app): + router = APIRouter() + + @router.get("/demo") + def demo(): + return ok({"value": 1}) + + @router.get("/auto") + def auto(): + return {"value": 2} + + @router.get("/raw") + @raw_response + def raw(): + return {"value": 3} + + @router.get("/text") + def text(): + return PlainTextResponse("ok") + + @router.get("/boom") + def boom(): + raise BizError("业务失败", code=400) + + @router.get("/limited", dependencies=[limit("1 per minute")]) + def limited(): + return ok() + + app.include_router(router) + + +def make_app(**config_values): + config = BaseConfig( + database_url="sqlite+pysqlite:///:memory:", + testing=True, + **config_values, + ) + return create_app(modules=[RoutesModule()], config_mapping=config) + + +def test_framework_health_routes(): + client = TestClient(make_app()) + + assert client.get("/health").json() == {"status": "ok"} + assert client.get("/ready").json() == {"status": "ok"} + + +def test_envelope_and_error_handlers(): + client = TestClient(make_app()) + + assert client.get("/demo").json() == { + "data": {"value": 1}, + "code": 200, + "message": "成功", + } + response = client.get("/boom") + assert response.status_code == 200 + assert response.json()["message"] == "业务失败" + + +def test_auto_envelope_wraps_plain_json_and_raw_can_skip(): + client = TestClient(make_app(raw_response_paths=["/health", "/ready", "/raw-path"])) + + assert client.get("/auto").json() == { + "data": {"value": 2}, + "code": 200, + "message": "成功", + } + assert client.get("/raw").json() == {"value": 3} + assert client.get("/text").text == "ok" + + +def test_rate_limit_dependency(): + client = TestClient(make_app(ratelimit_enabled=True)) + + assert client.get("/limited").status_code == 200 + response = client.get("/limited") + + assert response.status_code == 200 + assert response.json()["code"] == 429 + + +def test_request_log_includes_business_code(monkeypatch): + records = [] + + def capture(message, *args, **kwargs): + records.append((message, args, kwargs)) + + monkeypatch.setattr("iti.app.logger.info", capture) + client = TestClient(make_app()) + + assert client.get("/auto").json()["code"] == 200 + assert records[-1][1][3] == 200 + assert records[-1][2]["extra"]["response_code"] == 200 + + assert client.get("/boom").json()["code"] == 400 + assert records[-1][1][3] == 400 + assert records[-1][2]["extra"]["response_code"] == 400 diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..33f64a0 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,93 @@ +import httpx +import json +from fastapi import Request +from fastapi.testclient import TestClient + +from iti import create_app +from iti.audit import audit_operation, build_diff +from iti.config import BaseConfig +from iti.service_client import register_service_client + + +class AuditModule: + name = "audit" + + def register_routes(self, app): + @app.post("/change") + def change(request: Request): + audit_operation( + request, + title="修改", + target_type="demo", + target_id="1", + before={"name": "old", "password": "a"}, + after={"name": "new", "password": "b"}, + ) + return {"ok": True} + + +def make_app(): + return create_app( + modules=[AuditModule()], + config_mapping=BaseConfig( + database_url="sqlite+pysqlite:///:memory:", + testing=True, + audit_enabled=True, + audit_flush_interval_seconds=999, + ), + ) + + +def test_build_diff_masks_sensitive_fields(): + assert build_diff( + {"name": "old", "password": "a"}, + {"name": "new", "password": "b"}, + ) == { + "name": {"before": "old", "after": "new"}, + "password": {"before": "***", "after": "***"}, + } + + +def test_audit_operation_queues_event_without_blocking(): + app = make_app() + client = TestClient(app) + + response = client.post("/change") + + assert response.json()["data"] == {"ok": True} + event = app.state.audit_dispatcher._queue.get_nowait() + assert event.title == "修改" + assert event.diff == { + "name": {"before": "old", "after": "new"}, + "password": {"before": "***", "after": "***"}, + } + + +def test_audit_dispatcher_sends_events_to_configured_service(): + requests: list[dict] = [] + app = make_app() + client = TestClient(app) + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content)) + return httpx.Response(200, json={"data": {"count": 1}, "code": 200, "message": "成功"}) + + register_service_client( + app, + "audit", + {"base_url": "https://audit.local", "token": "svc-token"}, + transport=httpx.MockTransport(handler), + ) + client.post("/change") + event = app.state.audit_dispatcher._queue.get_nowait() + + app.state.audit_dispatcher._send([event]) + + sent = requests[0]["events"][0] + assert sent["type"] == "operation" + assert sent["title"] == "修改" + assert sent["path"] == "/change" + assert sent["diff"] == { + "name": {"before": "old", "after": "new"}, + "password": {"before": "***", "after": "***"}, + } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..40788a3 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,107 @@ +from fastapi import Depends +from fastapi.testclient import TestClient + +from iti import create_app +from iti.auth import ( + Actor, + Principal, + StaticPermissionProvider, + create_access_token, + require_actor, + require_permission, + require_service, + require_user, +) +from iti.config import BaseConfig +from iti.responses import ok + + +class Provider(StaticPermissionProvider): + def load_principal(self, principal_id, request): + return Principal( + id=principal_id, + permissions=frozenset({"demo.read"}), + scopes=frozenset({"svc:read"}), + ) + + +class AuthModule: + name = "auth" + + def register_routes(self, app): + @app.get("/me") + def me(principal=Depends(require_user)): + return ok({"id": principal.id}) + + @app.get("/allowed") + def allowed(principal=Depends(require_permission("demo.read"))): + return ok({"id": principal.id}) + + @app.get("/denied") + def denied(principal=Depends(require_permission("demo.write"))): + return ok({"id": principal.id}) + + @app.get("/service") + def service(actor: Actor = Depends(require_service())): + return ok({"id": actor.id, "type": actor.type}) + + @app.get("/actor") + def actor( + actor_value: Actor = Depends( + require_actor(permissions=["demo.read"], allow_service=True) + ), + ): + return ok({"id": actor_value.id, "type": actor_value.type}) + + +def make_app(): + config = BaseConfig( + database_url="sqlite+pysqlite:///:memory:", + testing=True, + jwt_secret_key="test-secret", + service_tokens={"erp": "svc-token"}, + ) + return create_app( + modules=[AuthModule()], + config_mapping=config, + permission_provider=Provider(), + ) + + +def test_jwt_auth_and_permission_dependencies(): + app = make_app() + token = create_access_token("u1", app.state.config) + client = TestClient(app) + headers = {"Authorization": f"Bearer {token}"} + + assert client.get("/me", headers=headers).json()["data"]["id"] == "u1" + assert client.get("/allowed", headers=headers).json()["code"] == 200 + assert client.get("/denied", headers=headers).json()["code"] == 403 + + +def test_missing_auth_returns_envelope(): + client = TestClient(make_app()) + + response = client.get("/me") + + assert response.status_code == 200 + assert response.json()["code"] == 401 + + +def test_service_token_and_actor_dependencies(): + app = make_app() + token = create_access_token("u1", app.state.config) + client = TestClient(app) + + assert client.get( + "/service", + headers={"Authorization": "Bearer svc-token"}, + ).json()["data"] == {"id": "erp", "type": "service"} + assert client.get( + "/actor", + headers={"Authorization": "Bearer svc-token"}, + ).json()["data"]["type"] == "service" + assert client.get( + "/actor", + headers={"Authorization": f"Bearer {token}"}, + ).json()["data"]["type"] == "user" diff --git a/tests/test_config.py b/tests/test_config.py index 1780c56..be2aadb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,157 +1,30 @@ -""" -配置测试 -""" -import os +from iti.config import BaseConfig, TestConfig as FrameworkTestConfig, default_mysql_url -import pytest -from iti.applications import create_app -from iti.config import DevConfig, TestConfig, ProdConfig, _load_env_file, get_config +def test_default_mysql_url_uses_mysql_driver(monkeypatch): + monkeypatch.setenv("MYSQL_USER", "u") + monkeypatch.setenv("MYSQL_PASSWORD", "p") + monkeypatch.setenv("MYSQL_HOST", "db") + monkeypatch.setenv("MYSQL_PORT", "3307") -class TestConfig2: - """配置测试类""" - - def test_dev_config(self): - """测试开发环境配置""" - app = create_app('dev') - - assert app.config['DEBUG'] is True - assert app.config['TESTING'] is False - assert app.config['SQLALCHEMY_DATABASE_URI'] - - def test_test_config(self): - """测试测试环境配置""" - app = create_app('test') - - assert app.config['DEBUG'] is False - assert app.config['TESTING'] is True - assert ':memory:' in app.config['SQLALCHEMY_DATABASE_URI'] - assert app.config['RATELIMIT_ENABLED'] is False - - def test_prod_config(self): - """测试生产环境配置""" - app = create_app('prod') - - assert app.config['DEBUG'] is False - assert app.config['TESTING'] is False - - def test_config_classes(self): - """测试配置类""" - dev_config = DevConfig() - test_config = TestConfig() - prod_config = ProdConfig() - - # 开发环境 - assert dev_config.DEBUG is True - assert dev_config.SQLALCHEMY_ECHO is True - - # 测试环境 - assert test_config.TESTING is True - assert test_config.RATELIMIT_ENABLED is False - - # 生产环境 - assert prod_config.DEBUG is False - - def test_get_config(self): - """测试配置获取函数""" - dev_config = get_config('dev') - assert dev_config == DevConfig - - test_config = get_config('test') - assert test_config == TestConfig - - prod_config = get_config('prod') - assert prod_config == ProdConfig - - # 测试默认配置 - default_config = get_config('unknown') - assert default_config == DevConfig + assert default_mysql_url("iti_test") == ( + "mysql+pymysql://u:p@db:3307/iti_test?charset=utf8mb4" + ) -@pytest.fixture -def app(): - """创建测试应用""" - return create_app('test') - - -@pytest.fixture -def client(app): - """创建测试客户端""" - return app.test_client() - - -def test_app_creation(app): - """测试应用创建""" - assert app is not None - assert app.config['TESTING'] is True - - -def test_app_context(app): - """测试应用上下文""" - with app.app_context(): - assert app.config['TESTING'] is True - - -def test_load_env_file_reads_project_directory(monkeypatch, tmp_path): - monkeypatch.delenv("DATABASE_URL", raising=False) - (tmp_path / ".env.dev").write_text("DATABASE_URL=sqlite:///project.db\n") - - assert _load_env_file(tmp_path) is True - assert os.getenv("DATABASE_URL") == "sqlite:///project.db" - - -def test_load_env_file_does_not_read_framework_package_dir(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) +def test_test_config_default_database_is_mysql(monkeypatch): monkeypatch.delenv("DATABASE_URL", raising=False) - monkeypatch.delenv("ITI_ENV_DIR", raising=False) - - assert _load_env_file() is False - assert os.getenv("DATABASE_URL") is None - - -def test_frontend_disabled_by_default(): - app = create_app("test") - client = app.test_client() - - response = client.get("/", headers={"Accept": "application/json"}) - - assert response.status_code == 200 - assert response.json["code"] == 404 - - -def test_default_error_page_is_available_for_html_clients(): - app = create_app("test") - client = app.test_client() - - response = client.get("/missing-page", headers={"Accept": "text/html"}) - - assert response.status_code == 404 - assert b"404" in response.data - - -def test_frontend_serves_business_spa_from_configured_path(tmp_path): - frontend_dir = tmp_path / "web-dist" - frontend_dir.mkdir() - (frontend_dir / "index.html").write_text("
business spa
") - (frontend_dir / "asset.txt").write_text("asset") - - class FrontendConfig(TestConfig): - FRONTEND_ENABLED = True - FRONTEND_PATH = str(frontend_dir) - - app = create_app(config_mapping={"test": FrontendConfig, "default": FrontendConfig}) - client = app.test_client() + monkeypatch.setenv("MYSQL_DATABASE", "iti_test") - index_response = client.get("/") - fallback_response = client.get("/business/route") - asset_response = client.get("/asset.txt") + config = FrameworkTestConfig() - assert index_response.status_code == 200 - assert b"business spa" in index_response.data - assert fallback_response.status_code == 200 - assert b"business spa" in fallback_response.data - assert asset_response.status_code == 200 - assert asset_response.data == b"asset" + assert config.database_url.startswith("mysql+pymysql://") + assert config.testing is True + assert config.ratelimit_enabled is False +def test_base_config_can_be_overridden_for_unit_tests(): + config = BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True) + assert config.database_url == "sqlite+pysqlite:///:memory:" + assert config.testing is True diff --git a/tests/test_http_utils.py b/tests/test_http_utils.py deleted file mode 100644 index ac30812..0000000 --- a/tests/test_http_utils.py +++ /dev/null @@ -1,418 +0,0 @@ -""" -HTTP 响应工具测试 -""" - -import pytest -from apiflask import APIBlueprint, Schema -from apiflask.fields import Integer, String -from iti.applications import create_app -from iti.applications.common.utils import ( - success, - fail, - page, - pagination_builder, - PaginationSchema, -) - - -@pytest.fixture -def app(): - """创建测试应用""" - test_bp = APIBlueprint("test_http_utils", __name__) - - @test_bp.get("/test/success") - def test_success(): - return success({"message": "ok"}, message="操作成功") - - @test_bp.get("/test/fail") - def test_fail(): - return fail("这是一个错误示例", code=400) - - @test_bp.get("/test/page") - def test_page(): - return page( - [{"id": 1}, {"id": 2}, {"id": 3}], - pagination_builder(None, page=1, size=10, total=30), - message="分页测试成功", - ) - - class IndexSchema(Schema): - id = Integer() - name = String() - - @test_bp.get("/") - @test_bp.output(IndexSchema) - def test_index(): - return success({"id": 1, "name": "test"}) - - class TestModule: - name = "test_http_utils" - - def register_routes(self, app): - app.register_blueprint(test_bp) - - return create_app("test", modules=[TestModule()]) - - -@pytest.fixture -def client(app): - """创建测试客户端""" - return app.test_client() - - -class TestSuccessFunction: - """测试 success() 函数""" - - def test_success_default(self): - """测试默认参数""" - result = success() - - assert result["code"] == 200 - assert result["message"] == "成功" - assert result["data"] is None - - def test_success_with_data(self): - """测试带数据""" - data = {"id": 1, "name": "test"} - result = success(data) - - assert result["code"] == 200 - assert result["message"] == "成功" - assert result["data"] == data - - def test_success_with_custom_message(self): - """测试自定义消息""" - result = success({"id": 1}, message="查询成功") - - assert result["code"] == 200 - assert result["message"] == "查询成功" - assert result["data"]["id"] == 1 - - def test_success_with_custom_code(self): - """测试自定义状态码""" - result = success({"id": 1}, message="创建成功", code=201) - - assert result["code"] == 201 - assert result["message"] == "创建成功" - - def test_success_with_list(self): - """测试列表数据""" - data = [{"id": 1}, {"id": 2}] - result = success(data) - - assert result["code"] == 200 - assert isinstance(result["data"], list) - assert len(result["data"]) == 2 - - -class TestFailFunction: - """测试 fail() 函数""" - - def test_fail_default(self): - """测试默认参数""" - result = fail() - - assert result["code"] == 500 - assert result["message"] == "操作失败" - assert result["data"] is None - - def test_fail_with_custom_message(self): - """测试自定义错误消息""" - result = fail("用户不存在") - - assert result["code"] == 500 - assert result["message"] == "用户不存在" - assert result["data"] is None - - def test_fail_with_custom_code(self): - """测试自定义错误码""" - result = fail("参数错误", code=400) - - assert result["code"] == 400 - assert result["message"] == "参数错误" - - def test_fail_with_data(self): - """测试带额外数据""" - errors = {"username": ["必填项"], "email": ["格式错误"]} - result = fail("验证失败", code=422, data=errors) - - assert result["code"] == 422 - assert result["message"] == "验证失败" - assert result["data"] == errors - - def test_fail_returns_dict(self): - """测试返回字典而非元组""" - result = fail("错误", code=404) - - # 确保返回的是字典,不是元组 - assert isinstance(result, dict) - assert "code" in result - assert "message" in result - assert "data" in result - - -class TestPaginationBuilder: - """测试 pagination_builder() 函数""" - - def test_manual_mode_basic(self): - """测试手动模式基础功能""" - result = pagination_builder(None, page=1, size=10, total=100) - - assert result["page"] == 1 - assert result["size"] == 10 - assert result["pages"] == 10 # ceil(100/10) - assert result["total"] == 100 - - def test_manual_mode_auto_calculate_pages(self): - """测试自动计算总页数""" - result = pagination_builder(None, page=1, size=20, total=95) - - assert result["pages"] == 5 # ceil(95/20) = 5 - - def test_manual_mode_with_explicit_pages(self): - """测试显式指定总页数""" - result = pagination_builder(None, page=1, size=10, total=100, pages=8) - - assert result["pages"] == 8 - - def test_manual_mode_zero_total(self): - """测试总数为 0""" - result = pagination_builder(None, page=1, size=10, total=0) - - assert result["pages"] == 0 - assert result["total"] == 0 - - def test_manual_mode_urls_are_none_outside_request(self): - """测试在请求上下文外 URL 为 None""" - result = pagination_builder(None, page=1, size=10, total=100) - - assert result["current"] is None - assert result["next"] is None - assert result["prev"] is None - assert result["first"] is None - assert result["last"] is None - - def test_auto_mode_with_mock_pagination(self): - """测试自动模式(使用模拟的 Pagination 对象)""" - - # 创建模拟的 Pagination 对象 - class MockPagination: - page = 2 - per_page = 10 - pages = 10 - total = 100 - has_prev = True - has_next = True - prev_num = 1 - next_num = 3 - - mock_pagination = MockPagination() - result = pagination_builder(mock_pagination) - - assert result["page"] == 2 - assert result["size"] == 10 # per_page → size - assert result["pages"] == 10 - assert result["total"] == 100 - - -class TestPageFunction: - """测试 page() 函数""" - - def test_page_with_list_and_dict(self): - """测试列表 + 字典分页信息""" - items = [{"id": 1}, {"id": 2}] - pagination_info = pagination_builder(None, page=1, size=10, total=50) - - result = page(items, pagination_info) - - assert result["code"] == 200 - assert result["message"] == "成功" - assert result["data"]["items"] == items - assert result["data"]["page"]["page"] == 1 - assert result["data"]["page"]["total"] == 50 - - def test_page_with_custom_message(self): - """测试自定义消息""" - items = [{"id": 1}] - pagination_info = pagination_builder(None, page=1, size=10, total=10) - - result = page(items, pagination_info, message="获取列表成功") - - assert result["message"] == "获取列表成功" - - def test_page_with_custom_code(self): - """测试自定义状态码""" - items = [{"id": 1}] - pagination_info = pagination_builder(None, page=1, size=10, total=10) - - result = page(items, pagination_info, code=201) - - assert result["code"] == 201 - - def test_page_with_mock_pagination_object(self): - """测试直接传入 Pagination 对象""" - - class MockPagination: - items = [{"id": 1}, {"id": 2}, {"id": 3}] - page = 1 - per_page = 10 - pages = 5 - total = 50 - has_prev = False - has_next = True - prev_num = None - next_num = 2 - - mock_pagination = MockPagination() - result = page(mock_pagination) - - assert result["code"] == 200 - assert len(result["data"]["items"]) == 3 - assert result["data"]["page"]["page"] == 1 - assert result["data"]["page"]["size"] == 10 - assert result["data"]["page"]["total"] == 50 - - def test_page_with_items_and_pagination_object(self): - """测试传入数据列表 + Pagination 对象""" - - class MockPagination: - items = [{"id": 1}, {"id": 2}] - page = 2 - per_page = 20 - pages = 3 - total = 60 - has_prev = True - has_next = True - prev_num = 1 - next_num = 3 - - mock_pagination = MockPagination() - custom_items = [{"id": 10}, {"id": 20}] # 使用自定义数据(如序列化后的) - - result = page(custom_items, mock_pagination) - - assert result["data"]["items"] == custom_items # 使用传入的 items - assert result["data"]["page"]["page"] == 2 - assert result["data"]["page"]["size"] == 20 - - def test_page_empty_items(self): - """测试空数据列表""" - items = [] - pagination_info = pagination_builder(None, page=1, size=10, total=0) - - result = page(items, pagination_info) - - assert result["data"]["items"] == [] - assert result["data"]["page"]["total"] == 0 - assert result["data"]["page"]["pages"] == 0 - - -class TestSchemaDefinitions: - """测试 Schema 定义""" - - def test_pagination_schema_exists(self): - """测试 PaginationSchema 存在""" - assert PaginationSchema is not None - - # 检查字段 - schema = PaginationSchema() - assert "page" in schema.fields - assert "size" in schema.fields - assert "pages" in schema.fields - assert "total" in schema.fields - - -class TestIntegrationWithFlaskApp: - """测试与 Flask 应用集成""" - - def test_success_in_route(self, client): - """测试 success() 在路由中使用""" - response = client.get("/test/success") - - assert response.status_code == 200 - data = response.get_json() - - assert data["code"] == 200 - assert data["message"] == "操作成功" - assert "message" in data["data"] - - def test_fail_in_route(self, client): - """测试 fail() 在路由中使用""" - response = client.get("/test/fail") - - # 确保 HTTP 状态码是 200(业务错误) - assert response.status_code == 200 - data = response.get_json() - - assert data["code"] == 400 - assert data["message"] == "这是一个错误示例" - - def test_page_in_route(self, client): - """测试 page() 在路由中使用""" - response = client.get("/test/page") - - assert response.status_code == 200 - data = response.get_json() - - assert data["code"] == 200 - assert data["message"] == "分页测试成功" - assert "items" in data["data"] - assert "page" in data["data"] - assert len(data["data"]["items"]) == 3 - assert data["data"]["page"]["page"] == 1 - assert data["data"]["page"]["size"] == 10 - assert data["data"]["page"]["total"] == 30 - - def test_index_with_schema(self, client): - """测试首页使用 Schema 验证""" - response = client.get("/") - - assert response.status_code == 200 - data = response.get_json() - - assert data["code"] == 200 - assert data["message"] == "成功" - assert "id" in data["data"] - assert "name" in data["data"] - # 验证 Schema 过滤了无关字段(如果有的话) - - -class TestEdgeCases: - """测试边界情况""" - - def test_pagination_builder_with_fractional_pages(self): - """测试非整数页数计算""" - result = pagination_builder(None, page=1, size=7, total=15) - - # ceil(15/7) = 3 - assert result["pages"] == 3 - - def test_pagination_builder_exact_pages(self): - """测试整数页数""" - result = pagination_builder(None, page=1, size=10, total=100) - - assert result["pages"] == 10 - - def test_success_with_none_data(self): - """测试 data 为 None""" - result = success(None, message="无数据") - - assert result["data"] is None - assert result["message"] == "无数据" - - def test_fail_with_empty_string_message(self): - """测试空字符串消息""" - result = fail("", code=400) - - assert result["message"] == "" - assert result["code"] == 400 - - def test_page_with_large_total(self): - """测试大数据量分页""" - items = [{"id": i} for i in range(100)] - pagination_info = pagination_builder(None, page=1, size=100, total=1000000) - - result = page(items, pagination_info) - - assert result["data"]["page"]["total"] == 1000000 - assert result["data"]["page"]["pages"] == 10000 diff --git a/tests/test_limiter.py b/tests/test_limiter.py deleted file mode 100644 index 9f04744..0000000 --- a/tests/test_limiter.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -限流器测试 -""" -import pytest -from iti.applications import create_app - - -@pytest.fixture -def app(): - """创建测试应用""" - return create_app('test') - - -@pytest.fixture -def client(app): - """创建测试客户端""" - return app.test_client() - - -def test_limiter_disabled_in_test_env(app): - """测试环境应该禁用限流""" - from iti.applications.extensions.limit import limiter - - # 测试环境应该禁用限流 - assert app.config.get('RATELIMIT_ENABLED') is False # 配置中禁用 - assert limiter is not None - assert limiter.enabled is False - - -def test_limiter_config_in_dev_env(): - """测试开发环境限流配置""" - app = create_app('dev') - - # 开发环境应该启用限流 - assert app.config.get('RATELIMIT_ENABLED') is True - assert app.config.get('RATELIMIT_DEFAULT') == "1000 per hour" - assert app.config.get('RATELIMIT_STORAGE_URL') == "memory://" - - -def test_limiter_config_in_prod_env(): - """测试生产环境限流配置""" - app = create_app('prod') - - # 生产环境应该启用限流 - assert app.config.get('RATELIMIT_ENABLED') is True - assert app.config.get('RATELIMIT_DEFAULT') == "100 per hour" - # 生产环境可能使用 Redis - assert app.config.get('RATELIMIT_STORAGE_URL') in ["memory://", "redis://localhost:6379/0"] - - -def test_limiter_initialization(): - """测试限流器初始化""" - # 测试环境 - 应该禁用(limiter 为 None) - app_test = create_app('test') - from iti.applications.extensions.limit import limiter as test_limiter - assert test_limiter is not None - assert test_limiter.enabled is False - - # 开发环境 - 应该启用 - app_dev = create_app('dev') - from iti.applications.extensions.limit import limiter as dev_limiter - assert dev_limiter is not None # 开发环境应该创建了 limiter 实例 - assert dev_limiter.enabled is True - - -def test_limiter_with_custom_config(): - """测试自定义限流配置""" - from flask import Flask - from iti.applications.extensions.limit import init_limiter - - # 创建测试应用 - app = Flask(__name__) - app.config.update({ - 'RATELIMIT_ENABLED': True, - 'RATELIMIT_STORAGE_URL': 'memory://', - 'RATELIMIT_DEFAULT': '50 per minute' - }) - - # 初始化限流器 - init_limiter(app) - - # 验证配置 - from iti.applications.extensions.limit import limiter - assert limiter is not None - assert limiter.enabled is True - # 验证存储 URI(使用私有属性) - assert limiter._storage_uri == 'memory://' - - -def test_limiter_disabled_config(): - """测试禁用限流配置""" - from flask import Flask - from iti.applications.extensions.limit import init_limiter - - # 创建测试应用 - app = Flask(__name__) - app.config.update({ - 'RATELIMIT_ENABLED': False, - 'RATELIMIT_STORAGE_URL': 'memory://', - 'RATELIMIT_DEFAULT': '100 per hour' - }) - - # 初始化限流器 - init_limiter(app) - - # 验证限流器被禁用 - from iti.applications.extensions.limit import limiter - assert limiter is not None - assert limiter.enabled is False diff --git a/tests/test_modules.py b/tests/test_modules.py index e6baeca..20f934c 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,80 +1,36 @@ -from __future__ import annotations - import pytest -from iti.applications import create_app -from iti.applications.common.enums import MenuTypeEnum -from iti.modules import ModuleMenuSeed, ModulePermission, get_module_registry -from iti.modules.registry import ModuleRegistry - - -class RecordingModule: - name = "recording" - - def __init__(self) -> None: - self.calls: list[str] = [] - - def init_app(self, app) -> None: - self.calls.append("init_app") +from iti.config import BaseConfig +from iti.modules import ModuleMenuSeed, ModulePermission, ModuleRegistry +from iti import create_app - def register_commands(self, app) -> None: - self.calls.append("register_commands") - def register_routes(self, app) -> None: - self.calls.append("register_routes") +class DemoModule: + name = "demo" - def register_permissions(self, app) -> None: - self.calls.append("register_permissions") - get_module_registry(app).register_permission( - ModulePermission(code="recording:list", name="录制列表") - ) + def register_permissions(self, app): + app.state.iti_modules.register_permission(ModulePermission("demo.read", "Demo Read")) - def register_menu_seed(self, app) -> None: - self.calls.append("register_menu_seed") - get_module_registry(app).register_menu_seed( - ModuleMenuSeed( - id="recording-menu", - name="Recording", - type=MenuTypeEnum.MENU.value, - path="/recording", - component="/recording/list", - auth_code="recording:list", - meta={"title": "录制模块"}, - sort=200, - ) + def register_menu_seed(self, app): + app.state.iti_modules.register_menu_seed( + ModuleMenuSeed(id="demo", name="Demo", type="menu", sort=10) ) def test_module_registry_rejects_duplicate_names(): registry = ModuleRegistry() - registry.register(RecordingModule()) - - with pytest.raises(ValueError, match="module already registered"): - registry.register(RecordingModule()) + registry.register(DemoModule()) + with pytest.raises(ValueError, match="already registered"): + registry.register(DemoModule()) -def test_create_app_runs_module_phases_and_collects_metadata(): - module = RecordingModule() - imported: list[str] = [] +def test_create_app_runs_module_registration(): app = create_app( - "test", - modules=[module], - config_mapping={"test": "iti.config.TestConfig"}, - model_imports=[lambda: imported.append("models")], + modules=[DemoModule()], + config_mapping=BaseConfig(database_url="sqlite+pysqlite:///:memory:"), ) - registry = get_module_registry(app) - - assert module.calls == [ - "init_app", - "register_commands", - "register_routes", - "register_permissions", - "register_menu_seed", - ] - assert imported == ["models"] - assert registry.list_permissions()[0].code == "recording:list" - assert registry.list_menu_seeds()[0].id == "recording-menu" - - + registry = app.state.iti_modules + assert registry.permissions["demo.read"].name == "Demo Read" + assert registry.list_menu_seeds()[0].id == "demo" diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 0000000..6bb519d --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,25 @@ +from iti.responses import fail, ok, page, pagination +from iti.schemas import ItiModel + + +class UserOut(ItiModel): + user_id: str + created_at: str + + +def test_envelope_helpers(): + assert ok({"id": "1"}) == {"data": {"id": "1"}, "code": 200, "message": "成功"} + assert fail("x", code=400) == {"data": None, "code": 400, "message": "x"} + + +def test_page_helper(): + result = page([{"id": 1}], pagination(page=2, size=10, total=11)) + + assert result["data"]["items"] == [{"id": 1}] + assert result["data"]["page"] == {"page": 2, "size": 10, "pages": 2, "total": 11} + + +def test_model_outputs_camel_case(): + result = UserOut(user_id="u1", created_at="2026-01-01").model_dump(by_alias=True) + + assert result == {"userId": "u1", "createdAt": "2026-01-01"} diff --git a/tests/test_service_client.py b/tests/test_service_client.py index 7bbc7dc..dbbf111 100644 --- a/tests/test_service_client.py +++ b/tests/test_service_client.py @@ -1,108 +1,107 @@ -from __future__ import annotations - import httpx import pytest from iti.service_client import ( ServiceClient, + ServiceBusinessError, ServiceConfig, ServiceHTTPError, ServiceUnavailableError, ) -from iti.service_client.config import CircuitBreakerConfig, RetryConfig - - -def _json_response(status_code: int, payload: dict) -> httpx.Response: - return httpx.Response(status_code, json=payload) -def test_service_client_sends_json_headers_token_trace_and_path(): - seen: dict[str, object] = {} - +def test_service_client_sends_json_and_token(): def handler(request: httpx.Request) -> httpx.Response: - seen["url"] = str(request.url) - seen["authorization"] = request.headers.get("Authorization") - seen["trace_id"] = request.headers.get("X-Trace-Id") - return _json_response(200, {"ok": True}) + assert request.headers["Authorization"] == "Bearer token" + return httpx.Response(200, json={"ok": True}) client = ServiceClient( - ServiceConfig(name="erp", base_url="http://erp.local", token="token-a"), + ServiceConfig.from_mapping( + "demo", + {"base_url": "https://example.test", "token": "token"}, + ), transport=httpx.MockTransport(handler), ) - result = client.get("/users/{id}", path={"id": 12}, params={"active": "1"}) + assert client.get("/ping") == {"ok": True} - assert result == {"ok": True} - assert seen["url"] == "http://erp.local/users/12?active=1" - assert seen["authorization"] == "Bearer token-a" - assert isinstance(seen["trace_id"], str) - assert seen["trace_id"] +def test_service_client_unwraps_envelope(): + client = ServiceClient( + ServiceConfig.from_mapping("demo", {"base_url": "https://example.test"}), + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, + json={"data": {"ok": True}, "code": 200, "message": "成功"}, + ) + ), + ) -def test_service_client_retries_idempotent_statuses(): - calls = {"count": 0} + assert client.get("/ping") == {"ok": True} - def handler(request: httpx.Request) -> httpx.Response: - calls["count"] += 1 - if calls["count"] == 1: - return _json_response(503, {"error": "busy"}) - return _json_response(200, {"ok": True}) +def test_service_client_raises_for_business_errors(): client = ServiceClient( - ServiceConfig( - name="erp", - base_url="http://erp.local", - retry=RetryConfig(attempts=2, backoff=0), + ServiceConfig.from_mapping("demo", {"base_url": "https://example.test"}), + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, + json={"data": None, "code": 403, "message": "无权限"}, + ) ), - transport=httpx.MockTransport(handler), ) - assert client.get("/health") == {"ok": True} - assert calls["count"] == 2 + with pytest.raises(ServiceBusinessError) as exc: + client.get("/ping") + assert exc.value.code == 403 + assert exc.value.status_code == 200 -def test_service_client_does_not_retry_post_by_default(): - calls = {"count": 0} - - def handler(request: httpx.Request) -> httpx.Response: - calls["count"] += 1 - return _json_response(503, {"error": "busy"}) +def test_service_client_prefers_envelope_code_on_http_error(): client = ServiceClient( - ServiceConfig( - name="erp", - base_url="http://erp.local", - retry=RetryConfig(attempts=3, backoff=0), + ServiceConfig.from_mapping("demo", {"base_url": "https://example.test"}), + transport=httpx.MockTransport( + lambda request: httpx.Response( + 401, + json={"data": {"reason": "expired"}, "code": 401, "message": "未认证"}, + ) ), - transport=httpx.MockTransport(handler), ) - with pytest.raises(ServiceHTTPError) as exc_info: - client.post("/jobs", json={"kind": "users"}) + with pytest.raises(ServiceBusinessError) as exc: + client.get("/ping") - assert exc_info.value.status_code == 503 - assert calls["count"] == 1 + assert exc.value.code == 401 + assert exc.value.status_code == 401 + assert exc.value.data == {"reason": "expired"} -def test_service_client_opens_circuit_breaker_after_failures(): - def handler(request: httpx.Request) -> httpx.Response: - return _json_response(500, {"error": "failed"}) +def test_service_client_raises_for_http_errors(): + client = ServiceClient( + ServiceConfig.from_mapping("demo", {"base_url": "https://example.test"}), + transport=httpx.MockTransport(lambda request: httpx.Response(503, text="down")), + ) + with pytest.raises(ServiceHTTPError) as exc: + client.get("/ping") + + assert exc.value.status_code == 503 + + +def test_service_client_circuit_breaker_opens_after_failures(): client = ServiceClient( - ServiceConfig( - name="erp", - base_url="http://erp.local", - circuit_breaker=CircuitBreakerConfig( - enabled=True, - fail_max=1, - reset_timeout=30, - ), + ServiceConfig.from_mapping( + "demo", + { + "base_url": "https://example.test", + "circuit_breaker": {"enabled": True, "fail_max": 1}, + }, ), - transport=httpx.MockTransport(handler), + transport=httpx.MockTransport(lambda request: httpx.Response(503, text="down")), ) with pytest.raises(ServiceHTTPError): - client.get("/health") - - with pytest.raises(ServiceUnavailableError, match="circuit breaker is open"): - client.get("/health") + client.get("/ping") + with pytest.raises(ServiceUnavailableError, match="circuit breaker"): + client.get("/ping") diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..586e44e --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,18 @@ +from io import BytesIO + +from iti.storage import StorageManager + + +def test_local_storage_roundtrip(tmp_path): + StorageManager._instances.clear() + storage = StorageManager.get_storage( + "local", + config={"LOCAL": {"base_path": str(tmp_path)}}, + ) + + storage.upload(BytesIO(b"hello"), "a/b.txt", "text/plain") + + assert storage.exists("a/b.txt") is True + assert storage.download("a/b.txt").read() == b"hello" + storage.delete("a/b.txt") + assert storage.exists("a/b.txt") is False diff --git a/tests/test_tasks.py b/tests/test_tasks.py index e14f523..09fa7d8 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,71 +1,45 @@ -from __future__ import annotations - -import threading import time -import pytest - -from iti.tasks.registry import TaskRegistry +from iti.tasks import TaskRegistry from iti.tasks.runner import TaskRunner, _parse_interval, _parse_simple_cron -def test_task_registry_triggers_success_and_failure_runs(): +def test_task_registry_trigger_success(): registry = TaskRegistry() - registry.register(name="success", handler=lambda: {"ok": True}) - registry.register(name="failure", handler=lambda: 1 / 0) + registry.register(name="demo", handler=lambda: "done") - success = registry.trigger("success") - failure = registry.trigger("failure") + run = registry.trigger("demo") - assert success.status == "success" - assert success.result == {"ok": True} - assert success.finished_at is not None - assert failure.status == "failed" - assert "ZeroDivisionError" in failure.error + assert run.status == "success" + assert run.result == "done" + assert run.finished_at is not None -def test_task_registry_rejects_duplicate_names(): +def test_task_registry_skips_duplicate_running_task(): registry = TaskRegistry() - registry.register(name="sync", handler=lambda: None) - - with pytest.raises(ValueError, match="task already registered"): - registry.register(name="sync", handler=lambda: None) + def handler(): + time.sleep(0.05) -def test_task_registry_skips_when_same_task_is_running(): - started = threading.Event() - release = threading.Event() - registry = TaskRegistry() - - def blocking_handler(): - started.set() - release.wait(timeout=2) + registry.register(name="demo", handler=handler) + registry._running.add("demo") - registry.register(name="sync", handler=blocking_handler) - worker = threading.Thread(target=registry.trigger, args=("sync",)) - worker.start() - assert started.wait(timeout=1) + run = registry.trigger("demo") - skipped = registry.trigger("sync") - release.set() - worker.join(timeout=2) + assert run.status == "skipped" - assert skipped.status == "skipped" - assert skipped.error == "task already running" +def test_schedule_parsers(): + assert _parse_interval("interval:5") == 5 + assert _parse_simple_cron("*/2 * * * *") == 120 + assert _parse_simple_cron("cron:* * * * *") == 60 -def test_task_schedule_parsers_and_due_check(): - runner = TaskRunner(TaskRegistry()) - now = time.time() - assert _parse_interval("interval:60") == 60 - assert _parse_interval("interval:0") == 1 - assert _parse_interval("bad") is None - assert _parse_simple_cron("cron:*/5 * * * *") == 300 - assert _parse_simple_cron("* * * * *") == 60 - assert _parse_simple_cron("0 * * * *") is None +def test_runner_due_calculation(): + registry = TaskRegistry() + runner = TaskRunner(registry) - assert runner._due("interval:10", "sync", now) is True - runner._last_run["sync"] = now - assert runner._due("interval:10", "sync", now + 5) is False - assert runner._due("interval:10", "sync", now + 10) is True + assert runner._due("interval:1", "demo", 100.0) is True + runner._last_run["demo"] = 100.0 + assert runner._due("interval:10", "demo", 105.0) is False + assert runner._due("interval:10", "demo", 110.0) is True