refactor: rebuild fastapi framework foundation

main
NoahLan 2 weeks ago
parent 69c845aacd
commit 9a71aa8c93

@ -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)

@ -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`。

@ -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)

@ -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 = {

@ -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

@ -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 %}

@ -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

@ -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()

@ -17,6 +17,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"httpx>=0.27.0",
]
[tool.setuptools.packages.find]

@ -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"}

@ -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="名称")

@ -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",

@ -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})

@ -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 并增加系统域路由

@ -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 鉴权。

@ -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.<FLASK_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.<ITI_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"]`

@ -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
```

@ -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 <accessToken>
```
## 系统接口
用户:
```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
}
}
```

@ -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。

@ -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。

@ -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)
## 常用命令

@ -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。

@ -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`

@ -28,7 +28,10 @@ iTi-Flask 提供单进程任务注册表和运行器。
默认不启动调度线程。
```python
TASKS_ENABLED = True
class DevConfig(BaseDevConfig):
def __init__(self) -> None:
super().__init__()
self.tasks_enabled = True
```
## 注册任务

@ -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 同时启用调度。
轻量任务只保证单进程内正常运行。

@ -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"]

@ -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}

@ -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)

@ -1,2 +0,0 @@
from .logger import setup_logger
from .filter import ModelFilter

@ -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

@ -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}")

@ -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

@ -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

@ -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

@ -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

@ -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)

@ -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]

@ -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)

@ -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)

@ -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)

@ -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,
)

@ -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)

@ -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,
)

@ -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

@ -1,10 +0,0 @@
from .eventbus import EventBus
eventbus = EventBus()
def init_eventbus(app):
"""
初始化事件总线
"""
eventbus.init_app(app)

@ -1,6 +0,0 @@
from .event_bus import EventBus
from .event_middleware import EventMiddleware
from .event_handler import (
BaseEventHandler,
FlaskEventHandler,
)

@ -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 或 detachedmerge 到新 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)

@ -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

@ -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

@ -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

@ -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

@ -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)

@ -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)

@ -1,8 +0,0 @@
from flask_moment import Moment
moment = Moment()
def init_moment(app):
moment.init_app(app)

@ -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")

@ -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")

@ -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("/<path:fallback>")
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")

@ -1,4 +0,0 @@
def init_services(app) -> None:
"""初始化Services"""
return None

@ -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)

@ -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",
]

@ -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

@ -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

@ -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()

@ -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

@ -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

@ -1,4 +1,7 @@
class PermissionDeniedException(Exception):
from iti.exceptions import PermissionDenied
class PermissionDeniedException(PermissionDenied):
"""
权限拒绝异常
"""

@ -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过滤条件

@ -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 # 200MBFlask 上传大小限制
# 缓存配置
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()

@ -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",
]

@ -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="更新人"
)

@ -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

@ -0,0 +1,3 @@
from .bus import EventBus, eventbus
__all__ = ["EventBus", "eventbus"]

@ -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()

@ -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 = "未认证"

@ -0,0 +1,3 @@
from .routes import router
__all__ = ["router"]

@ -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"}

@ -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)

@ -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", "-"),
}

@ -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."""

@ -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

@ -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"]

@ -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

@ -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)

@ -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

@ -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",
]

@ -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())

@ -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}")

@ -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}")

@ -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}")

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save