Compare commits

...
1 Commits
Author SHA1 Message Date
Wu Clan b775dd3a16 sync 2026-08-23 12:16:43 +08:00
300 changed files with 573 additions and 22383 deletions
+3 -50
View File
@@ -1,26 +1,19 @@
# Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args # Python environment from uv
ARG SERVER_TYPE=fba_server
# === Python environment from uv ===
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS builder
# Used for build Python packages
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends gcc make python3-dev \ && apt-get install -y --no-install-recommends gcc make python3-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY . /fba COPY . /fba
WORKDIR /fba WORKDIR /fba
# Configure uv environment
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_NO_CACHE=1 \ UV_NO_CACHE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/usr/local UV_PROJECT_ENVIRONMENT=/usr/local
# Install dependencies with cache
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@@ -30,8 +23,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)" python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)"
# === Runtime base server image === # Single FastAPI server image
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS base_server FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \ && apt-get update \
@@ -39,50 +32,10 @@ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debi
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /fba /fba COPY --from=builder /fba /fba
COPY --from=builder /usr/local /usr/local COPY --from=builder /usr/local /usr/local
COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
# === FastAPI server image ===
FROM base_server AS fba_server
COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/ COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba RUN mkdir -p /var/log/fba
EXPOSE 8001 EXPOSE 8001
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Worker image ===
FROM base_server AS fba_celery_worker
COPY deploy/backend/supervisor/fba_celery_worker.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Beat image ===
FROM base_server AS fba_celery_beat
COPY deploy/backend/supervisor/fba_celery_beat.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Flower image ===
FROM base_server AS fba_celery_flower
COPY deploy/backend/supervisor/fba_celery_flower.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
EXPOSE 8555
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# Build image
FROM ${SERVER_TYPE}
+4 -68
View File
@@ -1,71 +1,7 @@
<div align="center"> # FastAPI Best Architecture Slim
<img alt="The logo includes the abstract combination of the three letters FBA, forming a lightning bolt that seems to spread out from the ground" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png"> 专注于基本架构的 API 开发简化版本
# FastAPI Best Architecture 完整版请查看 [FastAPI Best Architecture](https://github.com/fastapi-practices/fastapi-best-architecture)
Enterprise-level backend architecture solution 简体中文 | [English](./README.zh-CN.md)
English | [简体中文](./README.zh-CN.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
![MySQL](https://img.shields.io/badge/MySQL-8.0%2B-%2300758f)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16.0%2B-%23336791)
![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-%23778877)
[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://pydantic.dev)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div>
## Three-tier architecture
The MVC architecture is common in Python Web as a general design pattern, but the three-tier architecture is more
fascinating
However, please note that we do not have a traditional multi-app (microservices) directory structure (django,
springBoot...). ), but a self-righteous directory structure that you can give it any twist if you don't like this model!
| workflow | java | fastapi_best_architecture |
|----------------|----------------|---------------------------|
| view | controller | api |
| data transmit | dto | schema |
| business logic | service + impl | service |
| data access | dao / mapper | crud |
| model | model / entity | model |
## Help
For more details, please check
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
## Contributors
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
</a>
## Special thanks
- [FastAPI](https://fastapi.tiangolo.com/)
- [Pydantic](https://docs.pydantic.dev/latest/)
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
- [Casbin](https://casbin.org/zh/)
- [Ruff](https://beta.ruff.rs/docs/)
- ...
## Interactivity
[Discord](https://wu-clan.github.io/homepage/)
## License
This project is licensed by the terms of
the [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) license
[![Stargazers over time](https://starchart.cc/fastapi-practices/fastapi_best_architecture.svg?variant=adaptive)](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
+4 -64
View File
@@ -1,67 +1,7 @@
<div align="center"> # FastAPI Best Architecture Slim
<img alt="Logo 包含了 FBA 三个字母抽象结合,形成了一个类似从地面扩散投影上来的闪电" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png"> A simplified version focused on basic architecture for API development
# FastAPI Best Architecture See the [full FastAPI Best Architecture project](https://github.com/fastapi-practices/fastapi-best-architecture)
企业级后端架构解决方案 English | [简体中文](./README.md)
简体中文 | [English](./README.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
![MySQL](https://img.shields.io/badge/MySQL-8.0%2B-%2300758f)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16.0%2B-%23336791)
![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-%23778877)
[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://pydantic.dev)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div>
## 三层架构
mvc 架构作为常规设计模式,在 python web 中很常见,但是三层架构更令人着迷
但请注意,我们并没有传统的多 app (微服务)目录结构(django、springBoot...),而是自以为是的目录结构,如果您不喜欢这种模式,可以对其进行任意改造!
| 工作流程 | java | fastapi_best_architecture |
|------|----------------|---------------------------|
| 视图 | controller | api |
| 数据传输 | dto | schema |
| 业务逻辑 | service + impl | service |
| 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model |
## 帮助
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
## 贡献者
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
</a>
## 特别鸣谢
- [FastAPI](https://fastapi.tiangolo.com/)
- [Pydantic](https://docs.pydantic.dev/latest/)
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
- [Casbin](https://casbin.org/zh/)
- [Ruff](https://beta.ruff.rs/docs/)
- ...
## 互动
[Discord](https://wu-clan.github.io/homepage/)
## 许可证
本项目由 [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) 许可证的条款进行许可
[![Stargazers over time](https://starchart.cc/fastapi-practices/fastapi_best_architecture.svg?variant=adaptive)](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
+304
View File
@@ -0,0 +1,304 @@
# FBA-Slim 瘦身指南
> 本文档记录了 fba-slim 与 fba 完整版之间的差异,方便日后从完整版合并代码时快速瘦身
## 功能差异概览
| 模块 | 完整版 | 精简版 | 说明 |
|-------------------------------|-----|-----|-----------------------------|
| 用户认证 (JWT) | ✅ | ✅ | 完整保留 |
| RBAC (角色/菜单/部门/权限校验) | ✅ | ❌ | 整体移除,仅保留 `DependsSuperUser` |
| 用户 CRUD | ✅ | ✅ | 保留(移除 dept/role 关联) |
| 操作日志/登录日志 | ✅ | ❌ | 移除 DB 日志,中间件仅控制台输出 |
| 多级缓存 (Local + Redis + PubSub) | ✅ | ✅ | 完整保留 |
| Snowflake 分布式 ID | ✅ | ✅ | 完整保留 |
| 文件上传 | ✅ | ✅ | 完整保留 |
| 密码安全/历史记录 | ✅ | ✅ | 完整保留 |
| config 插件 | ✅ | ✅ | 保留 |
| 插件核心系统 | ✅ | ✅ | 保留插件安装/卸载机制,内置仅 `config` |
| Celery 任务系统 | ✅ | ❌ | 整体移除 |
| Socket.IO 实时通信 | ✅ | ❌ | 整体移除 |
| Prometheus + OTel 可观测性 | ✅ | ❌ | 整体移除 |
| 监控 API (online/redis/server) | ✅ | ❌ | 整体移除 |
| 数据权限 (DataRule/DataScope) | ✅ | ❌ | 整体移除 |
| dict 插件 | ✅ | ❌ | 整体移除 |
| email 插件 | ✅ | ❌ | 整体移除 |
| notice 插件 | ✅ | ❌ | 整体移除 |
| oauth2 插件 | ✅ | ❌ | 整体移除 |
| code_generator 插件 | ✅ | ❌ | 整体移除 |
## 插件范围
精简版仓库只内置 `config` 插件,`backend/plugin/` 下不得保留其他业务插件目录
- `backend/core/conf.py``PLUGIN_REQUIRED` 固定为 `['config']`
- `backend/plugin/config/` 是唯一随仓库发布的插件
- 插件核心、CLI 和插件管理 API 仍然保留,用于后续按需安装外部插件
- 不保留 AI、dict、email、notice、oauth2、code_generator 等插件及其依赖
---
## 已删除的目录
```
backend/app/task/ # Celery 任务系统
backend/common/socketio/ # Socket.IO 实时通信
backend/common/observability/ # Prometheus + OpenTelemetry
backend/app/admin/api/v1/monitor/ # 监控 API (online/redis/server)
backend/app/admin/tests/ # 测试文件
backend/app/admin/api/v1/log/ # 日志 API (login_log/opera_log)
backend/plugin/dict/ # 字典插件
backend/plugin/email/ # 邮件插件
backend/plugin/notice/ # 通知插件
backend/plugin/oauth2/ # OAuth2 插件
backend/plugin/code_generator/ # 代码生成插件
backend/plugin/ai/ # AI 插件
deploy/backend/grafana/ # Grafana 部署配置
```
## 已删除的文件
```
# RBAC (角色/菜单/部门)
backend/app/admin/model/role.py
backend/app/admin/model/menu.py
backend/app/admin/model/dept.py
backend/app/admin/model/m2m.py
backend/app/admin/schema/role.py
backend/app/admin/schema/menu.py
backend/app/admin/schema/dept.py
backend/app/admin/crud/crud_role.py
backend/app/admin/crud/crud_menu.py
backend/app/admin/crud/crud_dept.py
backend/app/admin/service/role_service.py
backend/app/admin/service/menu_service.py
backend/app/admin/service/dept_service.py
backend/app/admin/api/v1/sys/role.py
backend/app/admin/api/v1/sys/menu.py
backend/app/admin/api/v1/sys/dept.py
backend/common/security/rbac.py
backend/common/security/permission.py
backend/utils/build_tree.py
# 数据权限
backend/app/admin/model/data_rule.py
backend/app/admin/model/data_scope.py
backend/app/admin/schema/data_rule.py
backend/app/admin/schema/data_scope.py
backend/app/admin/crud/crud_data_rule.py
backend/app/admin/crud/crud_data_scope.py
backend/app/admin/service/data_rule_service.py
backend/app/admin/service/data_scope_service.py
backend/app/admin/api/v1/sys/data_rule.py
backend/app/admin/api/v1/sys/data_scope.py
# 监控 API
backend/app/admin/schema/monitor.py
# 可观测性
backend/common/observability/otel.py
backend/common/observability/prometheus/fastapi.py
backend/common/observability/prometheus/queue.py
backend/common/observability/prometheus/sqlalchemy.py
# 日志系统
backend/app/admin/model/login_log.py
backend/app/admin/model/opera_log.py
backend/app/admin/schema/login_log.py
backend/app/admin/schema/opera_log.py
backend/app/admin/crud/crud_login_log.py
backend/app/admin/crud/crud_opera_log.py
backend/app/admin/service/login_log_service.py
backend/app/admin/service/opera_log_service.py
backend/common/queue.py
# 部署
deploy/backend/supervisor/fba_celery_beat.conf
deploy/backend/supervisor/fba_celery_flower.conf
deploy/backend/supervisor/fba_celery_worker.conf
deploy/backend/grafana/dashboards/fba_celery.json
```
---
## 已修改的文件
### 模型层
| 文件 | 修改内容 |
|---------------------------------------|-------------------------------------|
| `backend/app/admin/model/__init__.py` | 仅保留 `User``UserPasswordHistory` 导出 |
| `backend/app/admin/model/user.py` | 删除 `dept_id` 字段 |
### Schema 层
| 文件 | 修改内容 |
|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `backend/app/admin/schema/user.py` | 删除 `dept_id`/`roles` 字段、`AddUserRoleParam`/`GetUserInfoWithRelationDetail`/`GetCurrentUserInfoWithRelationDetail`/`AddOAuth2UserParam` 类 |
### CRUD 层
| 文件 | 修改内容 |
|---------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| `backend/app/admin/crud/crud_user.py` | 删除 Role/Dept/Menu/m2m 全部引用,移除 `get_join()` 方法、JoinConfig、m2m 操作;`add()`/`update()`/`delete()`/`get_select()` 简化 |
### Service 层
| 文件 | 修改内容 |
|---------------------------------------------|-----------------------------------------------------------------------------------|
| `backend/app/admin/service/user_service.py` | 删除 `get_roles()` 方法、dept/role 验证逻辑、`dept` 参数;`get_userinfo()` 改用 `user_dao.get()`/`get_by_username()``update_email()` 删除邮箱验证码(email 插件已移除),保留邮箱唯一性校验 |
| `backend/app/admin/service/auth_service.py` | 删除 `login_log_service`/`menu_dao` 引用、`get_codes()` 方法、`background_tasks` 参数 |
### Utils/安全层
| 文件 | 修改内容 |
|------------------------------------|------------------------------------------------------------------------------------------------------|
| `backend/app/admin/utils/cache.py` | 删除 `clear_by_role_id()`/`clear_by_menu_id()`/`clear_by_data_scope_id()`/`clear_by_data_rule_id()` 方法 |
| `backend/utils/trace_id.py` | 删除 `OtelTraceIdPlugin` 类 |
| `backend/utils/dynamic_config.py` | 保留上游懒加载 config 插件实现,删除邮箱插件相关 `load_email_config()`,仅保留用户安全与登录配置加载 |
### API 层
| 文件 | 修改内容 |
|----------------------------------------------|-------------------------------------------------------------------------------------------------|
| `backend/app/admin/api/v1/sys/user.py` | 删除 `get_user_roles` 路由、`dept` 参数、`update_user_email` 的 captcha 参数;`delete_user` 改用 `DependsSuperUser`;响应类型改为 `GetUserInfoDetail` |
| `backend/app/admin/api/v1/sys/file.py` | `RequestPermission + DependsRBAC` 改为 `DependsJwtAuth` |
| `backend/app/admin/api/v1/sys/__init__.py` | 仅保留 `user_router``file_router``plugin_router` |
| `backend/app/admin/api/v1/auth/auth.py` | 删除 `get_codes` 路由、`background_tasks` 参数 |
| `backend/app/admin/api/router.py` | 删除 `monitor_router``log_router` |
| `backend/app/router.py` | 删除 `task_v1` |
| `backend/app/admin/api/v1/sys/plugin.py` | `RequestPermission + DependsRBAC` 改为 `DependsSuperUser` |
| `backend/plugin/config/api/v1/sys/config.py` | `RequestPermission + DependsRBAC` 改为 `DependsSuperUser` |
### JWT/中间件层
| 文件 | 修改内容 |
|----------------------------------------------|------------------------------------------------------------------------------------------------------------------|
| `backend/common/security/jwt.py` | `GetUserInfoWithRelationDetail``GetUserInfoDetail``get_current_user()` 改用 `user_dao.get()`,删除 dept/role 状态检查;保留上游 `jwt_authentication_verify``TOKEN_REQUEST_UNDERLYING_SECURITY` |
| `backend/middleware/jwt_auth_middleware.py` | `GetUserInfoWithRelationDetail``GetUserInfoDetail` |
| `backend/middleware/opera_log_middleware.py` | 移除 DB 队列/消费者/入库,改为纯控制台日志输出;异常码读取需包含 `__request_authentication_exception__` |
| `backend/middleware/access_middleware.py` | 删除 Prometheus 导入和 2 处计数器调用 |
### 核心层
| 文件 | 修改内容 |
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `backend/core/registrar.py` | 删除 socketio/prometheus/otel 导入、`register_socket_app()``register_metrics()`、OtelTraceIdPlugin、`create_task(OperaLogMiddleware.consumer())`;保留 lifespan `try/finally` 与上游 `dispose_database()` |
| `backend/core/conf.py` | 删除 CELERY/GRAFANA/DATA_PERMISSION/OAUTH2/EMAIL/WS/CODE_GENERATOR/OPERA_LOG_*/RBAC_ROLE_MENU_*/CACHE_DICT_* 配置段;保留 `DATABASE_SOURCES``TOKEN_REQUEST_UNDERLYING_SECURITY``PLUGIN_REQUIRED` 仅保留 `config` |
| `backend/database/db.py` | 删除 SQLAlchemy 连接池 Prometheus 指标监听 |
| `backend/alembic/env.py` | 保持上游 `get_database_url()` 命名 |
| `backend/main.py` | 保留上游插件准备流程 `_prepare_plugins()`,检查必需插件并安装缺失插件依赖 |
| `backend/plugin/core.py` | `get_required_plugins()` 删除 RBAC 模式相关 `casbin_rbac` 逻辑 |
| `backend/plugin/hooks.py` | 保留上游插件 setup/lifespan hooks,删除 OpenTelemetry hook |
### CLI
| 文件 | 修改内容 |
|------------------|----------------------------------------------------------------------------------|
| `backend/cli.py` | 删除 Celery/代码生成相关命令,保留插件安装卸载与依赖同步;`FbaCli.subcmd``Init \| Run \| Add \| Remove \| Deps \| Format \| Alembic` |
### Enum
| 文件 | 修改内容 |
|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| `backend/common/enums.py` | 删除 `MenuType``MethodType``BuildTreeType``RoleDataRuleOperatorType``RoleDataRuleExpressionType``LoginLogStatusType``OperaLogCipherType` 枚举;保留上游 `PluginLevelType.capability` |
| `backend/plugin/config/enums.py` | 仅保留 `ConfigType.user_security``ConfigType.login` |
### SQL 初始化数据
| 文件 | 修改内容 |
|-------------------------------------------------------|-----------------------------------------------------------------------------------|
| `backend/sql/mysql/init_test_data.sql` | 仅保留 sys_user INSERT(删除 dept/menu/role/role_menu/user_role/data_scope/data_rule 等) |
| `backend/sql/mysql/init_snowflake_test_data.sql` | 同上 |
| `backend/sql/postgresql/init_test_data.sql` | 同上 |
| `backend/sql/postgresql/init_snowflake_test_data.sql` | 同上 |
| `backend/plugin/config/sql/**/init*.sql` | 删除 `sys_menu` 初始化与 EMAIL 配置,仅保留 `sys_config` 中用户安全/登录配置 |
| `backend/plugin/config/sql/**/destroy*.sql` | 删除 `sys_menu` 清理语句,仅保留 `sys_config` 清理 |
### 配置/部署
| 文件 | 修改内容 |
|---------------------------------------------|--------------------------------------------------------------------------------------|
| `backend/.env.example` | 删除 Celery/RabbitMQ/OAuth2/Email 环境变量;保留 `DATABASE_SOURCES` |
| `pyproject.toml` | 项目名改为 `fba-slim`;删除 celery/socketio/opentelemetry/prometheus/psutil/flower/gevent/aio-pika/jinja2/psycopg/pymysql 等依赖;保留 `dulwich`(插件 Git 安装仍依赖) |
| `README.md` / `README.zh-CN.md` | 使用 slim 专用说明,并指向完整版仓库 |
| `docker-compose.yml` | 删除 rabbitmq/celery/grafana 全套容器 |
| `Dockerfile` | 简化为单一 server 镜像,删除 celery worker/beat/flower 阶段;保留插件依赖预安装步骤,用于打包随镜像发布的插件依赖 |
| `deploy/backend/docker-compose/.env.docker` | 删除 RabbitMQ/Celery/Grafana 端口映射 |
| `deploy/backend/docker-compose/.env.server` | 删除 Celery/OAuth2/Email 环境变量 |
| `deploy/backend/nginx.conf` | 删除 Flower 代理配置 |
---
## 插件依赖处理约定
- `Dockerfile` 必须保留 `# Preinstall plugin dependencies`,用于镜像构建时为随包插件安装依赖;当前随包插件仅有 `config`
- `backend/main.py` 必须保留上游 `_prepare_plugins()`,服务启动时会检查必需插件并安装缺失插件依赖
- 后续通过插件安装接口安装 zip/git 插件时,仍由 `backend/plugin/requirements.py` 安装该插件依赖
---
## 合并指南
从 fba 完整版同步代码到 fba-slim 后,需要关注以下冲突区域:
### 快速检测 rg 模式
合并后运行以下命令,快速找出需要处理的非 slim 引用:
```bash
# 全量残留扫描:唯一可接受命中是 OperaLogMiddleware 导入(控制台日志保留)
rg -n "celery|CELERY_|app\.task|python-socketio|socketio|prometheus|opentelemetry|psutil|flower|gevent|aio-pika|rabbitmq|GRAFANA_|OAUTH2_|EMAIL_CAPTCHA|CACHE_DICT|RBAC_ROLE_MENU|DATA_PERMISSION|RequestPermission|DependsRBAC|GetUserInfoWithRelation|GetCurrentUserInfoWithRelation|sys_menu|sys_role|sys_dept|data_scope|data_rule|login_log|opera_log|casbin_rbac|OtelTraceIdPlugin|init_plugin_otel|AddOAuth2UserParam" backend pyproject.toml Dockerfile docker-compose.yml deploy -g '!**/__pycache__/**'
# 精确 DB 日志 / RBAC / 数据权限残留扫描:应无命中
rg -n "opera_log_service|login_log_service|OPERA_LOG_|batch_dequeue|opera_log_queue|LoginLog|DataRule|DataScope|RequestPermission|DependsRBAC|GetUserInfoWithRelationDetail|CACHE_DICT_REDIS_PREFIX|EMAIL_CAPTCHA_REDIS_PREFIX" backend pyproject.toml Dockerfile docker-compose.yml deploy -g '!**/__pycache__/**'
# 已移除依赖残留扫描:应无命中
rg -n "celery|celery-aio|opentelemetry|prometheus-client|psutil|python-socketio|flower|gevent|aio-pika|psycopg|pymysql" pyproject.toml
# 内置插件校验:输出必须只有 config
find backend/plugin -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' -exec basename {} \; | sort
test "$(find backend/plugin -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' -exec basename {} \; | sort)" = config
```
> 注:slim 版仍保留 `backend/middleware/opera_log_middleware.py` 的 `OperaLogMiddleware`,用于控制台访问日志;检测 DB 日志残留时不要把该中间件类名视为问题。
### 高冲突文件
以下文件在完整版更新时最容易产生冲突:
1. **`backend/core/conf.py`** — 配置字段差异最大
2. **`backend/core/registrar.py`** — 中间件和组件注册差异
3. **`backend/cli.py`** — 删除 Celery/代码生成命令,保留插件 `Add`/`Remove`/`Deps`
4. **`backend/main.py`** — 上游插件准备流程必须保留
5. **`backend/common/security/jwt.py`** — `GetUserInfoDetail` vs `GetUserInfoWithRelationDetail``get_current_user()` 差异
6. **`backend/app/admin/crud/crud_user.py`** — 无 get_join/JoinConfig/m2m 操作
7. **`backend/app/admin/service/auth_service.py`** — 登录日志、menu_dao、background_tasks 差异
8. **`backend/middleware/opera_log_middleware.py`** — 完整版有 DB 队列,slim 版仅控制台
9. **`backend/middleware/jwt_auth_middleware.py`** — schema 类型差异
10. **`backend/plugin/config/sql/**`** — config 插件 SQL 不能再引用已删除的 `sys_menu`
11. **`pyproject.toml`** — 依赖列表差异
12. **`docker-compose.yml`** — 容器编排差异
13. **`Dockerfile`** — 构建阶段差异,需保留插件依赖预安装步骤但删除 Celery 多镜像阶段
14. **`deploy/backend/nginx.conf`** — Flower 代理容易残留
### 合并策略
1. **以上游新版本为基准**,只按本文档删除标准瘦身项,不要直接用旧 slim 文件覆盖上游正常演进
2. **需要手动合并**的文件:CRUD/Service/API 层(可能有新增功能需要保留,但需移除 RBAC/数据权限/可观测性引用)
3. **直接接受完整版**的文件:不涉及上述移除功能的纯业务逻辑改动
4. **合并后运行上述 `rg` 命令**清理残留引用
5. **插件依赖安装保持上游行为**`backend/main.py` 启动时检测并安装缺失插件依赖,Dockerfile 构建时预装随包插件依赖
6. **运行 `fba format` 和导入检查**确认格式、锁文件、导出依赖和导入无误
7. **插件 CLI 必须保留**:插件核心系统完整保留,因此 `fba add` / `fba remove` / `fba deps` 与插件安装 API 一并保留;不要再删除插件安装卸载命令
### 验证清单
瘦身完成后至少运行:
```bash
fba format
uv run python -c "from backend.main import app; print(app.title); print(sorted(app.openapi()['paths']))"
```
> FastAPI 0.141+ 将 `include_router` 存为嵌套 `_IncludedRouter``len(app.routes)` 不再等于接口数量;请用 OpenAPI paths 核对。精简版应只有 auth/user/file/plugin/config,不应出现 role/menu/dept/log/monitor/task 或 AI 插件路由。
-16
View File
@@ -16,19 +16,3 @@ REDIS_PASSWORD=''
REDIS_DATABASE=0 REDIS_DATABASE=0
# Token # Token
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# [ App ] task
# Celery
CELERY_BROKER_REDIS_DATABASE=1
# Rabbitmq
CELERY_RABBITMQ_HOST='127.0.0.1'
CELERY_RABBITMQ_PORT=5672
CELERY_RABBITMQ_USERNAME='guest'
CELERY_RABBITMQ_PASSWORD='guest'
# [ Plugin ] oauth2
OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_GOOGLE_CLIENT_ID='test'
OAUTH2_GOOGLE_CLIENT_SECRET='test'
# [ Plugin ] email
EMAIL_USERNAME=''
EMAIL_PASSWORD=''
-4
View File
@@ -1,8 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.auth import router as auth_router from backend.app.admin.api.v1.auth import router as auth_router
from backend.app.admin.api.v1.log import router as log_router
from backend.app.admin.api.v1.monitor import router as monitor_router
from backend.app.admin.api.v1.sys import router as sys_router from backend.app.admin.api.v1.sys import router as sys_router
from backend.core.conf import settings from backend.core.conf import settings
@@ -10,5 +8,3 @@ v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
v1.include_router(auth_router) v1.include_router(auth_router)
v1.include_router(sys_router) v1.include_router(sys_router)
v1.include_router(log_router)
v1.include_router(monitor_router)
+1 -10
View File
@@ -3,13 +3,11 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from fastapi.security import HTTPBasicCredentials from fastapi.security import HTTPBasicCredentials
from pyrate_limiter import Duration, Rate from pyrate_limiter import Duration, Rate
from starlette.background import BackgroundTasks
from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken
from backend.app.admin.schema.user import AuthLoginParam from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.auth_service import auth_service from backend.app.admin.service.auth_service import auth_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.utils.limiter import RateLimiter from backend.utils.limiter import RateLimiter
@@ -34,18 +32,11 @@ async def login(
db: CurrentSessionTransaction, db: CurrentSessionTransaction,
response: Response, response: Response,
obj: AuthLoginParam, obj: AuthLoginParam,
background_tasks: BackgroundTasks,
) -> ResponseSchemaModel[GetLoginToken]: ) -> ResponseSchemaModel[GetLoginToken]:
data = await auth_service.login(db=db, response=response, obj=obj, background_tasks=background_tasks) data = await auth_service.login(db=db, response=response, obj=obj)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/codes', summary='获取所有授权码', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
async def get_codes(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[str]]:
codes = await auth_service.get_codes(db=db, request=request)
return response_base.success(data=codes)
@router.post('/refresh', summary='刷新 token') @router.post('/refresh', summary='刷新 token')
async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]: async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(db=db, request=request, response=response) data = await auth_service.refresh_token(db=db, request=request, response=response)
-9
View File
@@ -1,9 +0,0 @@
from fastapi import APIRouter
from backend.app.admin.api.v1.log.login_log import router as login_log
from backend.app.admin.api.v1.log.opera_log import router as opera_log
router = APIRouter(prefix='/logs')
router.include_router(login_log, prefix='/login', tags=['登录日志'])
router.include_router(opera_log, prefix='/opera', tags=['操作日志'])
-61
View File
@@ -1,61 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.login_log import DeleteLoginLogParam, GetLoginLogDetail
from backend.app.admin.service.login_log_service import login_log_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get(
'',
summary='分页获取登录日志',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_login_logs_paginated(
db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
page_data = await login_log_service.get_list(db=db, username=username, status=status, ip=ip)
return response_base.success(data=page_data)
@router.delete(
'',
summary='批量删除登录日志',
dependencies=[
Depends(RequestPermission('log:login:del')),
DependsRBAC,
],
)
async def delete_login_logs(db: CurrentSessionTransaction, obj: DeleteLoginLogParam) -> ResponseModel:
count = await login_log_service.delete(db=db, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/all',
summary='清空登录日志',
dependencies=[
Depends(RequestPermission('log:login:clear')),
DependsRBAC,
],
)
async def delete_all_login_logs(db: CurrentSessionTransaction) -> ResponseModel:
await login_log_service.delete_all(db=db)
return response_base.success()
-61
View File
@@ -1,61 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.opera_log import DeleteOperaLogParam, GetOperaLogDetail
from backend.app.admin.service.opera_log_service import opera_log_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get(
'',
summary='分页获取操作日志',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_opera_logs_paginated(
db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
page_data = await opera_log_service.get_list(db=db, username=username, status=status, ip=ip)
return response_base.success(data=page_data)
@router.delete(
'',
summary='批量删除操作日志',
dependencies=[
Depends(RequestPermission('log:opera:del')),
DependsRBAC,
],
)
async def delete_opera_logs(db: CurrentSessionTransaction, obj: DeleteOperaLogParam) -> ResponseModel:
count = await opera_log_service.delete(db=db, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/all',
summary='清空操作日志',
dependencies=[
Depends(RequestPermission('log:opera:clear')),
DependsRBAC,
],
)
async def delete_all_opera_logs(db: CurrentSessionTransaction) -> ResponseModel:
await opera_log_service.delete_all(db=db)
return response_base.success()
@@ -1,11 +0,0 @@
from fastapi import APIRouter
from backend.app.admin.api.v1.monitor.online import router as token_router
from backend.app.admin.api.v1.monitor.redis import router as redis_router
from backend.app.admin.api.v1.monitor.server import router as server_router
router = APIRouter(prefix='/monitors')
router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
router.include_router(server_router, prefix='/server', tags=['服务器监控'])
router.include_router(token_router, prefix='/sessions', tags=['会话监控'])
@@ -1,93 +0,0 @@
import json
from typing import Annotated
from fastapi import APIRouter, Path, Query
from backend.app.admin.schema.token import GetTokenDetail
from backend.common.enums import StatusType
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsSuperUser, jwt_decode, revoke_token
from backend.core.conf import settings
from backend.database.redis import redis_client
router = APIRouter()
@router.get('', summary='获取在线用户', dependencies=[DependsSuperUser])
async def get_sessions(
username: Annotated[str | None, Query(description='用户名')] = None,
) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.get_by_prefix(settings.TOKEN_REDIS_PREFIX)
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
data: list[GetTokenDetail] = []
if not token_keys:
return response_base.success(data=data)
def append_token_detail() -> None:
data.append(
token_detail.model_copy(
update={
'username': extra_info.get('username', '未知'),
'nickname': extra_info.get('nickname', '未知'),
'ip': extra_info.get('ip', '未知'),
'os': extra_info.get('os', '未知'),
'browser': extra_info.get('browser', '未知'),
'device': extra_info.get('device', '未知'),
'last_login_time': extra_info.get('last_login_time', '未知'),
},
),
)
token_values = await redis_client.mget(*token_keys)
token_details: list[GetTokenDetail] = []
extra_info_keys: list[str] = []
for token in token_values:
if not token:
continue
token_payload = jwt_decode(token)
user_id = token_payload.user_id
session_uuid = token_payload.session_uuid
token_detail = GetTokenDetail(
id=user_id,
session_uuid=session_uuid,
username='未知',
nickname='未知',
ip='未知',
os='未知',
browser='未知',
device='未知',
status=StatusType.enable if session_uuid in online_clients else StatusType.disable,
last_login_time='未知',
expire_time=token_payload.expire_time,
)
token_details.append(token_detail)
extra_info_keys.append(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
extra_infos = await redis_client.mget(*extra_info_keys) if extra_info_keys else []
for token_detail, extra_info in zip(token_details, extra_infos, strict=True):
if extra_info:
extra_info = json.loads(extra_info)
# 排除 swagger 登录生成的 token
if extra_info.get('swagger') is None:
if username is not None:
if username == extra_info.get('username'):
append_token_detail()
else:
append_token_detail()
else:
data.append(token_detail)
return response_base.success(data=data)
@router.delete(
'/{pk}',
summary='强制下线',
dependencies=[DependsSuperUser],
)
async def delete_session(
pk: Annotated[int, Path(description='用户 ID')],
session_uuid: Annotated[str, Query(description='会话 UUID')],
) -> ResponseModel:
await revoke_token(pk, session_uuid)
return response_base.success()
-42
View File
@@ -1,42 +0,0 @@
from fastapi import APIRouter
from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.database.redis import redis_client
from backend.utils.format import fmt_seconds
router = APIRouter()
@router.get('', summary='Redis 监控', dependencies=[DependsJwtAuth])
async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
info = await redis_client.info()
db_size = await redis_client.dbsize()
server_info = RedisServerInfo(
redis_version=str(info.get('redis_version', '')),
redis_mode=str(info.get('redis_mode', '')),
role=str(info.get('role', '')),
tcp_port=str(info.get('tcp_port', '')),
uptime=str(fmt_seconds(int(info.get('uptime_in_seconds', 0)))),
connected_clients=str(info.get('connected_clients', '')),
blocked_clients=str(info.get('blocked_clients', '')),
used_memory_human=str(info.get('used_memory_human', '')),
used_memory_rss_human=str(info.get('used_memory_rss_human', '')),
maxmemory_human=str(info.get('maxmemory_human', '0B')),
mem_fragmentation_ratio=str(info.get('mem_fragmentation_ratio', '0')),
instantaneous_ops_per_sec=str(info.get('instantaneous_ops_per_sec', '')),
total_commands_processed=str(info.get('total_commands_processed', '')),
rejected_connections=str(info.get('rejected_connections', '')),
keys_num=str(db_size),
)
command_stats = await redis_client.info('commandstats')
stats_list = []
for key, value in command_stats.items():
if isinstance(value, dict):
stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0'))))
data = RedisMonitorInfo(info=server_info, stats=stats_list)
return response_base.success(data=data)
-134
View File
@@ -1,134 +0,0 @@
import os
import platform
import socket
import sys
from datetime import datetime
from datetime import timezone as tz
import psutil
from fastapi import APIRouter
from starlette.concurrency import run_in_threadpool
from backend.app.admin.schema.monitor import (
CpuInfo,
DiskInfo,
MemInfo,
ServerMonitorInfo,
ServiceInfo,
SysInfo,
)
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsSuperUser
from backend.utils.format import fmt_bytes, fmt_seconds
from backend.utils.timezone import timezone
router = APIRouter()
@router.get('', summary='Server 监控', dependencies=[DependsSuperUser])
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # ruff:ignore[complex-structure]
def get_all_info() -> ServerMonitorInfo: # ruff:ignore[complex-structure]
# CPU 信息
cpu_data = {
'physical_num': psutil.cpu_count(logical=False) or 0,
'logical_num': psutil.cpu_count(logical=True) or 0,
'max_freq': 0.0,
'min_freq': 0.0,
'current_freq': 0.0,
'usage': round(psutil.cpu_percent(interval=0.1), 2),
}
try:
if hasattr(psutil, 'cpu_freq'):
cpu_freq = psutil.cpu_freq()
if cpu_freq:
cpu_data.update({
'max_freq': round(cpu_freq.max, 2),
'min_freq': round(cpu_freq.min, 2),
'current_freq': round(cpu_freq.current, 2),
})
except Exception:
pass
cpu = CpuInfo(**cpu_data)
# 内存信息
mem = psutil.virtual_memory()
gb_factor = 1024**3
mem_info = MemInfo(
total=round(mem.total / gb_factor, 2),
used=round(mem.used / gb_factor, 2),
free=round(mem.available / gb_factor, 2),
usage=round(mem.percent, 2),
)
# 系统信息
hostname = socket.gethostname()
ip = '127.0.0.1'
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(0.5)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except (TimeoutError, socket.gaierror, OSError):
pass
sys_info = SysInfo(name=hostname, os=platform.system(), ip=ip, arch=platform.machine())
# 磁盘信息
disk_list = []
exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'}
seen_devices = set()
for partition in psutil.disk_partitions(all=False):
# 跳过虚拟文件系统
if partition.fstype.lower() in exclude_fstypes:
continue
# 跳过重复设备(同一设备的不同挂载点)
if partition.device in seen_devices:
continue
try:
usage = psutil.disk_usage(partition.mountpoint)
if usage:
seen_devices.add(partition.device)
disk_list.append(
DiskInfo(
dir=partition.mountpoint,
device=partition.device,
type=partition.fstype,
total=fmt_bytes(usage.total),
used=fmt_bytes(usage.used),
free=fmt_bytes(usage.free),
usage=f'{usage.percent:.2f}%',
)
)
except (PermissionError, OSError):
continue
# 服务信息
process = psutil.Process(os.getpid())
proc_mem = process.memory_info()
try:
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
start_time = timezone.from_datetime(create_time)
except (psutil.NoSuchProcess, OSError):
start_time = timezone.now()
elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds()))
service = ServiceInfo(
name='Python3',
version=platform.python_version(),
home=sys.executable,
startup=timezone.to_str(start_time),
elapsed=elapsed,
cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%',
mem_vms=fmt_bytes(proc_mem.vms),
mem_rss=fmt_bytes(proc_mem.rss),
mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss),
)
return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service)
data = await run_in_threadpool(get_all_info)
return response_base.success(data=data)
-10
View File
@@ -1,21 +1,11 @@
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router
from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router
from backend.app.admin.api.v1.sys.dept import router as dept_router
from backend.app.admin.api.v1.sys.file import router as file_router from backend.app.admin.api.v1.sys.file import router as file_router
from backend.app.admin.api.v1.sys.menu import router as menu_router
from backend.app.admin.api.v1.sys.plugin import router as plugin_router from backend.app.admin.api.v1.sys.plugin import router as plugin_router
from backend.app.admin.api.v1.sys.role import router as role_router
from backend.app.admin.api.v1.sys.user import router as user_router from backend.app.admin.api.v1.sys.user import router as user_router
router = APIRouter(prefix='/sys') router = APIRouter(prefix='/sys')
router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
router.include_router(role_router, prefix='/roles', tags=['系统角色'])
router.include_router(user_router, prefix='/users', tags=['系统用户']) router.include_router(user_router, prefix='/users', tags=['系统用户'])
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则'])
router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
router.include_router(file_router, prefix='/files', tags=['系统文件']) router.include_router(file_router, prefix='/files', tags=['系统文件'])
router.include_router(plugin_router, prefix='/plugins', tags=['系统插件']) router.include_router(plugin_router, prefix='/plugins', tags=['系统插件'])
-119
View File
@@ -1,119 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail,
GetDataRuleDetail,
GetDataRuleTemplateVariableDetail,
UpdateDataRuleParam,
)
from backend.app.admin.service.data_rule_service import data_rule_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/models', summary='获取数据规则可用模型', dependencies=[DependsJwtAuth])
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
models = await data_rule_service.get_models()
return response_base.success(data=models)
@router.get('/models/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')],
) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
models = await data_rule_service.get_columns(model=model)
return response_base.success(data=models)
@router.get('/value-template-variables', summary='获取数据规则值可用模板变量', dependencies=[DependsJwtAuth])
async def get_data_rule_value_template_variables() -> ResponseSchemaModel[list[GetDataRuleTemplateVariableDetail]]:
variables = await data_rule_service.get_value_template_variables()
return response_base.success(data=variables)
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
async def get_all_data_rules(db: CurrentSession) -> ResponseSchemaModel[list[GetDataRuleDetail]]:
data = await data_rule_service.get_all(db=db)
return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
async def get_data_rule(
db: CurrentSession,
pk: Annotated[int, Path(description='数据规则 ID')],
) -> ResponseSchemaModel[GetDataRuleDetail]:
data = await data_rule_service.get(db=db, pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有数据规则',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_data_rules_paginated(
db: CurrentSession,
name: Annotated[str | None, Query(description='规则名称')] = None,
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
page_data = await data_rule_service.get_list(db=db, name=name)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建数据规则',
dependencies=[
Depends(RequestPermission('data:rule:add')),
DependsRBAC,
],
)
async def create_data_rule(db: CurrentSessionTransaction, obj: CreateDataRuleParam) -> ResponseModel:
await data_rule_service.create(db=db, obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新数据规则',
dependencies=[
Depends(RequestPermission('data:rule:edit')),
DependsRBAC,
],
)
async def update_data_rule(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='数据规则 ID')],
obj: UpdateDataRuleParam,
) -> ResponseModel:
count = await data_rule_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除数据规则',
dependencies=[
Depends(RequestPermission('data:rule:del')),
DependsRBAC,
],
)
async def delete_data_rules(db: CurrentSessionTransaction, obj: DeleteDataRuleParam) -> ResponseModel:
count = await data_rule_service.delete(db=db, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
-128
View File
@@ -1,128 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
DeleteDataScopeParam,
GetDataScopeDetail,
GetDataScopeWithRelationDetail,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.service.data_scope_service import data_scope_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/all', summary='获取所有数据范围', dependencies=[DependsJwtAuth])
async def get_all_data_scope(db: CurrentSession) -> ResponseSchemaModel[list[GetDataScopeDetail]]:
data = await data_scope_service.get_all(db=db)
return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
async def get_data_scope(
db: CurrentSession,
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeDetail]:
data = await data_scope_service.get(db=db, pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
async def get_data_scope_rules(
db: CurrentSession,
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
data = await data_scope_service.get_rules(db=db, pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有数据范围',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_data_scopes_paginated(
db: CurrentSession,
name: Annotated[str | None, Query(description='范围名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
page_data = await data_scope_service.get_list(db=db, name=name, status=status)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建数据范围',
dependencies=[
Depends(RequestPermission('data:scope:add')),
DependsRBAC,
],
)
async def create_data_scope(db: CurrentSessionTransaction, obj: CreateDataScopeParam) -> ResponseModel:
await data_scope_service.create(db=db, obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新数据范围',
dependencies=[
Depends(RequestPermission('data:scope:edit')),
DependsRBAC,
],
)
async def update_data_scope(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='数据范围 ID')],
obj: UpdateDataScopeParam,
) -> ResponseModel:
count = await data_scope_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/rules',
summary='更新数据范围规则',
dependencies=[
Depends(RequestPermission('data:scope:rule:edit')),
DependsRBAC,
],
)
async def update_data_scope_rules(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='数据范围 ID')],
rule_ids: UpdateDataScopeRuleParam,
) -> ResponseModel:
count = await data_scope_service.update_data_scope_rule(db=db, pk=pk, rule_ids=rule_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除数据范围',
dependencies=[
Depends(RequestPermission('data:scope:del')),
DependsRBAC,
],
)
async def delete_data_scopes(db: CurrentSessionTransaction, obj: DeleteDataScopeParam) -> ResponseModel:
count = await data_scope_service.delete(db=db, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
-83
View File
@@ -1,83 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import ColumnElement
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import DataPermissionFilter, RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
async def get_dept(
db: CurrentSession, pk: Annotated[int, Path(description='部门 ID')]
) -> ResponseSchemaModel[GetDeptDetail]:
data = await dept_service.get(db=db, pk=pk)
return response_base.success(data=data)
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
async def get_dept_tree(
db: CurrentSession,
data_filter: Annotated[ColumnElement[bool], Depends(DataPermissionFilter(Dept))],
name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_tree(
db=db, data_filter=data_filter, name=name, leader=leader, phone=phone, status=status
)
return response_base.success(data=dept)
@router.post(
'',
summary='创建部门',
dependencies=[
Depends(RequestPermission('sys:dept:add')),
DependsRBAC,
],
)
async def create_dept(db: CurrentSessionTransaction, obj: CreateDeptParam) -> ResponseModel:
await dept_service.create(db=db, obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新部门',
dependencies=[
Depends(RequestPermission('sys:dept:edit')),
DependsRBAC,
],
)
async def update_dept(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam
) -> ResponseModel:
count = await dept_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/{pk}',
summary='删除部门',
dependencies=[
Depends(RequestPermission('sys:dept:del')),
DependsRBAC,
],
)
async def delete_dept(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
count = await dept_service.delete(db=db, pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
+3 -7
View File
@@ -1,11 +1,10 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, File, UploadFile from fastapi import APIRouter, File, UploadFile
from backend.common.dataclasses import UploadUrl from backend.common.dataclasses import UploadUrl
from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.permission import RequestPermission from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.rbac import DependsRBAC
from backend.utils.file_ops import upload_file, upload_file_verify from backend.utils.file_ops import upload_file, upload_file_verify
router = APIRouter() router = APIRouter()
@@ -14,10 +13,7 @@ router = APIRouter()
@router.post( @router.post(
'/upload', '/upload',
summary='本地文件上传', summary='本地文件上传',
dependencies=[ dependencies=[DependsJwtAuth],
Depends(RequestPermission('sys:file:upload')),
DependsRBAC,
],
) )
async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]: async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
upload_file_verify(file) upload_file_verify(file)
-82
View File
@@ -1,82 +0,0 @@
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, GetMenuTree, UpdateMenuParam
from backend.app.admin.service.menu_service import menu_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='已适配 vben admin v5', dependencies=[DependsJwtAuth])
async def get_user_sidebar(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
menu = await menu_service.get_sidebar(db=db, request=request)
return response_base.success(data=menu)
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
async def get_menu(
db: CurrentSession, pk: Annotated[int, Path(description='菜单 ID')]
) -> ResponseSchemaModel[GetMenuDetail]:
data = await menu_service.get(db=db, pk=pk)
return response_base.success(data=data)
@router.get('', summary='获取菜单树', dependencies=[DependsJwtAuth])
async def get_menu_tree(
db: CurrentSession,
title: Annotated[str | None, Query(description='菜单标题')] = None,
status: Annotated[int | None, Query(description='状体')] = None,
) -> ResponseSchemaModel[list[GetMenuTree]]:
menu = await menu_service.get_tree(db=db, title=title, status=status)
return response_base.success(data=menu)
@router.post(
'',
summary='创建菜单',
dependencies=[
Depends(RequestPermission('sys:menu:add')),
DependsRBAC,
],
)
async def create_menu(db: CurrentSessionTransaction, obj: CreateMenuParam) -> ResponseModel:
await menu_service.create(db=db, obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新菜单',
dependencies=[
Depends(RequestPermission('sys:menu:edit')),
DependsRBAC,
],
)
async def update_menu(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam
) -> ResponseModel:
count = await menu_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/{pk}',
summary='删除菜单',
dependencies=[
Depends(RequestPermission('sys:menu:del')),
DependsRBAC,
],
)
async def delete_menu(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseModel:
count = await menu_service.delete(db=db, pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
-154
View File
@@ -1,154 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.menu import GetMenuTree
from backend.app.admin.schema.role import (
CreateRoleParam,
DeleteRoleParam,
GetRoleDetail,
GetRoleWithRelationDetail,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.app.admin.service.role_service import role_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
async def get_all_roles(db: CurrentSession) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_all(db=db)
return response_base.success(data=data)
@router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
async def get_role_menu_tree(
db: CurrentSession,
pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[GetMenuTree] | None]:
menu = await role_service.get_menu_tree(db=db, pk=pk)
return response_base.success(data=menu)
@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
async def get_role_scopes(
db: CurrentSession, pk: Annotated[int, Path(description='角色 ID')]
) -> ResponseSchemaModel[list[int]]:
rule = await role_service.get_scopes(db=db, pk=pk)
return response_base.success(data=rule)
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
async def get_role(
db: CurrentSession, pk: Annotated[int, Path(description='角色 ID')]
) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
data = await role_service.get(db=db, pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有角色',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_roles_paginated(
db: CurrentSession,
name: Annotated[str | None, Query(description='角色名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
page_data = await role_service.get_list(db=db, name=name, status=status)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建角色',
dependencies=[
Depends(RequestPermission('sys:role:add')),
DependsRBAC,
],
)
async def create_role(db: CurrentSessionTransaction, obj: CreateRoleParam) -> ResponseModel:
await role_service.create(db=db, obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新角色',
dependencies=[
Depends(RequestPermission('sys:role:edit')),
DependsRBAC,
],
)
async def update_role(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam
) -> ResponseModel:
count = await role_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/menus',
summary='更新角色菜单',
dependencies=[
Depends(RequestPermission('sys:role:menu:edit')),
DependsRBAC,
],
)
async def update_role_menus(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='角色 ID')],
menu_ids: UpdateRoleMenuParam,
) -> ResponseModel:
count = await role_service.update_role_menu(db=db, pk=pk, menu_ids=menu_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/scopes',
summary='更新角色数据范围',
dependencies=[
Depends(RequestPermission('sys:role:scope:edit')),
DependsRBAC,
],
)
async def update_role_scopes(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='角色 ID')],
scope_ids: UpdateRoleScopeParam,
) -> ResponseModel:
count = await role_service.update_role_scope(db=db, pk=pk, scope_ids=scope_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除角色',
dependencies=[
Depends(RequestPermission('sys:role:del')),
DependsRBAC,
],
)
async def delete_roles(db: CurrentSessionTransaction, obj: DeleteRoleParam) -> ResponseModel:
count = await role_service.delete(db=db, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
+9 -28
View File
@@ -1,12 +1,10 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Request from fastapi import APIRouter, Body, Path, Query, Request
from backend.app.admin.schema.role import GetRoleDetail
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
GetCurrentUserInfoWithRelationDetail, GetUserInfoDetail,
GetUserInfoWithRelationDetail,
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
) )
@@ -15,15 +13,13 @@ from backend.common.enums import UserPermissionType
from backend.common.pagination import DependsPagination, PageData from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth]) @router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]: async def get_current_user(request: Request) -> ResponseSchemaModel[GetUserInfoDetail]:
data = request.user.model_dump() data = request.user.model_dump()
return response_base.success(data=data) return response_base.success(data=data)
@@ -32,19 +28,11 @@ async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUs
async def get_userinfo( async def get_userinfo(
db: CurrentSession, db: CurrentSession,
pk: Annotated[int, Path(description='用户 ID')], pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]: ) -> ResponseSchemaModel[GetUserInfoDetail]:
data = await user_service.get_userinfo(db=db, pk=pk) data = await user_service.get_userinfo(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/roles', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
async def get_user_roles(
db: CurrentSession, pk: Annotated[int, Path(description='用户 ID')]
) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await user_service.get_roles(db=db, pk=pk)
return response_base.success(data=data)
@router.get( @router.get(
'', '',
summary='分页获取所有用户', summary='分页获取所有用户',
@@ -55,19 +43,16 @@ async def get_user_roles(
) )
async def get_users_paginated( async def get_users_paginated(
db: CurrentSession, db: CurrentSession,
dept: Annotated[int | None, Query(description='部门 ID')] = None,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
phone: Annotated[str | None, Query(description='手机号')] = None, phone: Annotated[str | None, Query(description='手机号')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]: ) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]:
page_data = await user_service.get_list(db=db, dept=dept, username=username, phone=phone, status=status) page_data = await user_service.get_list(db=db, username=username, phone=phone, status=status)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@router.post('', summary='创建用户', dependencies=[DependsSuperUser]) @router.post('', summary='创建用户', dependencies=[DependsSuperUser])
async def create_user( async def create_user(db: CurrentSessionTransaction, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoDetail]:
db: CurrentSessionTransaction, obj: AddUserParam
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.create(db=db, obj=obj) await user_service.create(db=db, obj=obj)
data = await user_service.get_userinfo(db=db, username=obj.username) data = await user_service.get_userinfo(db=db, username=obj.username)
return response_base.success(data=data) return response_base.success(data=data)
@@ -148,10 +133,9 @@ async def update_user_avatar(
async def update_user_email( async def update_user_email(
db: CurrentSessionTransaction, db: CurrentSessionTransaction,
request: Request, request: Request,
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
email: Annotated[str, Body(embed=True, description='用户邮箱')], email: Annotated[str, Body(embed=True, description='用户邮箱')],
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email) count = await user_service.update_email(db=db, user_id=request.user.id, email=email)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -160,10 +144,7 @@ async def update_user_email(
@router.delete( @router.delete(
path='/{pk}', path='/{pk}',
summary='删除用户', summary='删除用户',
dependencies=[ dependencies=[DependsSuperUser],
Depends(RequestPermission('sys:user:del')),
DependsRBAC,
],
) )
async def delete_user(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def delete_user(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.delete(db=db, pk=pk) count = await user_service.delete(db=db, pk=pk)
-110
View File
@@ -1,110 +0,0 @@
from collections.abc import Sequence
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule
from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam
from backend.utils.timezone import timezone
class CRUDDataRule(CRUDPlus[DataRule]):
"""数据规则数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
"""
获取规则详情
:param db: 数据库会话
:param pk: 规则 ID
:return:
"""
return await self.select_model(db, pk, deleted=0)
async def get_select(self, name: str | None) -> Select:
"""
获取规则列表查询表达式
:param name: 规则名称
:return:
"""
filters = {'deleted': 0}
if name is not None:
filters['name__like'] = f'%{name}%'
return await self.select_order('id', **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
"""
通过名称获取规则
:param db: 数据库会话
:param name: 规则名称
:return:
"""
return await self.select_model_by_column(db, name=name, deleted=0)
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
"""
获取所有规则
:param db: 数据库会话
:return:
"""
return await self.select_models(db, deleted=0)
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[DataRule]:
"""
通过 ID 列表批量获取数据规则
:param db: 数据库会话
:param pks: 规则 ID 列表
:return:
"""
return await self.select_models(db, id__in=pks, deleted=0)
async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
"""
创建规则
:param db: 数据库会话
:param obj: 创建规则参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
"""
更新规则
:param db: 数据库会话
:param pk: 规则 ID
:param obj: 更新规则参数
:return:
"""
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除规则
:param db: 数据库会话
:param pks: 规则 ID 列表
:return:
"""
return await self.delete_model_by_column(
db,
allow_multiple=True,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id__in=pks,
deleted=0,
)
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
-167
View File
@@ -1,167 +0,0 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, and_, delete, insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataRule, DataScope, data_scope_rule
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
CreateDataScopeRuleParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
class CRUDDataScope(CRUDPlus[DataScope]):
"""数据范围数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> DataScope | None:
"""
获取数据范围详情
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
return await self.select_model(db, pk, deleted=0)
async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None:
"""
通过名称获取数据范围
:param db: 数据库会话
:param name: 范围名称
:return:
"""
return await self.select_model_by_column(db, name=name, deleted=0)
async def get_join(self, db: AsyncSession, pk: int) -> Any:
"""
获取数据范围关联数据
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
result = await self.select_models(
db,
id=pk,
deleted=0,
join_conditions=[
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == self.model.id),
JoinConfig(
model=DataRule,
join_on=and_(DataRule.id == data_scope_rule.c.data_rule_id, DataRule.deleted == 0),
fill_result=True,
),
],
)
return select_join_serialize(result, relationships=['DataScope-m2m-DataRule:rules'])
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
"""
获取所有数据范围
:param db: 数据库会话
:return:
"""
return await self.select_models(db, deleted=0)
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[DataScope]:
"""
通过 ID 列表批量获取数据范围
:param db: 数据库会话
:param pks: 范围 ID 列表
:return:
"""
return await self.select_models(db, id__in=pks, deleted=0)
async def get_select(self, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表查询表达式
:param name: 范围名称
:param status: 范围状态
:return:
"""
filters = {'deleted': 0}
if name is not None:
filters['name__like'] = f'%{name}%'
if status is not None:
filters['status'] = status
return await self.select_order('id', **filters)
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
"""
创建数据范围
:param db: 数据库会话
:param obj: 创建数据范围参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
"""
更新数据范围
:param db: 数据库会话
:param pk: 范围 ID
:param obj: 更新数据范围参数
:return:
"""
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
@staticmethod
async def update_rules(db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:param rule_ids: 数据规则 ID 列表
:return:
"""
data_scope_rule_stmt = delete(data_scope_rule).where(data_scope_rule.c.data_scope_id == pk)
await db.execute(data_scope_rule_stmt)
if rule_ids.rules:
data_scope_rule_data = [
CreateDataScopeRuleParam(data_scope_id=pk, data_rule_id=rule_id).model_dump()
for rule_id in rule_ids.rules
]
data_scope_rule_stmt = insert(data_scope_rule)
await db.execute(data_scope_rule_stmt, data_scope_rule_data)
return len(rule_ids.rules)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除数据范围
:param db: 数据库会话
:param pks: 范围 ID 列表
:return:
"""
return await self.delete_model_by_column(
db,
allow_multiple=True,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id__in=pks,
deleted=0,
)
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
-143
View File
@@ -1,143 +0,0 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import ColumnElement, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import Dept, User
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
class CRUDDept(CRUDPlus[Dept]):
"""部门数据库操作类"""
async def get(self, db: AsyncSession, dept_id: int) -> Dept | None:
"""
获取部门详情
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.select_model_by_column(db, id=dept_id, deleted=0)
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
"""
通过名称获取部门
:param db: 数据库会话
:param name: 部门名称
:return:
"""
return await self.select_model_by_column(db, name=name, deleted=0)
async def get_all(
self,
db: AsyncSession,
data_filter: ColumnElement[bool],
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> Sequence[Dept]:
"""
获取所有部门
:param db: 数据库会话
:param data_filter: 请求用户
:param name: 部门名称
:param leader: 负责人
:param phone: 联系电话
:param status: 部门状态
:return:
"""
filters = {'deleted': 0}
if name is not None:
filters['name__like'] = f'%{name}%'
if leader is not None:
filters['leader__like'] = f'%{leader}%'
if phone is not None:
filters['phone__startswith'] = phone
if status is not None:
filters['status'] = status
return await self.select_models_order(db, 'sort', 'asc', data_filter, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
"""
创建部门
:param db: 数据库会话
:param obj: 创建部门参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, dept_id: int, obj: UpdateDeptParam) -> int:
"""
更新部门
:param db: 数据库会话
:param dept_id: 部门 ID
:param obj: 更新部门参数
:return:
"""
return await self.update_model_by_column(db, obj, id=dept_id, deleted=0)
async def delete(self, db: AsyncSession, dept_id: int) -> int:
"""
删除部门
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.delete_model_by_column(
db,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id=dept_id,
deleted=0,
)
async def get_join(self, db: AsyncSession, dept_id: int) -> Any | None:
"""
获取部门及关联数据
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
result = await self.select_model(
db,
dept_id,
deleted=0,
join_conditions=[
JoinConfig(
model=User,
join_on=and_(User.dept_id == self.model.id, User.deleted == 0),
fill_result=True,
)
],
)
return select_join_serialize(result, relationships=['Dept-o2m-User'])
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
"""
获取子部门列表
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.select_models(db, parent_id=dept_id, deleted=0)
dept_dao: CRUDDept = CRUDDept(Dept)
-64
View File
@@ -1,64 +0,0 @@
from sqlalchemy import Select
from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import LoginLog
from backend.app.admin.schema.login_log import CreateLoginLogParam
class CRUDLoginLog(CRUDPlus[LoginLog]):
"""登录日志数据库操作类"""
async def get_select(self, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取登录日志列表查询表达式
:param username: 用户名
:param status: 登录状态
:param ip: IP 地址
:return:
"""
filters = {}
if username is not None:
filters['username__like'] = f'%{username}%'
if status is not None:
filters['status'] = status
if ip is not None:
filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
"""
创建登录日志
:param db: 数据库会话
:param obj: 创建登录日志参数
:return:
"""
await self.create_model(db, obj, commit=True)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除登录日志
:param db: 数据库会话
:param pks: 登录日志 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
@staticmethod
async def delete_all(db: AsyncSession) -> None:
"""
删除所有日志
:param db: 数据库会话
:return:
"""
await db.execute(sa_delete(LoginLog))
login_log_dao: CRUDLoginLog = CRUDLoginLog(LoginLog)
-132
View File
@@ -1,132 +0,0 @@
from collections.abc import Sequence
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Menu, role_menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
from backend.utils.timezone import timezone
class CRUDMenu(CRUDPlus[Menu]):
"""菜单数据库操作类"""
async def get(self, db: AsyncSession, menu_id: int) -> Menu | None:
"""
获取菜单详情
:param db: 数据库会话
:param menu_id: 菜单 ID
:return:
"""
return await self.select_model(db, menu_id, deleted=0)
async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
"""
通过标题获取菜单
:param db: 数据库会话
:param title: 菜单标题
:return:
"""
return await self.select_model_by_column(db, title=title, type__ne=2, deleted=0)
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
"""
获取菜单列表
:param db: 数据库会话
:param title: 菜单标题
:param status: 菜单状态
:return:
"""
filters = {'deleted': 0}
if title is not None:
filters['title__like'] = f'%{title}%'
if status is not None:
filters['status'] = status
return await self.select_models_order(db, 'sort', 'asc', **filters)
async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
"""
获取用户的菜单侧边栏
:param db: 数据库会话
:param menu_ids: 菜单 ID 列表
:return:
"""
filters = {'type__in': [0, 1, 3, 4], 'deleted': 0}
if menu_ids:
filters['id__in'] = menu_ids
return await self.select_models_order(db, 'sort', 'asc', **filters)
async def get_all_by_ids(self, db: AsyncSession, menu_ids: list[int]) -> Sequence[Menu]:
"""
通过 ID 列表批量获取菜单
:param db: 数据库会话
:param menu_ids: 菜单 ID 列表
:return:
"""
return await self.select_models(db, id__in=menu_ids, deleted=0)
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
"""
创建菜单
:param db: 数据库会话
:param obj: 创建菜单参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, menu_id: int, obj: UpdateMenuParam) -> int:
"""
更新菜单
:param db: 数据库会话
:param menu_id: 菜单 ID
:param obj: 更新菜单参数
:return:
"""
return await self.update_model_by_column(db, obj, id=menu_id, deleted=0)
async def delete(self, db: AsyncSession, menu_id: int) -> int:
"""
删除菜单
:param db: 数据库会话
:param menu_id: 菜单 ID
:return:
"""
role_menu_stmt = delete(role_menu).where(role_menu.c.menu_id == menu_id)
await db.execute(role_menu_stmt)
return await self.delete_model_by_column(
db,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id=menu_id,
deleted=0,
)
async def get_children(self, db: AsyncSession, menu_id: int) -> Sequence[Menu | None]:
"""
获取子菜单列表
:param db: 数据库会话
:param menu_id: 菜单 ID
:return:
"""
return await self.select_models(db, parent_id=menu_id, deleted=0)
menu_dao: CRUDMenu = CRUDMenu(Menu)
-74
View File
@@ -1,74 +0,0 @@
from sqlalchemy import Select
from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import OperaLog
from backend.app.admin.schema.opera_log import CreateOperaLogParam
class CRUDOperaLogDao(CRUDPlus[OperaLog]):
"""操作日志数据库操作类"""
async def get_select(self, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取操作日志列表查询表达式
:param username: 用户名
:param status: 操作状态
:param ip: IP 地址
:return:
"""
filters = {}
if username is not None:
filters['username__like'] = f'%{username}%'
if status is not None:
filters['status__eq'] = status
if ip is not None:
filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
"""
创建操作日志
:param db: 数据库会话
:param obj: 操作日志创建参数
:return:
"""
await self.create_model(db, obj)
async def bulk_create(self, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
"""
批量创建操作日志
:param db: 数据库会话
:param objs: 操作日志创建参数列表
:return:
"""
await self.create_models(db, objs)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除操作日志
:param db: 数据库会话
:param pks: 操作日志 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
@staticmethod
async def delete_all(db: AsyncSession) -> None:
"""
删除所有日志
:param db: 数据库会话
:return:
"""
await db.execute(sa_delete(OperaLog))
opera_log_dao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog)
-215
View File
@@ -1,215 +0,0 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, and_, delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataScope, Menu, Role, role_data_scope, role_menu
from backend.app.admin.schema.role import (
CreateRoleMenuParam,
CreateRoleParam,
CreateRoleScopeParam,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
class CRUDRole(CRUDPlus[Role]):
"""角色数据库操作类"""
async def get(self, db: AsyncSession, role_id: int) -> Role | None:
"""
获取角色详情
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
return await self.select_model(db, role_id, deleted=0)
@staticmethod
async def get_menus(db: AsyncSession, role_id: int) -> Sequence[Menu] | None:
"""
获取角色菜单
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
menu_stmt = (
select(Menu)
.join(role_menu, Menu.id == role_menu.c.menu_id)
.where(role_menu.c.role_id == role_id, Menu.deleted == 0)
)
result = await db.execute(menu_stmt)
return result.scalars().all()
async def get_join(self, db: AsyncSession, role_id: int) -> Any:
"""
获取角色及关联数据
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
result = await self.select_models(
db,
id=role_id,
deleted=0,
join_conditions=[
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == self.model.id),
JoinConfig(
model=Menu,
join_on=and_(Menu.id == role_menu.c.menu_id, Menu.deleted == 0),
fill_result=True,
),
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == self.model.id),
JoinConfig(
model=DataScope,
join_on=and_(DataScope.id == role_data_scope.c.data_scope_id, DataScope.deleted == 0),
fill_result=True,
),
],
)
return select_join_serialize(result, relationships=['Role-m2m-Menu', 'Role-m2m-DataScope:scopes'])
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
"""
获取所有角色
:param db: 数据库会话
:return:
"""
return await self.select_models(db, deleted=0)
async def get_all_by_ids(self, db: AsyncSession, role_ids: list[int]) -> Sequence[Role]:
"""
通过 ID 列表批量获取角色
:param db: 数据库会话
:param role_ids: 角色 ID 列表
:return:
"""
return await self.select_models(db, id__in=role_ids, deleted=0)
async def get_select(self, name: str | None, status: int | None) -> Select:
"""
获取角色列表查询表达式
:param name: 角色名称
:param status: 角色状态
:return:
"""
filters = {'deleted': 0}
if name is not None:
filters['name__like'] = f'%{name}%'
if status is not None:
filters['status'] = status
return await self.select_order('id', **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
"""
通过名称获取角色
:param db: 数据库会话
:param name: 角色名称
:return:
"""
return await self.select_model_by_column(db, name=name, deleted=0)
async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
"""
创建角色
:param db: 数据库会话
:param obj: 创建角色参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, role_id: int, obj: UpdateRoleParam) -> int:
"""
更新角色
:param db: 数据库会话
:param role_id: 角色 ID
:param obj: 更新角色参数
:return:
"""
return await self.update_model_by_column(db, obj, id=role_id, deleted=0)
@staticmethod
async def update_menus(db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
"""
更新角色菜单
:param db: 数据库会话
:param role_id: 角色 ID
:param menu_ids: 菜单 ID 列表
:return:
"""
role_menu_stmt = delete(role_menu).where(role_menu.c.role_id == role_id)
await db.execute(role_menu_stmt)
if menu_ids.menus:
role_menu_data = [
CreateRoleMenuParam(role_id=role_id, menu_id=menu_id).model_dump() for menu_id in menu_ids.menus
]
role_menu_stmt = insert(role_menu)
await db.execute(role_menu_stmt, role_menu_data)
return len(menu_ids.menus)
@staticmethod
async def update_scopes(db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
"""
更新角色数据范围
:param db: 数据库会话
:param role_id: 角色 ID
:param scope_ids: 权限范围 ID 列表
:return:
"""
role_scope_stmt = delete(role_data_scope).where(role_data_scope.c.role_id == role_id)
await db.execute(role_scope_stmt)
if scope_ids.scopes:
role_scope_data = [
CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump()
for scope_id in scope_ids.scopes
]
role_scope_stmt = insert(role_data_scope)
await db.execute(role_scope_stmt, role_scope_data)
return len(scope_ids.scopes)
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
"""
批量删除角色
:param db: 数据库会话
:param role_ids: 角色 ID 列表
:return:
"""
return await self.delete_model_by_column(
db,
allow_multiple=True,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id__in=role_ids,
deleted=0,
)
role_dao: CRUDRole = CRUDRole(Role)
+7 -176
View File
@@ -1,35 +1,14 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
import bcrypt import bcrypt
from sqlalchemy import Select, and_, delete, insert, select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import ( from backend.app.admin.model import User
DataRule, from backend.app.admin.schema.user import AddUserParam, UpdateUserParam
DataScope,
Dept,
Menu,
Role,
User,
data_scope_rule,
role_data_scope,
role_menu,
user_role,
)
from backend.app.admin.schema.user import (
AddOAuth2UserParam,
AddUserParam,
AddUserRoleParam,
UpdateUserParam,
)
from backend.app.admin.utils.password_security import get_hash_password from backend.app.admin.utils.password_security import get_hash_password
from backend.common.enums import StatusType
from backend.common.exception import errors
from backend.plugin.core import check_plugin_installed
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -86,11 +65,10 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.select_model_by_column(db, email=email, deleted=0) return await self.select_model_by_column(db, email=email, deleted=0)
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select: async def get_select(self, username: str | None, phone: str | None, status: int | None) -> Select:
""" """
获取用户列表查询表达式 获取用户列表查询表达式
:param dept: 部门 ID
:param username: 用户名 :param username: 用户名
:param phone: 电话号码 :param phone: 电话号码
:param status: 用户状态 :param status: 用户状态
@@ -98,8 +76,6 @@ class CRUDUser(CRUDPlus[User]):
""" """
filters = {'deleted': 0} filters = {'deleted': 0}
if dept:
filters['dept_id'] = dept
if username: if username:
filters['username__like'] = f'%{username}%' filters['username__like'] = f'%{username}%'
if phone: if phone:
@@ -107,24 +83,7 @@ class CRUDUser(CRUDPlus[User]):
if status is not None: if status is not None:
filters['status'] = status filters['status'] = status
return await self.select_order( return await self.select_order('id', 'desc', **filters)
'id',
'desc',
join_conditions=[
JoinConfig(
model=Dept,
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
fill_result=True,
),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(
model=Role,
join_on=and_(Role.id == user_role.c.role_id, Role.deleted == 0),
fill_result=True,
),
],
**filters,
)
async def add(self, db: AsyncSession, obj: AddUserParam) -> None: async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
""" """
@@ -137,44 +96,12 @@ class CRUDUser(CRUDPlus[User]):
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
obj.password = get_hash_password(obj.password, salt) obj.password = get_hash_password(obj.password, salt)
dict_obj = obj.model_dump(exclude={'roles'}) dict_obj = obj.model_dump()
dict_obj.update({'salt': salt}) dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj) new_user = self.model(**dict_obj)
db.add(new_user) db.add(new_user)
await db.flush() await db.flush()
if obj.roles:
role_stmt = select(Role).where(Role.id.in_(obj.roles), Role.deleted == 0)
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_data = [AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
"""
通过 OAuth2 添加用户
:param db: 数据库会话
:param obj: 注册用户参数
:return:
"""
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj)
db.add(new_user)
await db.flush()
role_stmt = select(Role).where(Role.status == StatusType.enable, Role.deleted == 0)
result = await db.execute(role_stmt)
role = result.scalars().first() # 默认绑定第一个角色
if role is None:
raise errors.NotFoundError(msg='未找到可用角色,请联系系统管理员')
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
await db.execute(user_role_stmt)
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int: async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
""" """
更新用户信息 更新用户信息
@@ -184,23 +111,7 @@ class CRUDUser(CRUDPlus[User]):
:param obj: 更新用户参数 :param obj: 更新用户参数
:return: :return:
""" """
role_ids = obj.roles
del obj.roles
count = await self.update_model_by_column(db, obj, id=user_id, deleted=0) count = await self.update_model_by_column(db, obj, id=user_id, deleted=0)
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
if role_ids:
role_stmt = select(Role).where(Role.id.in_(role_ids), Role.deleted == 0)
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_data = [AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
return count return count
async def update_login_time(self, db: AsyncSession, username: str) -> int: async def update_login_time(self, db: AsyncSession, username: str) -> int:
@@ -323,17 +234,6 @@ class CRUDUser(CRUDPlus[User]):
:param user_id: 用户 ID :param user_id: 用户 ID
:return: :return:
""" """
if check_plugin_installed('oauth2'):
try:
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
await user_social_dao.delete_by_user_id(db, user_id)
except ImportError:
raise errors.ServerError(msg='OAuth2 插件用法导入失败,请联系系统管理员')
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
return await self.delete_model_by_column( return await self.delete_model_by_column(
db, db,
logical_deletion=True, logical_deletion=True,
@@ -345,74 +245,5 @@ class CRUDUser(CRUDPlus[User]):
deleted=0, deleted=0,
) )
async def get_join(
self,
db: AsyncSession,
*,
user_id: int | None = None,
username: str | None = None,
) -> Any | None:
"""
获取用户关联信息
:param db: 数据库会话
:param user_id: 用户 ID
:param username: 用户名
:return:
"""
filters = {'deleted': 0}
if user_id:
filters['id'] = user_id
if username:
filters['username'] = username
result = await self.select_models(
db,
join_conditions=[
JoinConfig(
model=Dept,
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
fill_result=True,
),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(
model=Role,
join_on=and_(Role.id == user_role.c.role_id, Role.deleted == 0),
fill_result=True,
),
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == Role.id),
JoinConfig(
model=Menu,
join_on=and_(Menu.id == role_menu.c.menu_id, Menu.deleted == 0),
fill_result=True,
),
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == Role.id),
JoinConfig(
model=DataScope,
join_on=and_(DataScope.id == role_data_scope.c.data_scope_id, DataScope.deleted == 0),
fill_result=True,
),
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == DataScope.id),
JoinConfig(
model=DataRule,
join_on=and_(DataRule.id == data_scope_rule.c.data_rule_id, DataRule.deleted == 0),
fill_result=True,
),
],
**filters,
)
return select_join_serialize(
result,
relationships=[
'User-m2o-Dept',
'User-m2m-Role',
'Role-m2m-Menu',
'Role-m2m-DataScope:scopes',
'DataScope-m2m-DataRule:rules',
],
)
user_dao: CRUDUser = CRUDUser(User) user_dao: CRUDUser = CRUDUser(User)
-11
View File
@@ -1,13 +1,2 @@
from backend.app.admin.model.data_rule import DataRule as DataRule
from backend.app.admin.model.data_scope import DataScope as DataScope
from backend.app.admin.model.dept import Dept as Dept
from backend.app.admin.model.login_log import LoginLog as LoginLog
from backend.app.admin.model.m2m import data_scope_rule as data_scope_rule
from backend.app.admin.model.m2m import role_data_scope as role_data_scope
from backend.app.admin.model.m2m import role_menu as role_menu
from backend.app.admin.model.m2m import user_role as user_role
from backend.app.admin.model.menu import Menu as Menu
from backend.app.admin.model.opera_log import OperaLog as OperaLog
from backend.app.admin.model.role import Role as Role
from backend.app.admin.model.user import User as User from backend.app.admin.model.user import User as User
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
-25
View File
@@ -1,25 +0,0 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
class DataRule(Base):
"""数据规则表"""
__tablename__ = 'sys_data_rule'
__table_args__ = (
sa.UniqueConstraint('name', 'deleted', name='uk_sys_data_rule_name_deleted'),
{'comment': '数据规则表'},
)
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(sa.String(512), comment='名称')
model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in',
)
value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
-19
View File
@@ -1,19 +0,0 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
class DataScope(Base):
"""数据范围表"""
__tablename__ = 'sys_data_scope'
__table_args__ = (
sa.UniqueConstraint('name', 'deleted', name='uk_sys_data_scope_name_deleted'),
{'comment': '数据范围表'},
)
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(sa.String(64), comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
-26
View File
@@ -1,26 +0,0 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
class Dept(Base):
"""部门表"""
__tablename__ = 'sys_dept'
__table_args__ = (
sa.UniqueConstraint('name', 'deleted', name='uk_sys_dept_name_deleted'),
{'comment': '部门表'},
)
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(sa.String(64), comment='部门名称')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
leader: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='负责人')
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
email: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='邮箱')
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
# 父级部门
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
-35
View File
@@ -1,35 +0,0 @@
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone
class LoginLog(DataClassBase):
"""登录日志表"""
__tablename__ = 'sys_login_log'
id: Mapped[id_key] = mapped_column(init=False)
user_uuid: Mapped[str] = mapped_column(sa.String(64), comment='用户UUID')
username: Mapped[str] = mapped_column(sa.String(64), comment='用户名')
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
ip: Mapped[str] = mapped_column(sa.String(64), comment='登录IP地址')
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
msg: Mapped[str] = mapped_column(UniversalText, comment='提示消息')
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
created_time: Mapped[datetime] = mapped_column(
TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
)
-39
View File
@@ -1,39 +0,0 @@
import sqlalchemy as sa
from backend.common.model import MappedBase
# 用户角色表
user_role = sa.Table(
'sys_user_role',
MappedBase.metadata,
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
)
# 角色菜单表
role_menu = sa.Table(
'sys_role_menu',
MappedBase.metadata,
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
sa.Column('menu_id', sa.BigInteger, primary_key=True, comment='菜单ID'),
)
# 角色数据范围表
role_data_scope = sa.Table(
'sys_role_data_scope',
MappedBase.metadata,
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
)
# 数据范围规则表
data_scope_rule = sa.Table(
'sys_data_scope_rule',
MappedBase.metadata,
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 ID'),
)
-29
View File
@@ -1,29 +0,0 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, UniversalText, id_key
class Menu(Base):
"""菜单表"""
__tablename__ = 'sys_menu'
id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(sa.String(64), comment='菜单标题')
name: Mapped[str] = mapped_column(sa.String(64), comment='菜单名称')
path: Mapped[str | None] = mapped_column(sa.String(200), comment='路由地址')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
icon: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='菜单图标')
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='权限标识')
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='外链地址')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
# 父级菜单
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父菜单ID')
-38
View File
@@ -1,38 +0,0 @@
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone
class OperaLog(DataClassBase):
"""操作日志表"""
__tablename__ = 'sys_opera_log'
id: Mapped[id_key] = mapped_column(init=False)
trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
method: Mapped[str] = mapped_column(sa.String(32), comment='请求方法')
title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP 地址')
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='用户代理')
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='请求参数')
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
code: Mapped[str] = mapped_column(sa.String(32), insert_default='200', comment='操作状态码')
msg: Mapped[str | None] = mapped_column(UniversalText, comment='提示消息')
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms')
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
created_time: Mapped[datetime] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
)
-21
View File
@@ -1,21 +0,0 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, UniversalText, id_key
class Role(Base):
"""角色表"""
__tablename__ = 'sys_role'
__table_args__ = (
sa.UniqueConstraint('name', 'deleted', name='uk_sys_role_name_deleted'),
{'comment': '角色表'},
)
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(sa.String(32), comment='角色名称')
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
-3
View File
@@ -39,6 +39,3 @@ class User(Base):
last_password_changed_time: Mapped[datetime | None] = mapped_column( last_password_changed_time: Mapped[datetime | None] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间' TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
) )
# 逻辑外键
dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='部门关联ID')
-55
View File
@@ -1,55 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.schema import SchemaBase
class DataRuleSchemaBase(SchemaBase):
"""数据规则基础模型"""
name: str = Field(description='规则名称')
model: str = Field(description='模型名称')
column: str = Field(description='字段名称')
operator: RoleDataRuleOperatorType = Field(description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(description='表达式类型')
value: str = Field(description='规则值')
class CreateDataRuleParam(DataRuleSchemaBase):
"""创建数据规则参数"""
class UpdateDataRuleParam(DataRuleSchemaBase):
"""更新数据规则参数"""
class DeleteDataRuleParam(SchemaBase):
"""删除数据规则参数"""
pks: list[int] = Field(description='规则 ID 列表')
class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情"""
model_config = ConfigDict(from_attributes=True, frozen=True)
id: int = Field(description='规则 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDataRuleColumnDetail(SchemaBase):
"""数据规则可用模型字段详情"""
key: str = Field(description='字段名')
comment: str | None = Field(description='字段评论')
class GetDataRuleTemplateVariableDetail(SchemaBase):
"""数据规则可用模板变量详情"""
key: str = Field(description='变量标识')
comment: str = Field(description='变量描述')
-57
View File
@@ -1,57 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_rule import GetDataRuleDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DataScopeBase(SchemaBase):
"""数据范围基础模型"""
name: str = Field(description='名称')
status: StatusType = Field(description='状态')
class CreateDataScopeParam(DataScopeBase):
"""创建数据范围参数"""
class UpdateDataScopeParam(DataScopeBase):
"""更新数据范围参数"""
class CreateDataScopeRuleParam(SchemaBase):
"""创建数据范围规则参数"""
data_scope_id: int = Field(description='数据范围 ID')
data_rule_id: int = Field(description='数据规则 ID')
class UpdateDataScopeRuleParam(SchemaBase):
"""更新数据范围规则参数"""
rules: list[int] = Field(description='数据规则 ID 列表')
class DeleteDataScopeParam(SchemaBase):
"""删除数据范围参数"""
pks: list[int] = Field(description='数据范围 ID 列表')
class GetDataScopeDetail(DataScopeBase):
"""数据范围详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='数据范围 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情"""
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
-44
View File
@@ -1,44 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import StatusType
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
class DeptSchemaBase(SchemaBase):
"""部门基础模型"""
name: str = Field(description='部门名称')
parent_id: int | None = Field(None, description='部门父级 ID')
sort: int = Field(0, ge=0, description='排序')
leader: str | None = Field(None, description='负责人')
phone: CustomPhoneNumber | None = Field(None, description='联系电话')
email: CustomEmailStr | None = Field(None, description='邮箱')
status: StatusType = Field(description='状态')
class CreateDeptParam(DeptSchemaBase):
"""创建部门参数"""
class UpdateDeptParam(DeptSchemaBase):
"""更新部门参数"""
class GetDeptDetail(DeptSchemaBase):
"""部门详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='部门 ID')
deleted: int = Field(description='是否已删除(0:否;id:是)')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
deleted_time: datetime | None = Field(None, description='删除时间')
class GetDeptTree(GetDeptDetail):
"""获取部门树"""
children: list['GetDeptTree'] | None = Field(None, description='子菜单')
-46
View File
@@ -1,46 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.schema import SchemaBase
class LoginLogSchemaBase(SchemaBase):
"""登录日志基础模型"""
user_uuid: str = Field(description='用户 UUID')
username: str = Field(description='用户名')
status: int = Field(description='登录状态')
ip: str = Field(description='IP 地址')
country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市')
user_agent: str | None = Field(description='用户代理')
browser: str | None = Field(None, description='浏览器')
os: str | None = Field(None, description='操作系统')
device: str | None = Field(None, description='设备')
msg: str = Field(description='消息')
login_time: datetime = Field(description='登录时间')
class CreateLoginLogParam(LoginLogSchemaBase):
"""创建登录日志参数"""
class UpdateLoginLogParam(LoginLogSchemaBase):
"""更新登录日志参数"""
class DeleteLoginLogParam(SchemaBase):
"""删除登录日志参数"""
pks: list[int] = Field(description='登录日志 ID 列表')
class GetLoginLogDetail(LoginLogSchemaBase):
"""登录日志详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='日志 ID')
created_time: datetime = Field(description='创建时间')
-49
View File
@@ -1,49 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import MenuType, StatusType
from backend.common.schema import SchemaBase
class MenuSchemaBase(SchemaBase):
"""菜单基础模型"""
title: str = Field(description='菜单标题')
name: str = Field(description='菜单名称')
path: str | None = Field(None, description='路由地址')
parent_id: int | None = Field(None, description='菜单父级 ID')
sort: int = Field(0, ge=0, description='排序')
icon: str | None = Field(None, description='图标')
type: MenuType = Field(description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: str | None = Field(None, description='组件路径')
perms: str | None = Field(None, description='权限标识')
status: StatusType = Field(description='状态')
display: StatusType = Field(description='是否显示')
cache: StatusType = Field(description='是否缓存')
link: str | None = Field(None, description='外链地址')
remark: str | None = Field(None, description='备注')
class CreateMenuParam(MenuSchemaBase):
"""创建菜单参数"""
class UpdateMenuParam(MenuSchemaBase):
"""更新菜单参数"""
class GetMenuDetail(MenuSchemaBase):
"""菜单详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='菜单 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetMenuTree(GetMenuDetail):
"""获取菜单树"""
children: list['GetMenuTree'] | None = Field(None, description='子菜单')
-102
View File
@@ -1,102 +0,0 @@
from pydantic import Field
from backend.common.schema import SchemaBase
class CpuInfo(SchemaBase):
"""CPU 信息"""
physical_num: int = Field(description='物理核心数')
logical_num: int = Field(description='逻辑核心数')
max_freq: float = Field(description='最大频率(MHz')
min_freq: float = Field(description='最小频率(MHz')
current_freq: float = Field(description='当前频率(MHz')
usage: float = Field(description='使用率(%')
class MemInfo(SchemaBase):
"""内存信息"""
total: float = Field(description='总容量(GB')
used: float = Field(description='已使用(GB')
free: float = Field(description='可用(GB')
usage: float = Field(description='使用率(%')
class SysInfo(SchemaBase):
"""系统信息"""
name: str = Field(description='主机名')
os: str = Field(description='操作系统')
ip: str = Field(description='IP 地址')
arch: str = Field(description='系统架构')
class DiskInfo(SchemaBase):
"""磁盘信息"""
dir: str = Field(description='挂载点')
device: str = Field(description='设备名称')
type: str = Field(description='文件系统类型')
total: str = Field(description='总容量')
used: str = Field(description='已使用')
free: str = Field(description='可用')
usage: str = Field(description='使用率(%')
class ServiceInfo(SchemaBase):
"""服务进程信息"""
name: str = Field(description='服务名称')
version: str = Field(description='版本')
home: str = Field(description='安装路径')
startup: str = Field(description='启动时间')
elapsed: str = Field(description='运行时长')
cpu_usage: str = Field(description='CPU 使用率')
mem_vms: str = Field(description='虚拟内存')
mem_rss: str = Field(description='物理内存')
mem_free: str = Field(description='可用内存')
class ServerMonitorInfo(SchemaBase):
"""服务器监控信息"""
cpu: CpuInfo = Field(description='CPU 信息')
mem: MemInfo = Field(description='内存信息')
sys: SysInfo = Field(description='系统信息')
disk: list[DiskInfo] = Field(description='磁盘信息')
service: ServiceInfo = Field(description='服务信息')
class RedisServerInfo(SchemaBase):
"""Redis 服务器信息"""
redis_version: str = Field(description='版本号')
redis_mode: str = Field(description='运行模式')
role: str = Field(description='节点角色')
tcp_port: str = Field(description='监听端口')
uptime: str = Field(description='运行时长')
connected_clients: str = Field(description='已连接客户端数')
blocked_clients: str = Field(description='阻塞客户端数')
used_memory_human: str = Field(description='已使用内存')
used_memory_rss_human: str = Field(description='RSS 内存')
maxmemory_human: str = Field(description='最大内存限制')
mem_fragmentation_ratio: str = Field(description='内存碎片率')
instantaneous_ops_per_sec: str = Field(description='每秒操作数')
total_commands_processed: str = Field(description='命令处理总数')
rejected_connections: str = Field(description='拒绝连接数')
keys_num: str = Field(description='键总数')
class RedisCommandStat(SchemaBase):
"""Redis 命令统计"""
name: str = Field(description='命令名称')
value: str = Field(description='调用次数')
class RedisMonitorInfo(SchemaBase):
"""Redis 监控信息"""
info: RedisServerInfo = Field(description='服务器信息')
stats: list[RedisCommandStat] = Field(description='命令统计')
-54
View File
@@ -1,54 +0,0 @@
from datetime import datetime
from typing import Any
from pydantic import ConfigDict, Field
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class OperaLogSchemaBase(SchemaBase):
"""操作日志基础模型"""
trace_id: str = Field(description='追踪 ID')
username: str | None = Field(None, description='用户名')
method: str = Field(description='请求方法')
title: str = Field(description='操作标题')
path: str = Field(description='请求路径')
ip: str = Field(description='IP 地址')
country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市')
user_agent: str | None = Field(description='用户代理')
os: str | None = Field(None, description='操作系统')
browser: str | None = Field(None, description='浏览器')
device: str | None = Field(None, description='设备')
args: dict[str, Any] | None = Field(None, description='请求参数')
status: StatusType = Field(description='状态')
code: str = Field(description='状态码')
msg: str | None = Field(None, description='消息')
cost_time: float = Field(description='耗时')
opera_time: datetime = Field(description='操作时间')
class CreateOperaLogParam(OperaLogSchemaBase):
"""创建操作日志参数"""
class UpdateOperaLogParam(OperaLogSchemaBase):
"""更新操作日志参数"""
class DeleteOperaLogParam(SchemaBase):
"""删除操作日志参数"""
pks: list[int] = Field(description='操作日志 ID 列表')
class GetOperaLogDetail(OperaLogSchemaBase):
"""操作日志详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='日志 ID')
created_time: datetime = Field(description='创建时间')
-74
View File
@@ -1,74 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_scope import GetDataScopeWithRelationDetail
from backend.app.admin.schema.menu import GetMenuDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class RoleSchemaBase(SchemaBase):
"""角色基础模型"""
name: str = Field(description='角色名称')
status: StatusType = Field(description='状态')
is_filter_scopes: bool = Field(True, description='过滤数据权限')
remark: str | None = Field(None, description='备注')
class CreateRoleParam(RoleSchemaBase):
"""创建角色参数"""
class UpdateRoleParam(RoleSchemaBase):
"""更新角色参数"""
class DeleteRoleParam(SchemaBase):
"""删除角色参数"""
pks: list[int] = Field(description='角色 ID 列表')
class CreateRoleMenuParam(SchemaBase):
"""创建角色菜单参数"""
role_id: int = Field(description='角色 ID')
menu_id: int = Field(description='菜单 ID')
class UpdateRoleMenuParam(SchemaBase):
"""更新角色菜单参数"""
menus: list[int] = Field(description='菜单 ID 列表')
class CreateRoleScopeParam(SchemaBase):
"""创建角色数据范围参数"""
role_id: int = Field(description='角色 ID')
data_scope_id: int = Field(description='数据范围 ID')
class UpdateRoleScopeParam(SchemaBase):
"""更新角色数据范围参数"""
scopes: list[int] = Field(description='数据范围 ID 列表')
class GetRoleDetail(RoleSchemaBase):
"""角色详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='角色 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
scopes: list[GetDataScopeWithRelationDetail | None] = Field([], description='数据范围列表')
+2 -57
View File
@@ -1,11 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import Annotated, Any from typing import Annotated
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer, model_validator from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer
from typing_extensions import Self
from backend.app.admin.schema.dept import GetDeptDetail
from backend.app.admin.schema.role import GetRoleWithRelationDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
@@ -30,24 +27,6 @@ class AddUserParam(AuthSchemaBase):
nickname: str | None = Field(None, description='昵称') nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱') email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号码') phone: CustomPhoneNumber | None = Field(None, description='手机号码')
dept_id: int = Field(description='部门 ID')
roles: list[int] = Field(description='角色 ID 列表')
class AddUserRoleParam(SchemaBase):
"""添加用户角色"""
user_id: int = Field(description='用户 ID')
role_id: int = Field(description='角色 ID')
class AddOAuth2UserParam(AuthSchemaBase):
"""添加 OAuth2 用户参数"""
password: str | None = Field(None, description='密码')
nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱')
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
class ResetPasswordParam(SchemaBase): class ResetPasswordParam(SchemaBase):
@@ -61,7 +40,6 @@ class ResetPasswordParam(SchemaBase):
class UserInfoSchemaBase(SchemaBase): class UserInfoSchemaBase(SchemaBase):
"""用户信息基础模型""" """用户信息基础模型"""
dept_id: int | None = Field(None, description='部门 ID')
username: str = Field(description='用户名') username: str = Field(description='用户名')
nickname: str = Field(description='昵称') nickname: str = Field(description='昵称')
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址') avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
@@ -72,15 +50,12 @@ class UserInfoSchemaBase(SchemaBase):
class UpdateUserParam(UserInfoSchemaBase): class UpdateUserParam(UserInfoSchemaBase):
"""更新用户参数""" """更新用户参数"""
roles: list[int] = Field(description='角色 ID 列表')
class GetUserInfoDetail(UserInfoSchemaBase): class GetUserInfoDetail(UserInfoSchemaBase):
"""用户信息详情""" """用户信息详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept_id: int | None = Field(None, description='部门 ID')
id: int = Field(description='用户 ID') id: int = Field(description='用户 ID')
uuid: str = Field(description='用户 UUID') uuid: str = Field(description='用户 UUID')
status: StatusType = Field(description='状态') status: StatusType = Field(description='状态')
@@ -89,33 +64,3 @@ class GetUserInfoDetail(UserInfoSchemaBase):
is_multi_login: bool = Field(description='是否允许多端登录') is_multi_login: bool = Field(description='是否允许多端登录')
join_time: datetime = Field(description='加入时间') join_time: datetime = Field(description='加入时间')
last_login_time: datetime | None = Field(None, description='最后登录时间') last_login_time: datetime | None = Field(None, description='最后登录时间')
class GetUserInfoWithRelationDetail(GetUserInfoDetail):
"""用户信息关联详情"""
model_config = ConfigDict(from_attributes=True)
dept: GetDeptDetail | None = Field(None, description='部门信息')
roles: list[GetRoleWithRelationDetail] = Field(description='角色列表')
class GetCurrentUserInfoWithRelationDetail(GetUserInfoWithRelationDetail):
"""当前用户信息关联详情"""
model_config = ConfigDict(from_attributes=True)
dept: str | None = Field(None, description='部门名称')
roles: list[str] = Field(description='角色名称列表')
@model_validator(mode='before')
@classmethod
def handel(cls, data: Any) -> Self:
"""处理部门和角色数据"""
dept = data['dept']
if dept:
data['dept'] = dept['name']
roles = data['roles']
if roles:
data['roles'] = [role['name'] for role in roles]
return data
+1 -49
View File
@@ -1,18 +1,14 @@
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.security import HTTPBasicCredentials from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTask, BackgroundTasks
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken
from backend.app.admin.schema.user import AuthLoginParam from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.login_log_service import login_log_service
from backend.app.admin.service.user_password_history_service import password_security_service from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify from backend.app.admin.utils.password_security import password_verify
from backend.common.context import ctx from backend.common.context import ctx
from backend.common.enums import LoginLogStatusType, StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.i18n import t from backend.common.i18n import t
from backend.common.log import log from backend.common.log import log
@@ -25,7 +21,6 @@ from backend.common.security.jwt import (
jwt_decode, jwt_decode,
) )
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import uuid4_str
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config from backend.utils.dynamic_config import load_login_config
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -86,7 +81,6 @@ class AuthService:
db: AsyncSession, db: AsyncSession,
response: Response, response: Response,
obj: AuthLoginParam, obj: AuthLoginParam,
background_tasks: BackgroundTasks,
) -> GetLoginToken: ) -> GetLoginToken:
""" """
用户登录 用户登录
@@ -94,7 +88,6 @@ class AuthService:
:param db: 数据库会话 :param db: 数据库会话
:param response: 响应对象 :param response: 响应对象
:param obj: 登录参数 :param obj: 登录参数
:param background_tasks: 后台任务
:return: :return:
""" """
user = None user = None
@@ -143,27 +136,11 @@ class AuthService:
except (errors.RequestError, errors.CustomError) as e: except (errors.RequestError, errors.CustomError) as e:
if not user: if not user:
log.error(f'登陆错误: {e.msg}') log.error(f'登陆错误: {e.msg}')
task = BackgroundTask( raise errors.RequestError(code=e.code, msg=e.msg)
login_log_service.create,
user_uuid=user.uuid if user else uuid4_str(),
username=obj.username,
login_time=timezone.now(),
status=LoginLogStatusType.fail.value,
msg=e.msg,
)
raise errors.RequestError(code=e.code, msg=e.msg, background=task)
except Exception as e: except Exception as e:
log.error(f'登陆错误: {e}') log.error(f'登陆错误: {e}')
raise raise
else: else:
background_tasks.add_task(
login_log_service.create,
user_uuid=user.uuid,
username=obj.username,
login_time=timezone.now(),
status=LoginLogStatusType.success.value,
msg=t('success.login.success'),
)
data = GetLoginToken( data = GetLoginToken(
access_token=access_token_data.access_token, access_token=access_token_data.access_token,
access_token_expire_time=access_token_data.access_token_expire_time, access_token_expire_time=access_token_data.access_token_expire_time,
@@ -173,31 +150,6 @@ class AuthService:
) )
return data return data
@staticmethod
async def get_codes(*, db: AsyncSession, request: Request) -> list[str]:
"""
获取用户权限码
:param db: 数据库会话
:param request: FastAPI 请求对象
:return:
"""
codes = set()
if request.user.is_superuser:
menus = await menu_dao.get_all(db, None, None)
for menu in menus:
if menu.status == StatusType.enable and menu.perms:
codes.update(menu.perms.split(','))
else:
roles = [role for role in request.user.roles if role.status == StatusType.enable]
if roles:
for role in roles:
for menu in role.menus:
if menu.status == StatusType.enable and menu.perms:
codes.update(menu.perms.split(','))
return list(codes)
@staticmethod @staticmethod
async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken: async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken:
""" """
@@ -1,156 +0,0 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Table
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.model import DataRule
from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail,
GetDataRuleTemplateVariableDetail,
UpdateDataRuleParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.common.security.permission import get_data_permission_models
from backend.core.conf import settings
class DataRuleService:
"""数据规则服务类"""
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> DataRule:
"""
获取数据规则详情
:param db: 数据库会话
:param pk: 规则 ID
:return:
"""
data_rule = await data_rule_dao.get(db, pk)
if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在')
return data_rule
@staticmethod
async def get_models() -> list[str]:
"""获取所有数据规则可用模型"""
model_template_variables = [var['key'] for var in settings.DATA_PERMISSION_MODEL_TEMPLATE_VARIABLES]
models = [
m for m in list(get_data_permission_models().keys()) if m not in settings.DATA_PERMISSION_MODEL_EXCLUDE
]
return model_template_variables + models
@staticmethod
async def get_value_template_variables() -> list[GetDataRuleTemplateVariableDetail]:
"""获取所有数据规则值可用模板变量"""
return [GetDataRuleTemplateVariableDetail(**var) for var in settings.DATA_PERMISSION_TEMPLATE_VARIABLES]
@staticmethod
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
"""
获取数据规则可用模型的字段列表
:param model: 模型名称
:return:
"""
column_template_variables = [
GetDataRuleColumnDetail(key=var['key'], comment=var['comment'])
for var in settings.DATA_PERMISSION_COLUMN_TEMPLATE_VARIABLES
]
model_template_variable_keys = {var['key'] for var in settings.DATA_PERMISSION_MODEL_TEMPLATE_VARIABLES}
if model in model_template_variable_keys:
return column_template_variables
available_models = get_data_permission_models()
if model not in available_models:
raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = available_models[model]
table = model_ins if isinstance(model_ins, Table) else model_ins.__table__
model_columns = [
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
for column in table.columns
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
]
return model_columns + column_template_variables
@staticmethod
async def get_list(*, db: AsyncSession, name: str | None) -> dict[str, Any]:
"""
获取数据规则列表
:param db: 数据库会话
:param name: 规则名称
:return:
"""
data_rule_select = await data_rule_dao.get_select(name=name)
return await paging_data(db, data_rule_select)
@staticmethod
async def get_all(*, db: AsyncSession) -> Sequence[DataRule]:
"""
获取所有数据规则
:param db: 数据库会话
:return:
"""
data_rules = await data_rule_dao.get_all(db)
return data_rules
@staticmethod
async def create(*, db: AsyncSession, obj: CreateDataRuleParam) -> None:
"""
创建数据规则
:param db: 数据库会话
:param obj: 规则创建参数
:return:
"""
data_rule = await data_rule_dao.get_by_name(db, obj.name)
if data_rule:
raise errors.ConflictError(msg='数据规则已存在')
await data_rule_dao.create(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
"""
更新数据规则
:param db: 数据库会话
:param pk: 规则 ID
:param obj: 规则更新参数
:return:
"""
data_rule = await data_rule_dao.get(db, pk)
if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在')
if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj)
await user_cache_manager.clear_by_data_rule_id(db, [pk])
return count
@staticmethod
async def delete(*, db: AsyncSession, obj: DeleteDataRuleParam) -> int:
"""
批量删除数据规则
:param db: 数据库会话
:param obj: 规则 ID 列表
:return:
"""
count = await data_rule_dao.delete(db, obj.pks)
await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
return count
data_rule_service: DataRuleService = DataRuleService()
@@ -1,146 +0,0 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.model import DataScope
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
DeleteDataScopeParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
class DataScopeService:
"""数据范围服务类"""
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> DataScope:
"""
获取数据范围详情
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@staticmethod
async def get_all(*, db: AsyncSession) -> Sequence[DataScope]:
"""
获取所有数据范围
:param db: 数据库会话
:return:
"""
data_scopes = await data_scope_dao.get_all(db)
return data_scopes
@staticmethod
async def get_rules(*, db: AsyncSession, pk: int) -> DataScope:
"""
获取数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
data_scope = await data_scope_dao.get_join(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@staticmethod
async def get_list(*, db: AsyncSession, name: str | None, status: int | None) -> dict[str, Any]:
"""
获取数据范围列表
:param db: 数据库会话
:param name: 范围名称
:param status: 范围状态
:return:
"""
data_scope_select = await data_scope_dao.get_select(name, status)
return await paging_data(db, data_scope_select)
@staticmethod
async def create(*, db: AsyncSession, obj: CreateDataScopeParam) -> None:
"""
创建数据范围
:param db: 数据库会话
:param obj: 数据范围参数
:return:
"""
data_scope = await data_scope_dao.get_by_name(db, obj.name)
if data_scope:
raise errors.ConflictError(msg='数据范围已存在')
await data_scope_dao.create(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
"""
更新数据范围
:param db: 数据库会话
:param pk: 范围 ID
:param obj: 数据范围更新参数
:return:
"""
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj)
await user_cache_manager.clear_by_data_scope_id(db, [pk])
return count
@staticmethod
async def update_data_scope_rule(*, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:param rule_ids: 规则 ID 列表
:return:
"""
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
if rule_ids.rules:
rules = await data_rule_dao.get_all_by_ids(db, list(set(rule_ids.rules)))
if {rule.id for rule in rules} != set(rule_ids.rules):
raise errors.NotFoundError(msg='数据规则不存在')
count = await data_scope_dao.update_rules(db, pk, rule_ids)
await user_cache_manager.clear_by_data_scope_id(db, [pk])
return count
@staticmethod
async def delete(*, db: AsyncSession, obj: DeleteDataScopeParam) -> int:
"""
批量删除数据范围
:param db: 数据库会话
:param obj: 范围 ID 列表
:return:
"""
count = await data_scope_dao.delete(db, obj.pks)
await user_cache_manager.clear_by_data_scope_id(db, obj.pks)
return count
data_scope_service: DataScopeService = DataScopeService()
-119
View File
@@ -1,119 +0,0 @@
from typing import Any
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.exception import errors
from backend.utils.build_tree import get_tree_data
class DeptService:
"""部门服务类"""
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> Dept:
"""
获取部门详情
:param db: 数据库会话
:param pk: 部门 ID
:return:
"""
dept = await dept_dao.get(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
return dept
@staticmethod
async def get_tree(
*,
db: AsyncSession,
data_filter: ColumnElement[bool],
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> list[dict[str, Any]]:
"""
获取部门树形结构
:param db: 数据库会话
:param data_filter: 请求用户
:param name: 部门名称
:param leader: 部门负责人
:param phone: 联系电话
:param status: 状态
:return:
"""
dept_select = await dept_dao.get_all(db, data_filter, name, leader, phone, status)
tree_data = get_tree_data(dept_select)
return tree_data
@staticmethod
async def create(*, db: AsyncSession, obj: CreateDeptParam) -> None:
"""
创建部门
:param db: 数据库会话
:param obj: 部门创建参数
:return:
"""
dept = await dept_dao.get_by_name(db, obj.name)
if dept:
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id is not None:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
raise errors.NotFoundError(msg='父级部门不存在')
await dept_dao.create(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateDeptParam) -> int:
"""
更新部门
:param db: 数据库会话
:param pk: 部门 ID
:param obj: 部门更新参数
:return:
"""
dept = await dept_dao.get(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
if dept.name != obj.name and await dept_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
raise errors.NotFoundError(msg='父级部门不存在')
if obj.parent_id == dept.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级')
count = await dept_dao.update(db, pk, obj)
return count
@staticmethod
async def delete(*, db: AsyncSession, pk: int) -> int:
"""
删除部门
:param db: 数据库会话
:param pk: 部门 ID
:return:
"""
dept = await dept_dao.get_join(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
if dept.users:
raise errors.ConflictError(msg='部门下存在用户,无法删除')
children = await dept_dao.get_children(db, pk)
if children:
raise errors.ConflictError(msg='部门下存在子部门,无法删除')
count = await dept_dao.delete(db, pk)
return count
dept_service: DeptService = DeptService()
@@ -1,90 +0,0 @@
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_login_log import login_log_dao
from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginLogParam
from backend.common.context import ctx
from backend.common.log import log
from backend.common.pagination import paging_data
from backend.database.db import async_db_session
class LoginLogService:
"""登录日志服务类"""
@staticmethod
async def get_list(*, db: AsyncSession, username: str | None, status: int | None, ip: str | None) -> dict[str, Any]:
"""
获取登录日志列表
:param db: 数据库会话
:param username: 用户名
:param status: 状态
:param ip: IP 地址
:return:
"""
log_select = await login_log_dao.get_select(username=username, status=status, ip=ip)
return await paging_data(db, log_select)
@staticmethod
async def create(
*,
user_uuid: str,
username: str,
login_time: datetime,
status: int,
msg: str,
) -> None:
"""
创建登录日志
:param user_uuid: 用户 UUID
:param username: 用户名
:param login_time: 登录时间
:param status: 状态
:param msg: 消息
:return:
"""
try:
obj = CreateLoginLogParam(
user_uuid=user_uuid,
username=username,
status=status,
ip=ctx.ip,
country=ctx.country,
region=ctx.region,
city=ctx.city,
user_agent=ctx.user_agent,
browser=ctx.browser,
os=ctx.os,
device=ctx.device,
msg=msg,
login_time=login_time,
)
# 为后台任务创建独立数据库会话
async with async_db_session.begin() as db:
await login_log_dao.create(db, obj)
except Exception as e:
log.error(f'登录日志创建失败: {e}')
@staticmethod
async def delete(*, db: AsyncSession, obj: DeleteLoginLogParam) -> int:
"""
批量删除登录日志
:param db: 数据库会话
:param obj: 日志 ID 列表
:return:
"""
count = await login_log_dao.delete(db, obj.pks)
return count
@staticmethod
async def delete_all(*, db: AsyncSession) -> None:
"""清空所有登录日志"""
await login_log_dao.delete_all(db)
login_log_service: LoginLogService = LoginLogService()
-137
View File
@@ -1,137 +0,0 @@
from typing import Any
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.model import Menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.enums import StatusType
from backend.common.exception import errors
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
class MenuService:
"""菜单服务类"""
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> Menu:
"""
获取菜单详情
:param db: 数据库会话
:param pk: 菜单 ID
:return:
"""
menu = await menu_dao.get(db, menu_id=pk)
if not menu:
raise errors.NotFoundError(msg='菜单不存在')
return menu
@staticmethod
async def get_tree(*, db: AsyncSession, title: str | None, status: int | None) -> list[dict[str, Any]]:
"""
获取菜单树形结构
:param db: 数据库会话
:param title: 菜单标题
:param status: 状态
:return:
"""
menu_data = await menu_dao.get_all(db, title=title, status=status)
menu_tree = get_tree_data(menu_data)
return menu_tree
@staticmethod
async def get_sidebar(*, db: AsyncSession, request: Request) -> list[dict[str, Any] | None]:
"""
获取用户的菜单侧边栏
:param db: 数据库会话
:param request: FastAPI 请求对象
:return:
"""
menu_data = None
if request.user.is_superuser:
menu_data = await menu_dao.get_sidebar(db, None)
else:
roles = [role for role in request.user.roles if role.status == StatusType.enable]
menu_ids = set()
if roles:
for role in roles:
menu_ids.update(menu.id for menu in role.menus)
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
if menu_data:
return get_vben5_tree_data(menu_data)
return []
@staticmethod
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
"""
创建菜单
:param db: 数据库会话
:param obj: 菜单创建参数
:return:
"""
title = await menu_dao.get_by_title(db, obj.title)
if title:
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu:
raise errors.NotFoundError(msg='父级菜单不存在')
await menu_dao.create(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateMenuParam) -> int:
"""
更新菜单
:param db: 数据库会话
:param pk: 菜单 ID
:param obj: 菜单更新参数
:return:
"""
menu = await menu_dao.get(db, pk)
if not menu:
raise errors.NotFoundError(msg='菜单不存在')
if menu.title != obj.title and await menu_dao.get_by_title(db, obj.title):
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu:
raise errors.NotFoundError(msg='父级菜单不存在')
if obj.parent_id == menu.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级')
count = await menu_dao.update(db, pk, obj)
await user_cache_manager.clear_by_menu_id(db, [pk])
return count
@staticmethod
async def delete(*, db: AsyncSession, pk: int) -> int:
"""
删除菜单
:param db: 数据库会话
:param pk: 菜单 ID
:return:
"""
children = await menu_dao.get_children(db, pk)
if children:
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
count = await menu_dao.delete(db, pk)
if count:
await user_cache_manager.clear_by_menu_id(db, [pk])
return count
menu_service: MenuService = MenuService()
@@ -1,72 +0,0 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_opera_log import opera_log_dao
from backend.app.admin.schema.opera_log import CreateOperaLogParam, DeleteOperaLogParam
from backend.common.pagination import paging_data
class OperaLogService:
"""操作日志服务类"""
@staticmethod
async def get_list(*, db: AsyncSession, username: str | None, status: int | None, ip: str | None) -> dict[str, Any]:
"""
获取操作日志列表
:param db: 数据库会话
:param username: 用户名
:param status: 状态
:param ip: IP 地址
:return:
"""
log_select = await opera_log_dao.get_select(username=username, status=status, ip=ip)
return await paging_data(db, log_select)
@staticmethod
async def create(*, db: AsyncSession, obj: CreateOperaLogParam) -> None:
"""
创建操作日志
:param db: 数据库会话
:param obj: 操作日志创建参数
:return:
"""
await opera_log_dao.create(db, obj)
@staticmethod
async def bulk_create(*, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
"""
批量创建操作日志
:param db: 数据库会话
:param objs: 操作日志创建参数列表
:return:
"""
await opera_log_dao.bulk_create(db, objs)
@staticmethod
async def delete(*, db: AsyncSession, obj: DeleteOperaLogParam) -> int:
"""
批量删除操作日志
:param db: 数据库会话
:param obj: 日志 ID 列表
:return:
"""
count = await opera_log_dao.delete(db, obj.pks)
return count
@staticmethod
async def delete_all(*, db: AsyncSession) -> None:
"""
清空所有操作日志
:param db: 数据库会话
:return:
"""
await opera_log_dao.delete_all(db)
opera_log_service: OperaLogService = OperaLogService()
-193
View File
@@ -1,193 +0,0 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.model import Role
from backend.app.admin.schema.role import (
CreateRoleParam,
DeleteRoleParam,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.utils.build_tree import get_tree_data
class RoleService:
"""角色服务类"""
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> Role:
"""
获取角色详情
:param db: 数据库会话
:param pk: 角色 ID
:return:
"""
role = await role_dao.get_join(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
return role
@staticmethod
async def get_all(*, db: AsyncSession) -> Sequence[Role]:
"""
获取所有角色
:param db: 数据库会话
:return:
"""
roles = await role_dao.get_all(db)
return roles
@staticmethod
async def get_list(*, db: AsyncSession, name: str | None, status: int | None) -> dict[str, Any]:
"""
获取角色列表
:param db: 数据库会话
:param name: 角色名称
:param status: 状态
:return:
"""
role_select = await role_dao.get_select(name=name, status=status)
return await paging_data(db, role_select)
@staticmethod
async def get_menu_tree(*, db: AsyncSession, pk: int) -> list[dict[str, Any] | None]:
"""
获取角色的菜单树形结构
:param db: 数据库会话
:param pk: 角色 ID
:return:
"""
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menus = await role_dao.get_menus(db, pk)
menu_tree = get_tree_data(menus) if menus else []
return menu_tree
@staticmethod
async def get_scopes(*, db: AsyncSession, pk: int) -> list[int]:
"""
获取角色数据范围列表
:param db: 数据库会话
:param pk:
:return:
"""
role = await role_dao.get_join(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
scope_ids = [scope.id for scope in role.scopes]
return scope_ids
@staticmethod
async def create(*, db: AsyncSession, obj: CreateRoleParam) -> None:
"""
创建角色
:param db: 数据库会话
:param obj: 角色创建参数
:return:
"""
role = await role_dao.get_by_name(db, obj.name)
if role:
raise errors.ConflictError(msg='角色已存在')
await role_dao.create(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateRoleParam) -> int:
"""
更新角色
:param db: 数据库会话
:param pk: 角色 ID
:param obj: 角色更新参数
:return:
"""
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='角色已存在')
count = await role_dao.update(db, pk, obj)
await user_cache_manager.clear_by_role_id(db, [pk])
return count
@staticmethod
async def update_role_menu(*, db: AsyncSession, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
"""
更新角色菜单
:param db: 数据库会话
:param pk: 角色 ID
:param menu_ids: 菜单 ID 列表
:return:
"""
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
if menu_ids.menus:
menus = await menu_dao.get_all_by_ids(db, list(set(menu_ids.menus)))
if {menu.id for menu in menus} != set(menu_ids.menus):
raise errors.NotFoundError(msg='菜单不存在')
count = await role_dao.update_menus(db, pk, menu_ids)
await user_cache_manager.clear_by_role_id(db, [pk])
return count
@staticmethod
async def update_role_scope(*, db: AsyncSession, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
"""
更新角色数据范围
:param db: 数据库会话
:param pk: 角色 ID
:param scope_ids: 权限规则 ID 列表
:return:
"""
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
if scope_ids.scopes:
scopes = await data_scope_dao.get_all_by_ids(db, list(set(scope_ids.scopes)))
if {scope.id for scope in scopes} != set(scope_ids.scopes):
raise errors.NotFoundError(msg='数据范围不存在')
count = await role_dao.update_scopes(db, pk, scope_ids)
await user_cache_manager.clear_by_role_id(db, [pk])
return count
@staticmethod
async def delete(*, db: AsyncSession, obj: DeleteRoleParam) -> int:
"""
批量删除角色
:param db: 数据库会话
:param obj: 角色 ID 列表
:return:
"""
count = await role_dao.delete(db, obj.pks)
await user_cache_manager.clear_by_role_id(db, obj.pks)
return count
role_service: RoleService = RoleService()
+7 -52
View File
@@ -1,13 +1,10 @@
from collections.abc import Sequence
from typing import Any from typing import Any
from fastapi import Request from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import Role, User from backend.app.admin.model import User
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
ResetPasswordParam, ResetPasswordParam,
@@ -16,15 +13,12 @@ from backend.app.admin.schema.user import (
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.app.admin.service.user_password_history_service import password_security_service from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify, validate_new_password from backend.app.admin.utils.password_security import password_verify, validate_new_password
from backend.common.context import ctx
from backend.common.enums import UserPermissionType from backend.common.enums import UserPermissionType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import get_token, jwt_decode from backend.common.security.jwt import get_token, jwt_decode
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.serializers import select_join_serialize
class UserService: class UserService:
@@ -40,44 +34,24 @@ class UserService:
:param username: 用户名 :param username: 用户名
:return: :return:
""" """
user = await user_dao.get_join(db, user_id=pk, username=username) user = await user_dao.get(db, pk) if pk else await user_dao.get_by_username(db, username or '')
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
return user return user
@staticmethod @staticmethod
async def get_roles(*, db: AsyncSession, pk: int) -> Sequence[Role]: async def get_list(*, db: AsyncSession, username: str, phone: str, status: int) -> dict[str, Any]:
"""
获取用户所有角色
:param db: 数据库会话
:param pk: 用户 ID
:return:
"""
user = await user_dao.get_join(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
return user.roles
@staticmethod
async def get_list(*, db: AsyncSession, dept: int, username: str, phone: str, status: int) -> dict[str, Any]:
""" """
获取用户列表 获取用户列表
:param db: 数据库会话 :param db: 数据库会话
:param dept: 部门 ID
:param username: 用户名 :param username: 用户名
:param phone: 手机号 :param phone: 手机号
:param status: 状态 :param status: 状态
:return: :return:
""" """
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status) user_select = await user_dao.get_select(username=username, phone=phone, status=status)
data = await paging_data(db, user_select) return await paging_data(db, user_select)
if data['items']:
serialized_items = select_join_serialize(data['items'], relationships=['User-m2o-Dept', 'User-m2m-Role'])
# 确保返回的是列表,即使只有一个元素
data['items'] = [serialized_items] if not isinstance(serialized_items, list) else serialized_items
return data
@staticmethod @staticmethod
async def create(*, db: AsyncSession, obj: AddUserParam) -> None: async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
@@ -94,12 +68,6 @@ class UserService:
raise errors.ConflictError(msg='邮箱已被绑定') raise errors.ConflictError(msg='邮箱已被绑定')
if not obj.password: if not obj.password:
raise errors.RequestError(msg='密码不允许为空') raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
if obj.roles:
roles = await role_dao.get_all_by_ids(db, list(set(obj.roles)))
if {role.id for role in roles} != set(obj.roles):
raise errors.NotFoundError(msg='角色不存在')
obj.nickname = obj.nickname or obj.username obj.nickname = obj.nickname or obj.username
await user_dao.add(db, obj) await user_dao.add(db, obj)
@@ -113,7 +81,7 @@ class UserService:
:param obj: 用户更新参数 :param obj: 用户更新参数
:return: :return:
""" """
user = await user_dao.get_join(db, user_id=pk) user = await user_dao.get(db, pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username and await user_dao.get_by_username(db, obj.username): if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
@@ -122,12 +90,6 @@ class UserService:
email_user = await user_dao.check_email(db, obj.email) email_user = await user_dao.check_email(db, obj.email)
if email_user: if email_user:
raise errors.ConflictError(msg='邮箱已被绑定') raise errors.ConflictError(msg='邮箱已被绑定')
if obj.dept_id and obj.dept_id != user.dept_id and not await dept_dao.get(db, dept_id=obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
if obj.roles:
roles = await role_dao.get_all_by_ids(db, list(set(obj.roles)))
if {role.id for role in roles} != set(obj.roles):
raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user.id, obj) count = await user_dao.update(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@@ -247,25 +209,18 @@ class UserService:
return count return count
@staticmethod @staticmethod
async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int: async def update_email(*, db: AsyncSession, user_id: int, email: str) -> int:
""" """
更新当前用户邮箱 更新当前用户邮箱
:param db: 数据库会话 :param db: 数据库会话
:param user_id: 用户 ID :param user_id: 用户 ID
:param captcha: 邮箱验证码
:param email: 邮箱 :param email: 邮箱
:return: :return:
""" """
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
if not captcha_code:
raise errors.RequestError(msg='验证码已失效,请重新获取')
if captcha != captcha_code:
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
email_user = await user_dao.check_email(db, email) email_user = await user_dao.check_email(db, email)
if email_user and email_user.id != user_id: if email_user and email_user.id != user_id:
raise errors.ConflictError(msg='邮箱已被绑定') raise errors.ConflictError(msg='邮箱已被绑定')
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
count = await user_dao.update_email(db, user_id, email) count = await user_dao.update_email(db, user_id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count return count
View File
@@ -1,7 +0,0 @@
from starlette.testclient import TestClient
def test_logout(client: TestClient, token_headers: dict[str, str]) -> None:
response = client.post('/auth/logout', headers=token_headers)
assert response.status_code == 200
assert response.json()['code'] == 200
+1 -82
View File
@@ -1,9 +1,5 @@
from collections.abc import Sequence from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.model import data_scope_rule, role_data_scope, role_menu, user_role
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
@@ -13,86 +9,9 @@ class UserCacheManager:
@staticmethod @staticmethod
async def clear(user_ids: Sequence[int]) -> None: async def clear(user_ids: Sequence[int]) -> None:
""" """清理用户缓存"""
清理用户缓存
:param user_ids: 用户 ID 列表
:return:
"""
if user_ids: if user_ids:
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids]) await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids])
async def clear_by_role_id(self, db: AsyncSession, role_ids: list[int]) -> None:
"""
通过角色 ID 清理用户缓存
:param db: 数据库会话
:param role_ids: 角色 ID 列表
:return:
"""
stmt = select(user_role.c.user_id).where(user_role.c.role_id.in_(role_ids)).distinct()
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_menu_id(self, db: AsyncSession, menu_ids: list[int]) -> None:
"""
通过菜单 ID 清理用户缓存
:param db: 数据库会话
:param menu_ids: 菜单 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_menu, user_role.c.role_id == role_menu.c.role_id)
.where(role_menu.c.menu_id.in_(menu_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_data_scope_id(self, db: AsyncSession, scope_ids: list[int]) -> None:
"""
通过数据范围 ID 清理用户缓存
:param db: 数据库会话
:param scope_ids: 数据范围 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
.where(role_data_scope.c.data_scope_id.in_(scope_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_data_rule_id(self, db: AsyncSession, rule_ids: list[int]) -> None:
"""
通过数据规则 ID 清理用户缓存
:param db: 数据库会话
:param rule_ids: 数据规则 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
.join(data_scope_rule, role_data_scope.c.data_scope_id == data_scope_rule.c.data_scope_id)
.where(data_scope_rule.c.data_rule_id.in_(rule_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
user_cache_manager: UserCacheManager = UserCacheManager() user_cache_manager: UserCacheManager = UserCacheManager()
-2
View File
@@ -1,9 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.router import v1 as admin_v1 from backend.app.admin.api.router import v1 as admin_v1
from backend.app.task.api.router import v1 as task_v1
router = APIRouter() router = APIRouter()
router.include_router(admin_v1) router.include_router(admin_v1)
router.include_router(task_v1)
-27
View File
@@ -1,27 +0,0 @@
## 任务介绍
当前任务使用 Celery
实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi-best-architecture/discussions/225)
## 定时任务
`backend/app/task/tasks/beat.py` 文件内编写相关定时任务
### 简单任务
`backend/app/task/tasks/tasks.py` 文件内编写相关任务代码
### 层级任务
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
2. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
## 消息代理
你可以通过 `CELERY_BROKER` 控制消息代理选择,它支持 redis 和 rabbitmq
对于本地调试,建议使用 redis
对于线上环境,强制使用 rabbitmq
-7
View File
@@ -1,7 +0,0 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
from .actions import * # ruff:ignore[undefined-local-with-import-star]
-11
View File
@@ -1,11 +0,0 @@
from starlette.concurrency import run_in_threadpool
from backend.app.task.celery import celery_app
from backend.common.socketio.server import sio
@sio.event
async def task_worker_status(sid, data) -> None: # ruff:ignore[missing-type-function-argument]
"""任务 Worker 状态事件"""
worker = await run_in_threadpool(celery_app.control.ping)
await sio.emit('task_worker_status', worker, sid)
View File
-12
View File
@@ -1,12 +0,0 @@
from fastapi import APIRouter
from backend.app.task.api.v1.control import router as task_control_router
from backend.app.task.api.v1.result import router as task_result_router
from backend.app.task.api.v1.scheduler import router as task_scheduler_router
from backend.core.conf import settings
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH, tags=['任务'])
v1.include_router(task_control_router, prefix='/tasks')
v1.include_router(task_result_router, prefix='/task-results')
v1.include_router(task_scheduler_router, prefix='/schedulers')
View File
-48
View File
@@ -1,48 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from starlette.concurrency import run_in_threadpool
from backend.app.task import celery_app
from backend.app.task.schema.control import TaskRegisteredDetail
from backend.common.exception import errors
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
router = APIRouter()
@router.get('/registered', summary='获取已注册的任务', dependencies=[DependsJwtAuth])
async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail]]:
inspector = celery_app.control.inspect(timeout=0.5)
registered = await run_in_threadpool(inspector.registered)
if not registered:
raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试')
task_registered = []
celery_app_tasks = celery_app.tasks
for tasks in registered.values():
for task in tasks:
task_ins = celery_app_tasks.get(task)
if task_ins:
task_registered.append(TaskRegisteredDetail(name=task_ins.__doc__ or task, task=task))
else:
task_registered.append(TaskRegisteredDetail(name=task, task=task))
return response_base.success(data=task_registered)
@router.delete(
'/{task_id}/cancel',
summary='撤销任务',
dependencies=[
Depends(RequestPermission('sys:task:revoke')),
DependsRBAC,
],
)
async def revoke_task(task_id: Annotated[str, Path(description='任务 UUID')]) -> ResponseModel:
workers = await run_in_threadpool(celery_app.control.ping, timeout=0.5)
if not workers:
raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试')
celery_app.control.revoke(task_id)
return response_base.success()
-55
View File
@@ -1,55 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.task.schema.result import DeleteTaskResultParam, GetTaskResultDetail
from backend.app.task.service.result_service import task_result_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/{pk}', summary='获取任务结果详情', dependencies=[DependsJwtAuth])
async def get_task_result(
db: CurrentSession,
pk: Annotated[int, Path(description='任务结果 ID')],
) -> ResponseSchemaModel[GetTaskResultDetail]:
result = await task_result_service.get(db=db, pk=pk)
return response_base.success(data=result)
@router.get(
'',
summary='分页获取所有任务结果',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_task_results_paginated(
db: CurrentSession,
name: Annotated[str | None, Query(description='任务名称')] = None,
task_id: Annotated[str | None, Query(description='任务 ID')] = None,
) -> ResponseSchemaModel[PageData[GetTaskResultDetail]]:
page_data = await task_result_service.get_list(db=db, name=name, task_id=task_id)
return response_base.success(data=page_data)
@router.delete(
'',
summary='批量删除任务结果',
dependencies=[
Depends(RequestPermission('sys:task:del')),
DependsRBAC,
],
)
async def delete_task_result(db: CurrentSessionTransaction, obj: DeleteTaskResultParam) -> ResponseModel:
count = await task_result_service.delete(db=db, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
-129
View File
@@ -1,129 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.task.schema.scheduler import (
CreateTaskSchedulerParam,
GetTaskSchedulerDetail,
UpdateTaskSchedulerParam,
)
from backend.app.task.service.scheduler_service import task_scheduler_service
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/all', summary='获取所有任务调度', dependencies=[DependsJwtAuth])
async def get_all_task_schedulers(db: CurrentSession) -> ResponseSchemaModel[list[GetTaskSchedulerDetail]]:
schedulers = await task_scheduler_service.get_all(db=db)
return response_base.success(data=schedulers)
@router.get('/{pk}', summary='获取任务调度详情', dependencies=[DependsJwtAuth])
async def get_task_scheduler(
db: CurrentSession,
pk: Annotated[int, Path(description='任务调度 ID')],
) -> ResponseSchemaModel[GetTaskSchedulerDetail]:
task_scheduler = await task_scheduler_service.get(db=db, pk=pk)
return response_base.success(data=task_scheduler)
@router.get(
'',
summary='分页获取所有任务调度',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_task_scheduler_paginated(
db: CurrentSession,
name: Annotated[str | None, Query(description='任务调度名称')] = None,
type: Annotated[int | None, Query(description='任务调度类型')] = None,
) -> ResponseSchemaModel[PageData[GetTaskSchedulerDetail]]:
page_data = await task_scheduler_service.get_list(db=db, name=name, type=type)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建任务调度',
dependencies=[
Depends(RequestPermission('sys:task:add')),
DependsRBAC,
],
)
async def create_task_scheduler(db: CurrentSessionTransaction, obj: CreateTaskSchedulerParam) -> ResponseModel:
await task_scheduler_service.create(db=db, obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新任务调度',
dependencies=[
Depends(RequestPermission('sys:task:edit')),
DependsRBAC,
],
)
async def update_task_scheduler(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='任务调度 ID')],
obj: UpdateTaskSchedulerParam,
) -> ResponseModel:
count = await task_scheduler_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/status',
summary='更新任务调度状态',
dependencies=[
Depends(RequestPermission('sys:task:edit')),
DependsRBAC,
],
)
async def update_task_scheduler_status(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='任务调度 ID')]
) -> ResponseModel:
count = await task_scheduler_service.update_status(db=db, pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/{pk}',
summary='删除任务调度',
dependencies=[
Depends(RequestPermission('sys:task:del')),
DependsRBAC,
],
)
async def delete_task_scheduler(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='任务调度 ID')]
) -> ResponseModel:
count = await task_scheduler_service.delete(db=db, pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
@router.post(
'/{pk}/execute',
summary='执行任务',
dependencies=[
Depends(RequestPermission('sys:task:exec')),
DependsRBAC,
],
)
async def execute_task(db: CurrentSession, pk: Annotated[int, Path(description='任务调度 ID')]) -> ResponseModel:
await task_scheduler_service.execute(db=db, pk=pk)
return response_base.success()
-92
View File
@@ -1,92 +0,0 @@
import os
import urllib.parse
import celery
import celery_aio_pool
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from backend.app.task.tasks.beat import get_local_beat_schedule
from backend.common.enums import DataBaseType
from backend.common.observability.otel import init_resource, init_tracer
from backend.core.conf import settings
from backend.core.path_conf import BASE_PATH
_celery_otel_initialized = False
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs) -> None:
"""初始化 Celery 追踪"""
global _celery_otel_initialized
if not settings.GRAFANA_METRICS_ENABLE or _celery_otel_initialized:
return
resource = init_resource(settings.GRAFANA_CELERY_OTEL_SERVICE_NAME)
init_tracer(resource)
CeleryInstrumentor().instrument()
_celery_otel_initialized = True
def find_task_packages() -> list[str]:
packages = []
task_dir = BASE_PATH / 'app' / 'task' / 'tasks'
for root, _dirs, files in os.walk(task_dir):
if 'tasks.py' in files:
package = root.replace(str(BASE_PATH.parent) + os.path.sep, '').replace(os.path.sep, '.')
packages.append(package)
return packages
def init_celery() -> celery.Celery:
"""初始化 Celery 应用"""
# TODO: Update this work if celery version >= 6.0.0
# https://github.com/fastapi-practices/fastapi-best-architecture/issues/321
# https://github.com/celery/celery/issues/7874
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations()
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{urllib.parse.quote(settings.CELERY_RABBITMQ_PASSWORD)}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}/{settings.CELERY_RABBITMQ_VHOST}'
if settings.CELERY_BROKER == 'redis':
broker_url = f'redis://:{urllib.parse.quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
result_backend = f'db+postgresql+psycopg://{settings.DATABASE_USER}:{urllib.parse.quote(settings.DATABASE_PASSWORD)}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}'
if DataBaseType.mysql == settings.DATABASE_TYPE:
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
app = celery.Celery(
'fba_celery',
broker_url=broker_url,
broker_connection_retry_on_startup=True,
result_backend=result_backend,
result_extended=True,
database_engine_options={'echo': settings.DATABASE_ECHO},
# result_expires=0,
# beat_sync_every=1,
beat_schedule=get_local_beat_schedule(),
beat_scheduler='backend.app.task.utils.schedulers:DatabaseScheduler',
task_cls='backend.app.task.tasks.base:TaskBase',
task_track_started=True,
enable_utc=False,
timezone=settings.DATETIME_TIMEZONE,
worker_send_task_events=True,
task_send_sent_event=True,
)
# 在 Celery 中设置此参数无效
# 参数:https://github.com/celery/celery/issues/7270
app.loader.override_backends = {'db': 'backend.app.task.database:DatabaseBackend'}
# 自动发现任务
packages = find_task_packages()
app.autodiscover_tasks(packages)
return app
# 创建 Celery 实例
celery_app: celery.Celery = init_celery()
View File
-49
View File
@@ -1,49 +0,0 @@
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.task.model import TaskResult
class CRUDTaskResult(CRUDPlus[TaskResult]):
"""任务结果数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> TaskResult | None:
"""
获取任务结果详情
:param db: 数据库会话
:param pk: 任务 ID
:return:
"""
return await self.select_model(db, pk)
async def get_select(self, name: str | None, task_id: str | None) -> Select:
"""
获取任务结果列表查询表达式
:param name: 任务名称
:param task_id: 任务 ID
:return:
"""
filters = {}
if name is not None:
filters['name__like'] = f'%{name}%'
if task_id is not None:
filters['task_id'] = task_id
return await self.select_order('id', 'desc', **filters)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除任务结果
:param db: 数据库会话
:param pks: 任务结果 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
task_result_dao: CRUDTaskResult = CRUDTaskResult(TaskResult)
-125
View File
@@ -1,125 +0,0 @@
from collections.abc import Sequence
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.task.model import TaskScheduler
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam
from backend.utils.timezone import timezone
class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
"""任务调度数据库操作类"""
@staticmethod
async def get(db: AsyncSession, pk: int) -> TaskScheduler | None:
"""
获取任务调度
:param db: 数据库会话
:param pk: 任务调度 ID
:return:
"""
return await task_scheduler_dao.select_model(db, pk, deleted=0)
async def get_all(self, db: AsyncSession) -> Sequence[TaskScheduler]:
"""
获取所有任务调度
:param db: 数据库会话
:return:
"""
return await self.select_models(db, deleted=0)
async def get_select(self, name: str | None, type: int | None) -> Select:
"""
获取任务调度列表查询表达式
:param name: 任务调度名称
:param type: 任务调度类型
:return:
"""
filters = {'deleted': 0}
if name is not None:
filters['name__like'] = f'%{name}%'
if type is not None:
filters['type'] = type
return await self.select_order('id', **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> TaskScheduler | None:
"""
通过名称获取任务调度
:param db: 数据库会话
:param name: 任务调度名称
:return:
"""
return await self.select_model_by_column(db, name=name, deleted=0)
async def create(self, db: AsyncSession, obj: CreateTaskSchedulerParam) -> None:
"""
创建任务调度
:param db: 数据库会话
:param obj: 创建任务调度参数
:return:
"""
await self.create_model(db, obj, flush=True)
TaskScheduler.no_changes = False
async def update(self, db: AsyncSession, pk: int, obj: UpdateTaskSchedulerParam) -> int:
"""
更新任务调度
:param db: 数据库会话
:param pk: 任务调度 ID
:param obj: 更新任务调度参数
:return:
"""
task_scheduler = await self.get(db, pk)
for key, value in obj.model_dump(exclude_unset=True).items():
setattr(task_scheduler, key, value)
TaskScheduler.no_changes = False
return 1
async def set_status(self, db: AsyncSession, pk: int, *, status: bool) -> int:
"""
设置任务调度状态
:param db: 数据库会话
:param pk: 任务调度 ID
:param status: 状态
:return:
"""
task_scheduler = await self.get(db, pk)
task_scheduler.enabled = status
TaskScheduler.no_changes = False
return 1
async def delete(self, db: AsyncSession, pk: int) -> int:
"""
删除任务调度
:param db: 数据库会话
:param pk: 任务调度 ID
:return:
"""
count = await self.delete_model_by_column(
db,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id=pk,
deleted=0,
)
if count:
TaskScheduler.no_changes = False
return count
task_scheduler_dao: CRUDTaskScheduler = CRUDTaskScheduler(TaskScheduler)
-176
View File
@@ -1,176 +0,0 @@
from celery import states
from celery.backends.base import BaseBackend
from celery.backends.database import retry, session_cleanup
from celery.exceptions import ImproperlyConfigured
from celery.utils.time import maybe_timedelta
from sqlalchemy import PickleType
from sqlalchemy.orm import Session
from backend.app.task.model.result import Task, TaskExtended, TaskSet
from backend.app.task.session import SessionManager
class DatabaseBackend(BaseBackend):
"""
重写 celery.backends.database DatabaseBackend此类实现与模型配合不佳导致 fba 创建表和 alembic 迁移困难
"""
# ResultSet.iterate should sleep this much between each pool,
# to not bombard the database with queries.
subpolling_interval = 0.5
task_cls = Task
taskset_cls = TaskSet
def __init__(self, dburi=None, engine_options=None, url=None, **kwargs) -> None: # ruff:ignore[missing-type-function-argument]
# The `url` argument was added later and is used by
# the app to set backend by url (celery.app.backends.by_url)
super().__init__(expires_type=maybe_timedelta, url=url, **kwargs)
conf = self.app.conf
if self.extended_result:
self.task_cls = TaskExtended
self.url = url or dburi or conf.database_url
self.engine_options = dict(engine_options or {}, **conf.database_engine_options or {})
self.short_lived_sessions = kwargs.get('short_lived_sessions', conf.database_short_lived_sessions)
schemas = conf.database_table_schemas or {}
tablenames = conf.database_table_names or {}
self.task_cls.configure(schema=schemas.get('task'), name=tablenames.get('task'))
self.taskset_cls.configure(schema=schemas.get('group'), name=tablenames.get('group'))
if not self.url:
raise ImproperlyConfigured(
'Missing connection string! Do you have the database_url setting set to a real value?',
)
self.session_manager = SessionManager()
create_tables_at_setup = conf.database_create_tables_at_setup
if create_tables_at_setup is True:
self._create_tables()
@property
def extended_result(self): # ruff:ignore[missing-return-type-undocumented-public-function]
return self.app.conf.find_value_for_key('extended', 'result')
def _create_tables(self) -> None:
"""Create the task and taskset tables."""
self.result_session()
def result_session(self, session_manager=None) -> Session: # ruff:ignore[missing-type-function-argument]
if session_manager is None:
session_manager = self.session_manager
return session_manager.session_factory(
dburi=self.url,
short_lived_sessions=self.short_lived_sessions,
**self.engine_options,
)
@retry
def _store_result(self, task_id, result, state, traceback=None, request=None, **kwargs) -> None: # ruff:ignore[missing-type-function-argument]
"""Store return value and state of an executed task."""
session = self.result_session()
with session_cleanup(session):
task = list(session.query(self.task_cls).filter(self.task_cls.task_id == task_id))
task = task and task[0]
if not task:
task = self.task_cls(task_id)
task.task_id = task_id
session.add(task)
session.flush()
self._update_result(task, result, state, traceback=traceback, request=request)
session.commit()
def _update_result(self, task, result, state, traceback=None, request=None) -> None: # ruff:ignore[missing-type-function-argument]
meta = self._get_result_meta(
result=result,
state=state,
traceback=traceback,
request=request,
format_date=False,
encode=True,
)
# Exclude the primary key id and task_id columns
# as we should not set it None
columns = [column.name for column in self.task_cls.__table__.columns if column.name not in {'id', 'task_id'}]
# Iterate through the columns name of the table
# to set the value from meta.
# If the value is not present in meta, set None
for column in columns:
value = meta.get(column)
setattr(task, column, value)
@retry
def _get_task_meta_for(self, task_id: str): # ruff:ignore[missing-return-type-private-function]
"""Get task meta-data for a task by id."""
session = self.result_session()
with session_cleanup(session):
task = list(session.query(self.task_cls).filter(self.task_cls.task_id == task_id))
task = task and task[0]
if not task:
task = self.task_cls(task_id)
task.status = states.PENDING
task.result = None
data = task.to_dict()
if data.get('args', None) is not None:
data['args'] = self.decode(data['args'])
if data.get('kwargs', None) is not None:
data['kwargs'] = self.decode(data['kwargs'])
return self.meta_from_decoded(data)
@retry
def _save_group(self, group_id: str, result: PickleType): # ruff:ignore[missing-return-type-private-function]
"""Store the result of an executed group."""
session = self.result_session()
with session_cleanup(session):
group = self.taskset_cls(group_id, result)
session.add(group)
session.flush()
session.commit()
return result
@retry
def _restore_group(self, group_id: str) -> dict | None:
"""Get meta-data for group by id."""
session = self.result_session()
with session_cleanup(session):
group = session.query(self.taskset_cls).filter(self.taskset_cls.taskset_id == group_id).first()
if group:
return group.to_dict()
@retry
def _delete_group(self, group_id: str) -> None:
"""Delete meta-data for group by id."""
session = self.result_session()
with session_cleanup(session):
session.query(self.taskset_cls).filter(self.taskset_cls.taskset_id == group_id).delete()
session.flush()
session.commit()
@retry
def _forget(self, task_id: str) -> None:
"""Forget about result."""
session = self.result_session()
with session_cleanup(session):
session.query(self.task_cls).filter(self.task_cls.task_id == task_id).delete()
session.commit()
def cleanup(self) -> None:
"""Delete expired meta-data."""
session = self.result_session()
expires = self.expires
now = self.app.now()
with session_cleanup(session):
session.query(self.task_cls).filter(self.task_cls.date_done < (now - expires)).delete()
session.query(self.taskset_cls).filter(self.taskset_cls.date_done < (now - expires)).delete()
session.commit()
def __reduce__(self, args=(), kwargs=None): # ruff:ignore[missing-type-function-argument, missing-return-type-special-method]
kwargs = kwargs or {}
kwargs.update({'dburi': self.url, 'expires': self.expires, 'engine_options': self.engine_options})
return super().__reduce__(args, kwargs)
-18
View File
@@ -1,18 +0,0 @@
from backend.common.enums import IntEnum, StrEnum
class TaskSchedulerType(IntEnum):
"""任务调度类型"""
INTERVAL = 0
CRONTAB = 1
class PeriodType(StrEnum):
"""周期类型"""
DAYS = 'days'
HOURS = 'hours'
MINUTES = 'minutes'
SECONDS = 'seconds'
MICROSECONDS = 'microseconds'
-2
View File
@@ -1,2 +0,0 @@
from backend.app.task.model.result import TaskExtended as TaskResult # ruff:ignore[unused-import]
from backend.app.task.model.scheduler import TaskScheduler as TaskScheduler
-109
View File
@@ -1,109 +0,0 @@
import sqlalchemy as sa
from celery import states
from sqlalchemy.types import PickleType
from backend.common.model import MappedBase, TimeZone
from backend.utils.timezone import timezone
"""
重写 celery.backends.database.models 内部所有模型适配 fba 创建表和 alembic 迁移
"""
class Task(MappedBase):
"""Task result/status."""
__tablename__ = 'task_result'
__table_args__ = {'comment': '任务结果表'}
id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True)
task_id = sa.Column(sa.String(155), unique=True)
status = sa.Column(sa.String(64), default=states.PENDING)
result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(
TimeZone,
default=timezone.now,
onupdate=timezone.now,
nullable=True,
)
traceback = sa.Column(sa.Text, nullable=True)
def __init__(self, task_id: str) -> None:
self.task_id = task_id
def to_dict(self) -> dict:
return {
'task_id': self.task_id,
'status': self.status,
'result': self.result,
'traceback': self.traceback,
'date_done': self.date_done,
}
def __repr__(self) -> str:
return f'<Task {self.task_id} state: {self.status}>'
@classmethod
def configure(cls, schema=None, name=None) -> None: # ruff:ignore[missing-type-function-argument]
cls.__table__.schema = schema
cls.id.default.schema = schema
cls.__table__.name = name or cls.__tablename__
class TaskExtended(Task):
"""For the extend result."""
__tablename__ = 'task_result'
__table_args__ = {'extend_existing': True, 'comment': '任务结果表'}
name = sa.Column(sa.String(155), nullable=True)
args = sa.Column(sa.LargeBinary, nullable=True)
kwargs = sa.Column(sa.LargeBinary, nullable=True)
worker = sa.Column(sa.String(155), nullable=True)
retries = sa.Column(sa.Integer, nullable=True)
queue = sa.Column(sa.String(155), nullable=True)
def to_dict(self) -> dict:
task_dict = super().to_dict()
task_dict.update({
'name': self.name,
'args': self.args,
'kwargs': self.kwargs,
'worker': self.worker,
'retries': self.retries,
'queue': self.queue,
})
return task_dict
class TaskSet(MappedBase):
"""TaskSet result."""
__tablename__ = 'task_set_result'
__table_args__ = {'comment': '任务集结果表'}
id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True)
taskset_id = sa.Column(sa.String(155), unique=True)
result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(TimeZone, default=timezone.now, nullable=True)
def __init__(self, taskset_id, result) -> None: # ruff:ignore[missing-type-function-argument]
self.taskset_id = taskset_id
self.result = result
def to_dict(self) -> dict:
return {
'taskset_id': self.taskset_id,
'result': self.result,
'date_done': self.date_done,
}
def __repr__(self) -> str:
return f'<TaskSet: {self.taskset_id}>'
@classmethod
def configure(cls, schema=None, name=None) -> None: # ruff:ignore[missing-type-function-argument]
cls.__table__.schema = schema
cls.id.default.schema = schema
cls.__table__.name = name or cls.__tablename__
-74
View File
@@ -1,74 +0,0 @@
import asyncio
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.exception import errors
from backend.common.model import Base, TimeZone, UniversalText, id_key
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.timezone import timezone
class TaskScheduler(Base):
"""任务调度表"""
__tablename__ = 'task_scheduler'
__table_args__ = (
sa.UniqueConstraint('name', 'deleted', name='uk_task_scheduler_name_deleted'),
{'comment': '任务调度表'},
)
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(sa.String(64), comment='任务名称')
task: Mapped[str] = mapped_column(sa.String(256), comment='要运行的 Celery 任务')
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的位置参数')
kwargs: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的关键字参数')
queue: Mapped[str | None] = mapped_column(sa.String(256), comment='CELERY_TASK_QUEUES 中定义的队列')
exchange: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的交换机')
routing_key: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的路由密钥')
start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
expire_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务不再触发的截止时间')
expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差')
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型')
crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='Crontab 表达式')
one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次')
enabled: Mapped[bool] = mapped_column(default=True, comment='是否启用任务')
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
no_changes: bool = False
@staticmethod
def before_insert_or_update(mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
if target.expire_seconds is not None and target.expire_time:
raise errors.ConflictError(msg='expires 和 expire_seconds 只能设置一个')
@classmethod
def changed(cls, mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
if not target.no_changes:
cls.update_changed(mapper, connection, target)
@classmethod
async def update_changed_async(cls) -> None:
now = timezone.now()
await redis_client.set(f'{settings.CELERY_REDIS_PREFIX}:last_update', timezone.to_str(now))
@classmethod
def update_changed(cls, mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
asyncio.create_task(cls.update_changed_async())
# 事件监听器
event.listen(TaskScheduler, 'before_insert', TaskScheduler.before_insert_or_update)
event.listen(TaskScheduler, 'before_update', TaskScheduler.before_insert_or_update)
event.listen(TaskScheduler, 'after_insert', TaskScheduler.update_changed)
event.listen(TaskScheduler, 'after_delete', TaskScheduler.update_changed)
event.listen(TaskScheduler, 'after_update', TaskScheduler.changed)
View File
-6
View File
@@ -1,6 +0,0 @@
from backend.common.schema import SchemaBase
class TaskRegisteredDetail(SchemaBase):
name: str
task: str
-41
View File
@@ -1,41 +0,0 @@
from datetime import datetime
from typing import Any
from pydantic import ConfigDict, Field, field_serializer
from backend.app.task import celery_app
from backend.common.schema import SchemaBase
class TaskResultSchemaBase(SchemaBase):
"""任务结果基础模型"""
task_id: str = Field(description='任务 ID')
status: str = Field(description='执行状态')
result: Any | None = Field(description='执行结果')
date_done: datetime | None = Field(description='结束时间')
traceback: str | None = Field(description='错误回溯')
name: str | None = Field(description='任务名称')
args: bytes | None = Field(description='任务位置参数')
kwargs: bytes | None = Field(description='任务关键字参数')
worker: str | None = Field(description='运行 Worker')
retries: int | None = Field(description='重试次数')
queue: str | None = Field(description='运行队列')
class DeleteTaskResultParam(SchemaBase):
"""删除任务结果参数"""
pks: list[int] = Field(description='任务结果 ID 列表')
class GetTaskResultDetail(TaskResultSchemaBase):
"""任务结果详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='任务结果 ID')
@field_serializer('args', 'kwargs', when_used='unless-none')
def serialize_params(self, value: bytes | None) -> Any:
return celery_app.backend.decode(value)
-49
View File
@@ -1,49 +0,0 @@
from datetime import datetime
from pydantic import ConfigDict, Field
from pydantic.types import JsonValue
from backend.app.task.enums import PeriodType, TaskSchedulerType
from backend.common.schema import SchemaBase
class TaskSchedulerSchemeBase(SchemaBase):
"""任务调度参数"""
name: str = Field(description='任务名称')
task: str = Field(description='要运行的 Celery 任务')
args: JsonValue | None = Field(default=None, description='任务可接收的位置参数')
kwargs: JsonValue | None = Field(default=None, description='任务可接收的关键字参数')
queue: str | None = Field(default=None, description='CELERY_TASK_QUEUES 中定义的队列')
exchange: str | None = Field(default=None, description='低级别 AMQP 路由的交换机')
routing_key: str | None = Field(default=None, description='低级别 AMQP 路由的路由密钥')
start_time: datetime | None = Field(default=None, description='任务开始触发的时间')
expire_time: datetime | None = Field(default=None, description='任务不再触发的截止时间')
expire_seconds: int | None = Field(default=None, description='任务不再触发的秒数时间差')
type: TaskSchedulerType = Field(description='任务调度类型(0间隔 1定时)')
interval_every: int | None = Field(default=None, description='任务再次运行前的间隔周期数')
interval_period: PeriodType | None = Field(default=None, description='任务运行之间的周期类型')
crontab: str = Field(default='* * * * *', description='Crontab 表达式')
one_off: bool = Field(default=False, description='是否仅运行一次')
remark: str | None = Field(default=None, description='备注')
class CreateTaskSchedulerParam(TaskSchedulerSchemeBase):
"""创建任务调度参数"""
class UpdateTaskSchedulerParam(TaskSchedulerSchemeBase):
"""更新任务调度参数"""
class GetTaskSchedulerDetail(TaskSchedulerSchemeBase):
"""任务调度详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='任务调度 ID')
enabled: bool = Field(description='是否启用任务')
total_run_count: int = Field(description='已运行总次数')
last_run_time: datetime | None = Field(None, description='最后运行时间')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
@@ -1,55 +0,0 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.task.crud.crud_result import task_result_dao
from backend.app.task.model import TaskResult
from backend.app.task.schema.result import DeleteTaskResultParam
from backend.common.exception import errors
from backend.common.pagination import paging_data
class TaskResultService:
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> TaskResult:
"""
获取任务结果详情
:param db: 数据库会话
:param pk: 任务 ID
:return:
"""
result = await task_result_dao.get(db, pk)
if not result:
raise errors.NotFoundError(msg='任务结果不存在')
return result
@staticmethod
async def get_list(*, db: AsyncSession, name: str | None, task_id: str | None) -> dict[str, Any]:
"""
获取任务结果列表
:param db: 数据库会话
:param name: 任务名称
:param task_id: 任务 ID
:return:
"""
result_select = await task_result_dao.get_select(name, task_id)
return await paging_data(db, result_select)
@staticmethod
async def delete(*, db: AsyncSession, obj: DeleteTaskResultParam) -> int:
"""
批量删除任务结果
:param db: 数据库会话
:param obj: 任务结果 ID 列表
:return:
"""
count = await task_result_dao.delete(db, obj.pks)
return count
task_result_service: TaskResultService = TaskResultService()
@@ -1,157 +0,0 @@
import json
from collections.abc import Sequence
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from backend.app.task.celery import celery_app
from backend.app.task.crud.crud_scheduler import task_scheduler_dao
from backend.app.task.enums import TaskSchedulerType
from backend.app.task.model import TaskScheduler
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam
from backend.app.task.utils.tzcrontab import crontab_verify
from backend.common.exception import errors
from backend.common.pagination import paging_data
class TaskSchedulerService:
"""任务调度服务类"""
@staticmethod
async def get(*, db: AsyncSession, pk: int) -> TaskScheduler | None:
"""
获取任务调度详情
:param db: 数据库会话
:param pk: 任务调度 ID
:return:
"""
task_scheduler = await task_scheduler_dao.get(db, pk)
if not task_scheduler:
raise errors.NotFoundError(msg='任务调度不存在')
return task_scheduler
@staticmethod
async def get_all(*, db: AsyncSession) -> Sequence[TaskScheduler]:
"""
获取所有任务调度
:param db: 数据库会话
:return:
"""
task_schedulers = await task_scheduler_dao.get_all(db)
return task_schedulers
@staticmethod
async def get_list(*, db: AsyncSession, name: str | None, type: int | None) -> dict[str, Any]:
"""
获取任务调度列表
:param db: 数据库会话
:param name: 任务调度名称
:param type: 任务调度类型
:return:
"""
task_scheduler_select = await task_scheduler_dao.get_select(name=name, type=type)
return await paging_data(db, task_scheduler_select)
@staticmethod
async def create(*, db: AsyncSession, obj: CreateTaskSchedulerParam) -> None:
"""
创建任务调度
:param db: 数据库会话
:param obj: 任务调度创建参数
:return:
"""
task_scheduler = await task_scheduler_dao.get_by_name(db, obj.name)
if task_scheduler:
raise errors.ConflictError(msg='任务调度已存在')
if obj.type == TaskSchedulerType.CRONTAB:
crontab_verify(obj.crontab)
await task_scheduler_dao.create(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateTaskSchedulerParam) -> int:
"""
更新任务调度
:param db: 数据库会话
:param pk: 任务调度 ID
:param obj: 任务调度更新参数
:return:
"""
task_scheduler = await task_scheduler_dao.get(db, pk)
if not task_scheduler:
raise errors.NotFoundError(msg='任务调度不存在')
if task_scheduler.name != obj.name and await task_scheduler_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='任务调度已存在')
if obj.type == TaskSchedulerType.CRONTAB:
crontab_verify(obj.crontab)
count = await task_scheduler_dao.update(db, pk, obj)
return count
@staticmethod
async def update_status(*, db: AsyncSession, pk: int) -> int:
"""
更新任务调度状态
:param db: 数据库会话
:param pk: 任务调度 ID
:return:
"""
task_scheduler = await task_scheduler_dao.get(db, pk)
if not task_scheduler:
raise errors.NotFoundError(msg='任务调度不存在')
count = await task_scheduler_dao.set_status(db, pk, status=not task_scheduler.enabled)
return count
@staticmethod
async def delete(*, db: AsyncSession, pk: int) -> int:
"""
删除任务调度
:param db: 数据库会话
:param pk: 用户 ID
:return:
"""
task_scheduler = await task_scheduler_dao.get(db, pk)
if not task_scheduler:
raise errors.NotFoundError(msg='任务调度不存在')
count = await task_scheduler_dao.delete(db, pk)
return count
@staticmethod
async def execute(*, db: AsyncSession, pk: int) -> None:
"""
执行任务
:param db: 数据库会话
:param pk: 任务调度 ID
:return:
"""
workers = await run_in_threadpool(celery_app.control.ping, timeout=0.5)
if not workers:
raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试')
task_scheduler = await task_scheduler_dao.get(db, pk)
if not task_scheduler:
raise errors.NotFoundError(msg='任务调度不存在')
try:
args = json.loads(task_scheduler.args) if task_scheduler.args else None
kwargs = json.loads(task_scheduler.kwargs) if task_scheduler.kwargs else None
except (TypeError, json.JSONDecodeError):
raise errors.RequestError(msg='执行失败,任务参数非法')
else:
celery_app.send_task(name=task_scheduler.task, args=args, kwargs=kwargs)
task_scheduler_service: TaskSchedulerService = TaskSchedulerService()
-13
View File
@@ -1,13 +0,0 @@
from celery.backends.database.session import SessionManager as CelerySessionManager
class SessionManager(CelerySessionManager):
"""
重写 celery SessionManager
"""
def __init__(self) -> None:
super().__init__()
# 禁止自动创建 celery 内部定义的任务结果表
self.prepared = True
View File
-46
View File
@@ -1,46 +0,0 @@
import asyncio
from typing import Any
from celery import Task
from sqlalchemy.exc import SQLAlchemyError
from backend.common.socketio.actions import task_notification
from backend.core.conf import settings
class TaskBase(Task):
"""Celery 任务基类"""
autoretry_for = (SQLAlchemyError,)
max_retries = settings.CELERY_TASK_MAX_RETRIES
async def before_start(self, task_id: str, args, kwargs) -> None: # ruff:ignore[missing-type-function-argument]
"""
任务开始前执行钩子
:param task_id: 任务 ID
:return:
"""
await task_notification(msg=f'任务 {task_id} 开始执行')
async def on_success(self, retval: Any, task_id: str, args, kwargs) -> None: # ruff:ignore[missing-type-function-argument]
"""
任务成功后执行钩子
:param retval: 任务返回值
:param task_id: 任务 ID
:return:
"""
await task_notification(msg=f'任务 {task_id} 执行成功')
def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None: # ruff:ignore[missing-type-function-argument]
"""
任务失败后执行钩子
:param exc: 异常对象
:param task_id: 任务 ID
:param einfo: 异常信息
:return:
"""
asyncio.create_task(task_notification(msg=f'任务 {task_id} 执行失败'))
-34
View File
@@ -1,34 +0,0 @@
from typing import Any
from celery.schedules import schedule
from backend.app.task.utils.tzcrontab import TzAwareCrontab
def get_local_beat_schedule() -> dict[str, dict[str, Any]]:
"""获取本地 Celery beat 任务配置"""
# 参考:https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html
return {
'测试同步任务': {
'task': 'task_demo',
'schedule': schedule(30),
},
'测试异步任务': {
'task': 'task_demo_async',
'schedule': TzAwareCrontab('1'),
},
'测试传参任务': {
'task': 'task_demo_params',
'schedule': TzAwareCrontab('1'),
'args': ['你好,'],
'kwargs': {'world': '世界'},
},
'清理操作日志': {
'task': 'backend.app.task.tasks.db_log.tasks.delete_db_opera_log',
'schedule': TzAwareCrontab('0', '0', day_of_week='6'),
},
'清理登录日志': {
'task': 'backend.app.task.tasks.db_log.tasks.delete_db_login_log',
'schedule': TzAwareCrontab('0', '0', day_of_month='15'),
},
}
-21
View File
@@ -1,21 +0,0 @@
from celery import shared_task
from backend.app.admin.service.login_log_service import login_log_service
from backend.app.admin.service.opera_log_service import opera_log_service
from backend.database.db import async_db_session
@shared_task
async def delete_db_opera_log() -> str:
"""自动删除数据库操作日志"""
async with async_db_session.begin() as db:
await opera_log_service.delete_all(db=db)
return 'Success'
@shared_task
async def delete_db_login_log() -> str:
"""自动删除数据库登录日志"""
async with async_db_session.begin() as db:
await login_log_service.delete_all(db=db)
return 'Success'
-26
View File
@@ -1,26 +0,0 @@
from time import sleep
from anyio import sleep as asleep
from backend.app.task.celery import celery_app
@celery_app.task(name='task_demo')
def task_demo() -> str:
"""示例任务,模拟耗时操作"""
sleep(30)
return 'test async'
@celery_app.task(name='task_demo_async')
async def task_demo_async() -> str:
"""异步示例任务,模拟耗时操作"""
await asleep(30)
return 'test async'
@celery_app.task(name='task_demo_params')
async def task_demo_params(hello: str, world: str | None = None) -> str:
"""参数示例任务,模拟传参操作"""
await asleep(1)
return hello + world
View File
-451
View File
@@ -1,451 +0,0 @@
from __future__ import annotations
import asyncio
import json
import math
from datetime import datetime, timedelta
from multiprocessing.util import Finalize
from typing import TYPE_CHECKING, Final
from celery import current_app, schedules
from celery.beat import ScheduleEntry, Scheduler
from celery.signals import beat_init
from celery.utils.log import get_logger
from sqlalchemy import select
from sqlalchemy.exc import DatabaseError, InterfaceError
from backend.app.task.enums import PeriodType, TaskSchedulerType
from backend.app.task.model.scheduler import TaskScheduler
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam
from backend.app.task.utils.tzcrontab import TzAwareCrontab, crontab_verify
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.async_helper import run_await
from backend.utils.serializers import select_as_dict
from backend.utils.timezone import timezone
if TYPE_CHECKING:
from redis.asyncio.lock import Lock
# 此计划程序必须比常规的 5 分钟更频繁地唤醒,因为它需要考虑对计划的外部更改
_DEFAULT_MAX_INTERVAL: Final = 5 # seconds
# 计划锁时长,避免重复创建
_DEFAULT_MAX_LOCK_TIMEOUT: Final = _DEFAULT_MAX_INTERVAL * 5 # seconds
logger = get_logger('fba.schedulers')
class ModelEntry(ScheduleEntry):
"""任务调度实体"""
def __init__(self, model: TaskScheduler, app=None) -> None: # ruff:ignore[missing-type-function-argument, complex-structure]
super().__init__(
app=app or current_app._get_current_object(),
name=model.name,
task=model.task,
)
try:
if (
model.type == TaskSchedulerType.INTERVAL
and model.interval_every is not None
and model.interval_period is not None
):
self.schedule = schedules.schedule(timedelta(**{model.interval_period: model.interval_every}))
elif model.type == TaskSchedulerType.CRONTAB and model.crontab is not None:
self.schedule = TzAwareCrontab.from_string(model.crontab)
else:
raise errors.NotFoundError(msg=f'{self.name} 计划为空!')
# logger.debug('Schedule: {}'.format(self.schedule))
except Exception as e:
logger.error(f'禁用计划为空的任务 {self.name},详情:{e}')
asyncio.create_task(self._disable(model))
try:
self.args = json.loads(model.args) if model.args else None
self.kwargs = json.loads(model.kwargs) if model.kwargs else None
except ValueError as exc:
logger.error(f'禁用参数错误的任务:{self.name}error: {exc!s}')
asyncio.create_task(self._disable(model))
self.options = {}
for option in ['queue', 'exchange', 'routing_key']:
value = getattr(model, option)
if value is None:
continue
self.options[option] = value
if model.expire_seconds is not None:
self.options['expires'] = model.expire_seconds
elif model.expire_time is not None:
self.options['expires'] = timezone.from_datetime(model.expire_time)
if not model.last_run_time:
model.last_run_time = timezone.now()
if model.start_time:
model.last_run_time = timezone.from_datetime(model.start_time) - timedelta(days=365)
self.last_run_at = timezone.from_datetime(model.last_run_time)
self.options['periodic_task_name'] = model.name
self.model = model
async def _disable(self, model: TaskScheduler) -> None:
"""禁用任务"""
model.no_changes = True
self.model.enabled = self.enabled = model.enabled = False
async with async_db_session.begin() as db:
stmt = select(TaskScheduler).where(TaskScheduler.id == model.id, TaskScheduler.deleted == 0)
query = await db.execute(stmt)
task = query.scalars().first()
if task:
task.no_changes = True
task.enabled = False
def is_due(self) -> tuple[bool, int | float | datetime]:
"""任务到期状态"""
if not self.model.enabled:
# 重新启用时延迟 5 秒
return schedules.schedstate(is_due=False, next=5)
# 仅在 'start_time' 之后运行
if self.model.start_time is not None:
now = timezone.now()
start_time = timezone.from_datetime(self.model.start_time)
if now < start_time:
delay = math.ceil((start_time - now).total_seconds())
return schedules.schedstate(is_due=False, next=delay)
# 一次性任务
if self.model.one_off and self.model.enabled and self.model.total_run_count > 0:
self.model.enabled = False
self.model.total_run_count = 0
self.model.no_changes = False
save_fields = ('enabled',)
run_await(self.save)(save_fields)
return schedules.schedstate(is_due=False, next=1000000000) # 高延迟,避免重新检查
return self.schedule.is_due(self.last_run_at)
def __next__(self): # ruff:ignore[missing-return-type-special-method]
self.model.last_run_time = timezone.now()
self.model.total_run_count += 1
self.model.no_changes = True
return self.__class__(self.model)
next = __next__
async def save(self, fields: tuple = ()) -> None:
"""
保存任务状态字段
:param fields: 要保存的其他字段
:return:
"""
async with async_db_session.begin() as db:
stmt = (
select(TaskScheduler)
.where(TaskScheduler.id == self.model.id, TaskScheduler.deleted == 0)
.with_for_update()
)
query = await db.execute(stmt)
task = query.scalars().first()
if task:
for field in ['last_run_time', 'total_run_count', 'no_changes']:
setattr(task, field, getattr(self.model, field))
for field in fields:
setattr(task, field, getattr(self.model, field))
else:
logger.warning(f'任务 {self.model.name} 不存在,跳过更新')
@classmethod
async def from_entry(cls, name, app=None, **entry) -> ModelEntry: # ruff:ignore[missing-type-function-argument]
"""保存或更新本地任务调度"""
async with async_db_session.begin() as db:
stmt = select(TaskScheduler).where(TaskScheduler.name == name, TaskScheduler.deleted == 0)
query = await db.execute(stmt)
task = query.scalars().first()
temp = await cls._unpack_fields(name, **entry)
if not task:
task = TaskScheduler(**temp)
db.add(task)
else:
for key, value in temp.items():
setattr(task, key, value)
res = cls(task, app=app)
return res
@staticmethod
async def to_model_schedule(name: str, task: str, schedule: schedules.schedule | TzAwareCrontab) -> TaskScheduler:
schedule = schedules.maybe_schedule(schedule)
async with async_db_session() as db:
if isinstance(schedule, schedules.schedule):
every = max(schedule.run_every.total_seconds(), 0)
spec = {
'name': name,
'type': TaskSchedulerType.INTERVAL.value,
'interval_every': every,
'interval_period': PeriodType.SECONDS.value,
}
stmt = select(TaskScheduler).filter_by(**spec, deleted=0)
query = await db.execute(stmt)
obj = query.scalars().first()
if not obj:
obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump())
elif isinstance(schedule, schedules.crontab):
crontab = f'{schedule._orig_minute} {schedule._orig_hour} {schedule._orig_day_of_month} {schedule._orig_month_of_year} {schedule._orig_day_of_week}' # ruff:ignore[line-too-long]
crontab_verify(crontab)
spec = {
'name': name,
'type': TaskSchedulerType.CRONTAB.value,
'crontab': crontab,
}
stmt = select(TaskScheduler).filter_by(**spec, deleted=0)
query = await db.execute(stmt)
obj = query.scalars().first()
if not obj:
obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump())
else:
raise errors.NotFoundError(msg=f'暂不支持的计划类型:{schedule}')
return obj
@classmethod
async def _unpack_fields(
cls,
name: str,
task: str,
schedule: schedules.schedule | TzAwareCrontab,
args: tuple | None = None,
kwargs: dict | None = None,
options: dict | None = None,
**entry,
) -> dict:
model_schedule = await cls.to_model_schedule(name, task, schedule)
model_dict = select_as_dict(model_schedule)
for k in ['id', 'created_time', 'updated_time', 'deleted', 'deleted_time']:
try:
del model_dict[k]
except KeyError: # ruff:ignore[try-except-in-loop]
continue
model_dict.update(
args=json.dumps(args, ensure_ascii=False) if args else None,
kwargs=json.dumps(kwargs, ensure_ascii=False) if kwargs else None,
**cls._unpack_options(**options or {}),
**entry,
)
return model_dict
@classmethod
def _unpack_options(
cls,
queue: str | None = None,
exchange: str | None = None,
routing_key: str | None = None,
start_time: datetime | None = None,
expires: datetime | None = None,
expire_seconds: int | None = None,
*,
one_off: bool = False,
) -> dict:
data = {
'queue': queue,
'exchange': exchange,
'routing_key': routing_key,
'start_time': start_time,
'expire_time': None,
'expire_seconds': expire_seconds,
'one_off': one_off,
}
if expires:
if isinstance(expires, int):
data['expire_seconds'] = expires
elif isinstance(expires, timedelta):
data['expire_time'] = timezone.now() + expires
elif isinstance(expires, datetime):
data['expire_time'] = expires
return data
class DatabaseScheduler(Scheduler):
"""数据库调度程序"""
Entry = ModelEntry
_schedule = None
_last_update = None
_initial_read = True
_heap_invalidated = False
lock: Lock | None = None
lock_key = f'{settings.CELERY_REDIS_PREFIX}:beat_lock'
def __init__(self, *args, **kwargs) -> None:
self.app = kwargs['app']
self._dirty = set()
super().__init__(*args, **kwargs)
self._finalize = Finalize(self, self.sync, exitpriority=5)
self.max_interval = kwargs.get('max_interval') or self.app.conf.beat_max_loop_interval or _DEFAULT_MAX_INTERVAL
def schedules_equal(self, *args, **kwargs) -> bool:
"""重写父函数"""
if self._heap_invalidated:
self._heap_invalidated = False
return False
return super().schedules_equal(*args, **kwargs)
def reserve(self, entry): # ruff:ignore[missing-type-function-argument, missing-return-type-undocumented-public-function]
"""重写父函数"""
new_entry = next(entry)
# 需要按名称存储条目,因为条目可能会发生变化
self._dirty.add(new_entry.name)
return new_entry
def setup_schedule(self) -> None:
"""重写父函数"""
logger.info('setup_schedule')
tasks = self.schedule
self.install_default_entries(tasks)
self.update_from_dict(self.app.conf.beat_schedule)
def sync(self) -> None:
"""重写父函数"""
tried = set()
failed = set()
try:
while self._dirty:
name = self._dirty.pop()
try:
tasks = self.schedule
run_await(tasks[name].save)()
logger.debug(f'保存任务 {name} 最新状态到数据库')
tried.add(name)
except KeyError as e:
logger.error(f'保存任务 {name} 最新状态失败:{e} ')
failed.add(name)
except DatabaseError:
logger.exception('同步时出现数据库错误')
except InterfaceError as e:
logger.warning(f'DatabaseScheduler InterfaceError{e!s},等待下次调用时重试...')
finally:
# 请稍后重试(仅针对失败的)
self._dirty |= failed
def tick(self, **kwargs) -> float:
"""重写父函数"""
if self.lock:
logger.debug('beat: Extending lock...')
run_await(self.lock.extend)(_DEFAULT_MAX_LOCK_TIMEOUT, replace_ttl=True)
return super().tick(**kwargs)
def close(self) -> None:
"""重写父函数"""
if self.lock:
logger.info('beat: Releasing lock')
if run_await(self.lock.owned)():
run_await(self.lock.release)()
self.lock = None
super().close()
def update_from_dict(self, beat_dict: dict) -> None:
"""重写父函数"""
s = {}
name = None
try:
for name, entry_fields in beat_dict.items():
entry = run_await(self.Entry.from_entry)(name, app=self.app, **entry_fields)
if entry.model.enabled:
s[name] = entry
except Exception:
logger.error(f'添加任务 {name} 到数据库失败')
raise
tasks = self.schedule
tasks.update(s)
def schedule_changed(self) -> bool | None:
"""任务调度变更状态"""
now = timezone.now()
last_update = run_await(redis_client.get)(f'{settings.CELERY_REDIS_PREFIX}:last_update')
if not last_update:
run_await(redis_client.set)(f'{settings.CELERY_REDIS_PREFIX}:last_update', timezone.to_str(now))
return False
last, ts = self._last_update, timezone.from_str(last_update)
try:
if ts and ts > (last or ts):
return True
finally:
self._last_update = now
async def get_all_task_schedulers(self) -> dict:
"""获取所有任务调度"""
async with async_db_session() as db:
logger.debug('DatabaseScheduler: Fetching database schedule')
stmt = select(TaskScheduler).where(
TaskScheduler.enabled.is_(True),
TaskScheduler.deleted == 0,
)
query = await db.execute(stmt)
schedulers = query.scalars().all()
s = {}
for scheduler in schedulers:
s[scheduler.name] = self.Entry(scheduler, app=self.app)
return s
@property
def schedule(self) -> dict[str, ModelEntry]:
"""获取任务调度"""
initial = update = False
if self._initial_read:
logger.debug('DatabaseScheduler: initial read')
initial = update = True
self._initial_read = False
elif self.schedule_changed():
logger.info('DatabaseScheduler: Schedule changed.')
update = True
if update:
logger.debug('beat: Synchronizing schedule...')
self.sync()
self._schedule = run_await(self.get_all_task_schedulers)()
# 计划已更改,使 Scheduler.tick 中的堆无效
if not initial:
self._heap = []
self._heap_invalidated = True
logger.debug(
'Current schedule:\n%s',
'\n'.join(repr(entry) for entry in self._schedule.values()),
)
# logger.debug(self._schedule)
return self._schedule
@beat_init.connect
def acquire_distributed_beat_lock(sender=None, **kwargs) -> None: # ruff:ignore[missing-type-function-argument]
"""
尝试在启动时获取锁
:param sender: 接收方应响应的发送方
:return:
"""
scheduler = sender.scheduler
if not scheduler.lock_key:
return
logger.debug('beat: Acquiring lock...')
lock = redis_client.lock(
scheduler.lock_key,
timeout=_DEFAULT_MAX_LOCK_TIMEOUT,
sleep=scheduler.max_interval,
)
run_await(lock.acquire)()
logger.info('beat: Acquired lock')
scheduler.lock = lock
-36
View File
@@ -1,36 +0,0 @@
from celery import schedules
from celery.schedules import ParseException
from backend.common.exception import errors
from backend.utils.timezone import timezone
class TzAwareCrontab(schedules.crontab):
"""时区感知 Crontab"""
def __init__(self, minute='*', hour='*', day_of_week='*', day_of_month='*', month_of_year='*', app=None) -> None: # ruff:ignore[missing-type-function-argument]
super().__init__(
minute=minute,
hour=hour,
day_of_week=day_of_week,
day_of_month=day_of_month,
month_of_year=month_of_year,
nowfun=timezone.now,
app=app,
)
def crontab_verify(crontab: str) -> None:
"""
验证标准 crontab 表达式
:param crontab: 标准 crontab 表达式
:return:
"""
crontab_split = crontab.split(' ')
if len(crontab_split) != 5:
raise errors.RequestError(msg='Crontab 表达式非法')
try:
TzAwareCrontab.from_string(crontab)
except (ParseException, ValueError):
raise errors.RequestError(msg='Crontab 表达式非法')
+2 -212
View File
@@ -6,7 +6,7 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Annotated, Final, Literal from typing import Annotated, Final
import anyio import anyio
import cappa import cappa
@@ -330,37 +330,6 @@ def run(host: str, port: int, reload: bool, workers: int) -> None: # ruff:ignor
).serve() ).serve()
def run_celery_worker(log_level: Literal['info', 'debug']) -> None:
"""启动 Celery worker 服务"""
try:
subprocess.run(['celery', '-A', 'backend.app.task.celery', 'worker', '-l', f'{log_level}', '-P', 'gevent'])
except KeyboardInterrupt:
pass
def run_celery_beat(log_level: Literal['info', 'debug']) -> None:
"""启动 Celery beat 定时任务服务"""
try:
subprocess.run(['celery', '-A', 'backend.app.task.celery', 'beat', '-l', f'{log_level}'])
except KeyboardInterrupt:
pass
def run_celery_flower(port: int, basic_auth: str) -> None:
"""启动 Celery flower 监控服务"""
try:
subprocess.run([
'celery',
'-A',
'backend.app.task.celery',
'flower',
f'--port={port}',
f'--basic-auth={basic_auth}',
])
except KeyboardInterrupt:
pass
async def install_plugin( # ruff:ignore[complex-structure] async def install_plugin( # ruff:ignore[complex-structure]
path: str | None, path: str | None,
repo_url: str | None, repo_url: str | None,
@@ -584,104 +553,6 @@ async def execute_destroy_sql_scripts(db: AsyncSession, sql_scripts: str) -> Non
console.tip('销毁 SQL 脚本已执行完成') console.tip('销毁 SQL 脚本已执行完成')
async def import_table(
app: str,
table_schema: str,
table_name: str,
) -> None:
"""导入代码生成业务和模型列"""
if settings.ENVIRONMENT != 'dev':
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
try:
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try:
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
async with async_db_session.begin() as db:
await gen_service.import_business_and_model(db=db, obj=obj)
console.tip('代码生成业务和模型列导入成功')
console.log('\n快试试 [bold cyan]fba codegen[/bold cyan] 生成代码吧~')
except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
async def generate(*, preview: bool = False) -> None:
"""交互式代码生成"""
if settings.ENVIRONMENT != 'dev':
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
try:
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.gen_service import gen_service
except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try:
ids = []
async with async_db_session() as db:
results = await gen_business_service.get_all(db=db)
if not results:
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
table = Table(show_header=True, header_style='bold magenta')
table.add_column('业务编号', style='cyan', no_wrap=True, justify='center')
table.add_column('应用名称', style='green', no_wrap=True)
table.add_column('生成路径', style='yellow')
table.add_column('备注', style='blue')
for result in results:
ids.append(result.id)
table.add_row(
str(result.id),
result.app_name,
result.gen_path or f'应用 {result.app_name} 根路径',
result.remark or '',
)
console.print(table)
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(id_) for id_ in ids])
# 预览
async with async_db_session() as db:
preview_data = await gen_service.preview(db=db, pk=business)
console.print('\n[bold yellow]将要生成以下文件:[/]')
file_table = Table(show_header=True, header_style='bold cyan')
file_table.add_column('文件路径', style='white')
file_table.add_column('大小', style='green', justify='right')
for filepath, content in sorted(preview_data.items()):
size = len(content)
size_str = f'{size} B' if size < 1024 else f'{size / 1024:.1f} KB'
file_table.add_row(filepath, size_str)
console.print(file_table)
if preview:
console.print('\n[bold cyan]预览模式:未执行实际生成操作[/]')
return
# 生成
console.print('\n[bold red]警告:代码生成将进行磁盘文件(覆盖)写入,切勿在生产环境中使用!!![/]')
ok = Prompt.ask('\n确认继续生成代码吗?', choices=['y', 'n'], default='n')
if ok.lower() == 'y':
async with async_db_session.begin() as db:
gen_path = await gen_service.generate(db=db, pk=business)
console.print()
console.tip('代码已生成完成')
console.print(Text('\n详情请查看:'), Text(str(gen_path), style='bold white'))
except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
def run_alembic(*args: str) -> None: def run_alembic(*args: str) -> None:
"""执行 alembic 命令""" """执行 alembic 命令"""
try: try:
@@ -814,85 +685,6 @@ class Format:
pass pass
@cappa.command(help='从当前主机启动 Celery worker 服务', default_long=True)
@dataclass
class Worker:
log_level: Annotated[
Literal['info', 'debug'],
cappa.Arg(short='-l', default='info', help='日志输出级别'),
]
def __call__(self) -> None:
run_celery_worker(log_level=self.log_level)
@cappa.command(help='从当前主机启动 Celery beat 服务', default_long=True)
@dataclass
class Beat:
log_level: Annotated[
Literal['info', 'debug'],
cappa.Arg(short='-l', default='info', help='日志输出级别'),
]
def __call__(self) -> None:
run_celery_beat(log_level=self.log_level)
@cappa.command(help='从当前主机启动 Celery flower 服务', default_long=True)
@dataclass
class Flower:
port: Annotated[
int,
cappa.Arg(default=8555, help='提供服务的主机端口号'),
]
basic_auth: Annotated[
str,
cappa.Arg(default='admin:123456', help='页面登录的用户名和密码'),
]
def __call__(self) -> None:
run_celery_flower(port=self.port, basic_auth=self.basic_auth)
@cappa.command(help='运行 Celery 服务')
@dataclass
class Celery:
subcmd: cappa.Subcommands[Worker | Beat | Flower]
@cappa.command(help='导入代码生成业务和模型列', default_long=True)
@dataclass
class Import:
app: Annotated[
str,
cappa.Arg(help='应用名称,用于代码生成到指定 app'),
]
table_schema: Annotated[
str,
cappa.Arg(short='tc', default='fba', help='数据库名'),
]
table_name: Annotated[
str,
cappa.Arg(short='tn', help='数据库表名'),
]
async def __call__(self) -> None:
await import_table(self.app, self.table_schema, self.table_name)
@cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True)
@dataclass
class CodeGenerator:
preview: Annotated[
bool,
cappa.Arg(short='-p', default=False, help='仅预览将要生成的文件,不执行实际生成操作'),
]
subcmd: cappa.Subcommands[Import | None] = None
async def __call__(self) -> None:
await generate(preview=self.preview)
@cappa.command(help='生成数据库迁移文件', default_long=True) @cappa.command(help='生成数据库迁移文件', default_long=True)
@dataclass @dataclass
class Revision: class Revision:
@@ -1005,9 +797,7 @@ class FbaCli:
str, str,
cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'), cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'),
] ]
subcmd: cappa.Subcommands[Init | Run | Add | Remove | Deps | Format | Celery | CodeGenerator | Alembic | None] = ( subcmd: cappa.Subcommands[Init | Run | Add | Remove | Deps | Format | Alembic | None] = None
None
)
async def __call__(self) -> None: async def __call__(self) -> None:
if self.sql: if self.sql:
-64
View File
@@ -32,70 +32,6 @@ class StrEnum(_EnumBase, str, Enum):
"""字符串枚举基类""" """字符串枚举基类"""
class MenuType(IntEnum):
"""菜单类型"""
directory = 0
menu = 1
button = 2
embedded = 3
link = 4
class RoleDataRuleOperatorType(IntEnum):
"""数据规则运算符"""
AND = 0
OR = 1
class RoleDataRuleExpressionType(IntEnum):
"""数据规则表达式"""
eq = 0 # ==
ne = 1 # !=
gt = 2 # >
ge = 3 # >=
lt = 4 # <
le = 5 # <=
in_ = 6
not_in = 7
class MethodType(StrEnum):
"""HTTP 请求方法"""
GET = 'GET'
POST = 'POST'
PUT = 'PUT'
DELETE = 'DELETE'
PATCH = 'PATCH'
OPTIONS = 'OPTIONS'
class LoginLogStatusType(IntEnum):
"""登录日志状态"""
fail = 0
success = 1
class BuildTreeType(StrEnum):
"""构建树形结构类型"""
traversal = 'traversal'
recursive = 'recursive'
class OperaLogCipherType(IntEnum):
"""操作日志加密类型"""
aes = 0
md5 = 1
itsdangerous = 2
plain = 3
class StatusType(IntEnum): class StatusType(IntEnum):
"""状态类型""" """状态类型"""

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