mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-23 21:38:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b775dd3a16 |
+3
-50
@@ -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}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|
||||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
|
||||||
[](https://www.python.org/downloads/)
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
[](https://pydantic.dev)
|
|
||||||
[](https://github.com/astral-sh/ruff)
|
|
||||||
[](https://github.com/astral-sh/uv)
|
|
||||||

|
|
||||||
[](https://discord.com/invite/yNN3wTbVAC)
|
|
||||||

|
|
||||||
[](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
|
|
||||||
|
|
||||||
[](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
|
|
||||||
|
|||||||
+4
-64
@@ -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)
|
|
||||||
|
|
||||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
|
||||||
[](https://www.python.org/downloads/)
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
[](https://pydantic.dev)
|
|
||||||
[](https://github.com/astral-sh/ruff)
|
|
||||||
[](https://github.com/astral-sh/uv)
|
|
||||||

|
|
||||||
[](https://discord.com/invite/yNN3wTbVAC)
|
|
||||||

|
|
||||||
[](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) 许可证的条款进行许可
|
|
||||||
|
|
||||||
[](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
|
|
||||||
|
|||||||
+304
@@ -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,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=''
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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=['操作日志'])
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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=['系统插件'])
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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='运算符(0:and、1:or)')
|
|
||||||
expression: Mapped[int] = mapped_column(
|
|
||||||
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)',
|
|
||||||
)
|
|
||||||
value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
|
|
||||||
@@ -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正常)')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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='创建时间',
|
|
||||||
)
|
|
||||||
@@ -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'),
|
|
||||||
)
|
|
||||||
@@ -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')
|
|
||||||
@@ -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='创建时间'
|
|
||||||
)
|
|
||||||
@@ -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='备注')
|
|
||||||
@@ -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')
|
|
||||||
|
|||||||
@@ -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='变量描述')
|
|
||||||
@@ -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='数据规则列表')
|
|
||||||
@@ -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='子菜单')
|
|
||||||
@@ -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='创建时间')
|
|
||||||
@@ -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='子菜单')
|
|
||||||
@@ -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='命令统计')
|
|
||||||
@@ -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='创建时间')
|
|
||||||
@@ -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='数据范围列表')
|
|
||||||
@@ -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,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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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')
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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'
|
|
||||||
@@ -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
|
|
||||||
@@ -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__
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from backend.common.schema import SchemaBase
|
|
||||||
|
|
||||||
|
|
||||||
class TaskRegisteredDetail(SchemaBase):
|
|
||||||
name: str
|
|
||||||
task: str
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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} 执行失败'))
|
|
||||||
@@ -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'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user