mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-24 05:46:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123a44aed0 | ||
|
|
9ec613eddc | ||
|
|
8d9a79dee4 | ||
|
|
935c49487e | ||
|
|
54a438c762 | ||
|
|
7ab3bb81dd | ||
|
|
2054c5bdc4 | ||
|
|
110a9c2e0c | ||
|
|
c5c043052c | ||
|
|
c5f6362e67 | ||
|
|
cd0d1f3ab8 | ||
|
|
d47534ad8b | ||
|
|
315f8a55cc | ||
|
|
5a658c44f8 | ||
|
|
5ae7afaf29 | ||
|
|
ae6d165e98 | ||
|
|
e5aa1ccea7 | ||
|
|
ee553f8b67 | ||
|
|
a38bd0b4a6 | ||
|
|
b58cca4788 |
+50
-3
@@ -1,19 +1,26 @@
|
|||||||
# Python environment from uv
|
# Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args
|
||||||
|
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 \
|
||||||
@@ -23,8 +30,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)"
|
||||||
|
|
||||||
# Single FastAPI server image
|
# === Runtime base server image ===
|
||||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim
|
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS base_server
|
||||||
|
|
||||||
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 \
|
||||||
@@ -32,10 +39,50 @@ 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,7 +1,85 @@
|
|||||||
# FastAPI Best Architecture Slim
|
<div align="center">
|
||||||
|
|
||||||
专注于基本架构的 API 开发简化版本
|
<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">
|
||||||
|
|
||||||
完整版请查看 [FastAPI Best Architecture](https://github.com/fastapi-practices/fastapi-best-architecture)
|
# FastAPI Best Architecture
|
||||||
|
|
||||||
简体中文 | [English](./README.zh-CN.md)
|
Enterprise-level backend architecture solution
|
||||||
|
|
||||||
|
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/)
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://claude.uy/home">
|
||||||
|
<img src="https://purple-sun-4f5a.wuyao1243.workers.dev/" alt="Claude.uy" width="400">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|||||||
+78
-4
@@ -1,7 +1,81 @@
|
|||||||
# FastAPI Best Architecture Slim
|
<div align="center">
|
||||||
|
|
||||||
A simplified version focused on basic architecture for API development
|
<img alt="Logo 包含了 FBA 三个字母抽象结合,形成了一个类似从地面扩散投影上来的闪电" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
|
||||||
|
|
||||||
See the [full FastAPI Best Architecture project](https://github.com/fastapi-practices/fastapi-best-architecture)
|
# 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/)
|
||||||
|
|
||||||
|
## 赞助商
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://claude.uy/home">
|
||||||
|
<img src="https://purple-sun-4f5a.wuyao1243.workers.dev/" alt="Claude.uy" width="400">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
<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
@@ -1,304 +0,0 @@
|
|||||||
# 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,3 +16,19 @@ 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=''
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
alembic/versions/
|
alembic/versions/
|
||||||
static/media/
|
static/media/
|
||||||
|
static/ai_buddy/
|
||||||
*.log
|
*.log
|
||||||
celerybeat-schedule.*
|
celerybeat-schedule.*
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -8,3 +10,5 @@ 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,11 +3,13 @@ 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
|
||||||
|
|
||||||
@@ -32,11 +34,18 @@ 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)
|
data = await auth_service.login(db=db, response=response, obj=obj, background_tasks=background_tasks)
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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=['操作日志'])
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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=['会话监控'])
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query
|
||||||
|
|
||||||
|
from backend.app.admin.schema.token import GetTokenDetail
|
||||||
|
from backend.common.enums import StatusType
|
||||||
|
from backend.common.exception import errors
|
||||||
|
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||||
|
from backend.common.security.jwt import DependsSuperUser, jwt_decode
|
||||||
|
from backend.common.security.token import revoke_token
|
||||||
|
from backend.core.conf import settings
|
||||||
|
from backend.database.redis import redis_client
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from backend.common.dataclasses import TokenPayload
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('', summary='获取在线用户', dependencies=[DependsSuperUser])
|
||||||
|
async def get_sessions( # ruff:ignore[complex-structure]
|
||||||
|
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||||
|
) -> ResponseSchemaModel[list[GetTokenDetail]]:
|
||||||
|
users_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users'
|
||||||
|
user_ids = list(await redis_client.smembers(users_key))
|
||||||
|
if not user_ids:
|
||||||
|
return response_base.success(data=[])
|
||||||
|
|
||||||
|
session_sets = await redis_client.smembers_many([
|
||||||
|
f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}' for user_id in user_ids
|
||||||
|
])
|
||||||
|
session_refs: list[tuple[str, str]] = []
|
||||||
|
for user_id, members in zip(user_ids, session_sets, strict=True):
|
||||||
|
session_refs.extend((user_id, session_uuid) for session_uuid in members)
|
||||||
|
if not session_refs:
|
||||||
|
await redis_client.srem(users_key, *user_ids)
|
||||||
|
return response_base.success(data=[])
|
||||||
|
|
||||||
|
tokens = await redis_client.mget_batched([
|
||||||
|
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}' for user_id, session_uuid in session_refs
|
||||||
|
])
|
||||||
|
token_payloads: list[TokenPayload] = []
|
||||||
|
live_user_ids: set[str] = set()
|
||||||
|
for (user_id, _session_uuid), token in zip(session_refs, tokens, strict=True):
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
token_payloads.append(jwt_decode(token))
|
||||||
|
except errors.TokenError:
|
||||||
|
continue
|
||||||
|
live_user_ids.add(user_id)
|
||||||
|
stale_user_ids = [user_id for user_id in user_ids if user_id not in live_user_ids]
|
||||||
|
if stale_user_ids:
|
||||||
|
await redis_client.srem(users_key, *stale_user_ids)
|
||||||
|
if not token_payloads:
|
||||||
|
return response_base.success(data=[])
|
||||||
|
|
||||||
|
extra_infos = await redis_client.mget_batched([
|
||||||
|
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{item.user_id}:{item.session_uuid}' for item in token_payloads
|
||||||
|
])
|
||||||
|
sid_sets = await redis_client.smembers_many([
|
||||||
|
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{item.session_uuid}' for item in token_payloads
|
||||||
|
])
|
||||||
|
sid_list = [sid for members in sid_sets for sid in members]
|
||||||
|
sid_values = await redis_client.mget_batched([
|
||||||
|
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}' for sid in sid_list
|
||||||
|
])
|
||||||
|
sid_session_map = dict(zip(sid_list, sid_values, strict=True))
|
||||||
|
online_sessions = {
|
||||||
|
item.session_uuid
|
||||||
|
for item, members in zip(token_payloads, sid_sets, strict=True)
|
||||||
|
if any(sid_session_map.get(sid) == item.session_uuid for sid in members)
|
||||||
|
}
|
||||||
|
data: list[GetTokenDetail] = []
|
||||||
|
for token_payload, extra_info in zip(token_payloads, extra_infos, strict=True):
|
||||||
|
info: dict[str, Any] = {}
|
||||||
|
if extra_info:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(extra_info)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
info = parsed
|
||||||
|
if info.get('swagger') is not None:
|
||||||
|
continue
|
||||||
|
if username is not None and username != info.get('username'):
|
||||||
|
continue
|
||||||
|
data.append(
|
||||||
|
GetTokenDetail(
|
||||||
|
id=token_payload.user_id,
|
||||||
|
session_uuid=token_payload.session_uuid,
|
||||||
|
username=info.get('username', '未知'),
|
||||||
|
nickname=info.get('nickname', '未知'),
|
||||||
|
ip=info.get('ip', '未知'),
|
||||||
|
os=info.get('os', '未知'),
|
||||||
|
browser=info.get('browser', '未知'),
|
||||||
|
device=info.get('device', '未知'),
|
||||||
|
status=StatusType.enable if token_payload.session_uuid in online_sessions else StatusType.disable,
|
||||||
|
last_login_time=info.get('last_login_time', '未知'),
|
||||||
|
expire_time=token_payload.expire_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data.sort(key=lambda item: (item.id, item.session_uuid))
|
||||||
|
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()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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,11 +1,21 @@
|
|||||||
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=['系统插件'])
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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,10 +1,11 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, File, UploadFile
|
from fastapi import APIRouter, Depends, 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.jwt import DependsJwtAuth
|
from backend.common.security.permission import RequestPermission
|
||||||
|
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()
|
||||||
@@ -13,7 +14,10 @@ router = APIRouter()
|
|||||||
@router.post(
|
@router.post(
|
||||||
'/upload',
|
'/upload',
|
||||||
summary='本地文件上传',
|
summary='本地文件上传',
|
||||||
dependencies=[DependsJwtAuth],
|
dependencies=[
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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,10 +1,12 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Path, Query, Request
|
from fastapi import APIRouter, Body, Depends, 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,
|
||||||
GetUserInfoDetail,
|
GetCurrentUserInfoWithRelationDetail,
|
||||||
|
GetUserInfoWithRelationDetail,
|
||||||
ResetPasswordParam,
|
ResetPasswordParam,
|
||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
)
|
)
|
||||||
@@ -13,13 +15,15 @@ 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[GetUserInfoDetail]:
|
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
|
||||||
data = request.user.model_dump()
|
data = request.user.model_dump()
|
||||||
return response_base.success(data=data)
|
return response_base.success(data=data)
|
||||||
|
|
||||||
@@ -28,11 +32,19 @@ async def get_current_user(request: Request) -> ResponseSchemaModel[GetUserInfoD
|
|||||||
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[GetUserInfoDetail]:
|
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
|
||||||
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='分页获取所有用户',
|
||||||
@@ -43,16 +55,19 @@ async def get_userinfo(
|
|||||||
)
|
)
|
||||||
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[GetUserInfoDetail]]:
|
) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
|
||||||
page_data = await user_service.get_list(db=db, username=username, phone=phone, status=status)
|
page_data = await user_service.get_list(db=db, dept=dept, 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(db: CurrentSessionTransaction, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoDetail]:
|
async def create_user(
|
||||||
|
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)
|
||||||
@@ -133,9 +148,10 @@ 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, email=email)
|
count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email)
|
||||||
if count > 0:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
@@ -144,7 +160,10 @@ async def update_user_email(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
path='/{pk}',
|
path='/{pk}',
|
||||||
summary='删除用户',
|
summary='删除用户',
|
||||||
dependencies=[DependsSuperUser],
|
dependencies=[
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 CRUDOperaLog(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: CRUDOperaLog = CRUDOperaLog(OperaLog)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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,14 +1,35 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from sqlalchemy import Select
|
from sqlalchemy import Select, and_, delete, insert, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy_crud_plus import CRUDPlus
|
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||||
|
|
||||||
from backend.app.admin.model import User
|
from backend.app.admin.model import (
|
||||||
from backend.app.admin.schema.user import AddUserParam, UpdateUserParam
|
DataRule,
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -65,10 +86,11 @@ 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, username: str | None, phone: str | None, status: int | None) -> Select:
|
async def get_select(self, dept: int | None, 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: 用户状态
|
||||||
@@ -76,6 +98,8 @@ 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:
|
||||||
@@ -83,7 +107,24 @@ 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('id', 'desc', **filters)
|
return await self.select_order(
|
||||||
|
'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:
|
||||||
"""
|
"""
|
||||||
@@ -96,12 +137,44 @@ 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()
|
dict_obj = obj.model_dump(exclude={'roles'})
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
更新用户信息
|
更新用户信息
|
||||||
@@ -111,7 +184,23 @@ 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:
|
||||||
@@ -234,6 +323,17 @@ 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,
|
||||||
@@ -245,5 +345,74 @@ 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,2 +1,13 @@
|
|||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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='规则值')
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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正常)')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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')
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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='创建时间',
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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'),
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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='创建时间'
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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,3 +39,6 @@ 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')
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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='变量描述')
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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 DataScopeSchemaBase(SchemaBase):
|
||||||
|
"""数据范围基础模型"""
|
||||||
|
|
||||||
|
name: str = Field(description='名称')
|
||||||
|
status: StatusType = Field(description='状态')
|
||||||
|
|
||||||
|
|
||||||
|
class CreateDataScopeParam(DataScopeSchemaBase):
|
||||||
|
"""创建数据范围参数"""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDataScopeParam(DataScopeSchemaBase):
|
||||||
|
"""更新数据范围参数"""
|
||||||
|
|
||||||
|
|
||||||
|
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(DataScopeSchemaBase):
|
||||||
|
"""数据范围详情"""
|
||||||
|
|
||||||
|
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='数据规则列表')
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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='子菜单')
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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='创建时间')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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='子菜单')
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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='命令统计')
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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='创建时间')
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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,8 +1,11 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer
|
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer, model_validator
|
||||||
|
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
|
||||||
|
|
||||||
@@ -27,6 +30,24 @@ 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):
|
||||||
@@ -40,6 +61,7 @@ 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='头像地址')
|
||||||
@@ -50,12 +72,15 @@ 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='状态')
|
||||||
@@ -64,3 +89,33 @@ 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
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ from pydantic import Field
|
|||||||
from backend.common.schema import SchemaBase
|
from backend.common.schema import SchemaBase
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordHistoryBase(SchemaBase):
|
class UserPasswordHistorySchemaBase(SchemaBase):
|
||||||
"""用户历史密码记录基础模型"""
|
"""用户历史密码记录基础模型"""
|
||||||
|
|
||||||
user_id: int = Field(description='用户 ID')
|
user_id: int = Field(description='用户 ID')
|
||||||
password: str = Field(description='历史密码')
|
password: str = Field(description='历史密码')
|
||||||
|
|
||||||
|
|
||||||
class CreateUserPasswordHistoryParam(UserPasswordHistoryBase):
|
class CreateUserPasswordHistoryParam(UserPasswordHistorySchemaBase):
|
||||||
"""创建用户历史密码记录"""
|
"""创建用户历史密码记录"""
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
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
|
||||||
from backend.common.response.response_code import CustomErrorCode
|
from backend.common.response.response_code import CustomErrorCode
|
||||||
from backend.common.security.jwt import (
|
from backend.common.security.jwt import jwt_decode
|
||||||
|
from backend.common.security.token import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_new_token,
|
create_new_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
get_token,
|
get_token,
|
||||||
jwt_decode,
|
get_user_sessions,
|
||||||
|
revoke_token,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -30,7 +37,7 @@ class AuthService:
|
|||||||
"""认证服务类"""
|
"""认证服务类"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
|
async def user_verify(*, db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
|
||||||
"""
|
"""
|
||||||
验证用户名和密码
|
验证用户名和密码
|
||||||
|
|
||||||
@@ -42,9 +49,7 @@ class AuthService:
|
|||||||
user = await user_dao.get_by_username(db, username)
|
user = await user_dao.get_by_username(db, username)
|
||||||
if not user:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户名或密码有误')
|
raise errors.NotFoundError(msg='用户名或密码有误')
|
||||||
|
|
||||||
await password_security_service.check_status(user.id, user.status)
|
await password_security_service.check_status(user.id, user.status)
|
||||||
|
|
||||||
if user.password is None or not password_verify(password, user.password):
|
if user.password is None or not password_verify(password, user.password):
|
||||||
await password_security_service.handle_login_failure(db, user.id)
|
await password_security_service.handle_login_failure(db, user.id)
|
||||||
raise errors.AuthorizationError(msg='用户名或密码有误')
|
raise errors.AuthorizationError(msg='用户名或密码有误')
|
||||||
@@ -52,7 +57,6 @@ class AuthService:
|
|||||||
days_remaining = await password_security_service.check_password_expiry_status(
|
days_remaining = await password_security_service.check_password_expiry_status(
|
||||||
db, user.last_password_changed_time
|
db, user.last_password_changed_time
|
||||||
)
|
)
|
||||||
|
|
||||||
await password_security_service.handle_login_success(user.id)
|
await password_security_service.handle_login_success(user.id)
|
||||||
|
|
||||||
return user, days_remaining
|
return user, days_remaining
|
||||||
@@ -65,12 +69,11 @@ class AuthService:
|
|||||||
:param obj: 登录凭证
|
:param obj: 登录凭证
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
user, _ = await self.user_verify(db, obj.username, obj.password)
|
user, _ = await self.user_verify(db=db, username=obj.username, password=obj.password)
|
||||||
await user_dao.update_login_time(db, obj.username)
|
await user_dao.update_login_time(db, obj.username)
|
||||||
access_token_data = await create_access_token(
|
access_token_data = await create_access_token(
|
||||||
user.id,
|
user.id,
|
||||||
multi_login=user.is_multi_login,
|
multi_login=user.is_multi_login,
|
||||||
# extra info
|
|
||||||
swagger=True,
|
swagger=True,
|
||||||
)
|
)
|
||||||
return access_token_data.access_token, user
|
return access_token_data.access_token, user
|
||||||
@@ -81,6 +84,7 @@ class AuthService:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
response: Response,
|
response: Response,
|
||||||
obj: AuthLoginParam,
|
obj: AuthLoginParam,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
) -> GetLoginToken:
|
) -> GetLoginToken:
|
||||||
"""
|
"""
|
||||||
用户登录
|
用户登录
|
||||||
@@ -88,6 +92,7 @@ class AuthService:
|
|||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:param response: 响应对象
|
:param response: 响应对象
|
||||||
:param obj: 登录参数
|
:param obj: 登录参数
|
||||||
|
:param background_tasks: 后台任务
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
user = None
|
user = None
|
||||||
@@ -96,14 +101,15 @@ class AuthService:
|
|||||||
if settings.LOGIN_CAPTCHA_ENABLED:
|
if settings.LOGIN_CAPTCHA_ENABLED:
|
||||||
if not obj.uuid or not obj.captcha:
|
if not obj.uuid or not obj.captcha:
|
||||||
raise errors.RequestError(msg=t('error.captcha.invalid'))
|
raise errors.RequestError(msg=t('error.captcha.invalid'))
|
||||||
captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
|
captcha_key = f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}'
|
||||||
|
captcha_code = await redis_client.get(captcha_key)
|
||||||
if not captcha_code:
|
if not captcha_code:
|
||||||
raise errors.RequestError(msg=t('error.captcha.expired'))
|
raise errors.RequestError(msg=t('error.captcha.expired'))
|
||||||
if captcha_code.lower() != obj.captcha.lower():
|
if captcha_code.lower() != obj.captcha.lower():
|
||||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||||
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
|
await redis_client.delete(captcha_key)
|
||||||
|
|
||||||
user, days_remaining = await self.user_verify(db, obj.username, obj.password)
|
user, days_remaining = await self.user_verify(db=db, username=obj.username, password=obj.password)
|
||||||
await user_dao.update_login_time(db, obj.username)
|
await user_dao.update_login_time(db, obj.username)
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
access_token_data = await create_access_token(
|
access_token_data = await create_access_token(
|
||||||
@@ -136,11 +142,27 @@ 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}')
|
||||||
raise errors.RequestError(code=e.code, msg=e.msg)
|
task = BackgroundTask(
|
||||||
|
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,
|
||||||
@@ -150,6 +172,31 @@ 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:
|
||||||
"""
|
"""
|
||||||
@@ -170,11 +217,9 @@ class AuthService:
|
|||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
if not user.status:
|
if not user.status:
|
||||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||||
token_keys = await redis_client.get_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
if not user.is_multi_login and await get_user_sessions(user.id) - {token_payload.session_uuid}:
|
||||||
if not user.is_multi_login and [
|
|
||||||
key for key in token_keys if not key.endswith(f':{token_payload.session_uuid}')
|
|
||||||
]:
|
|
||||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||||
|
|
||||||
new_token = await create_new_token(
|
new_token = await create_new_token(
|
||||||
refresh_token,
|
refresh_token,
|
||||||
token_payload.session_uuid,
|
token_payload.session_uuid,
|
||||||
@@ -196,6 +241,7 @@ class AuthService:
|
|||||||
expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
|
expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
|
||||||
httponly=True,
|
httponly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = GetNewToken(
|
data = GetNewToken(
|
||||||
access_token=new_token.new_access_token,
|
access_token=new_token.new_access_token,
|
||||||
access_token_expire_time=new_token.new_access_token_expire_time,
|
access_token_expire_time=new_token.new_access_token_expire_time,
|
||||||
@@ -215,18 +261,12 @@ class AuthService:
|
|||||||
try:
|
try:
|
||||||
token = get_token(request)
|
token = get_token(request)
|
||||||
token_payload = jwt_decode(token)
|
token_payload = jwt_decode(token)
|
||||||
user_id = token_payload.user_id
|
|
||||||
session_uuid = token_payload.session_uuid
|
|
||||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
|
||||||
except errors.TokenError:
|
except errors.TokenError:
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
|
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||||
|
|
||||||
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
await revoke_token(token_payload.user_id, token_payload.session_uuid)
|
||||||
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
|
||||||
if refresh_token:
|
|
||||||
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
|
||||||
|
|
||||||
|
|
||||||
auth_service: AuthService = AuthService()
|
auth_service: AuthService = AuthService()
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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()
|
||||||
@@ -28,16 +28,15 @@ class UserPasswordHistoryService:
|
|||||||
if not user_status:
|
if not user_status:
|
||||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||||
|
|
||||||
locked_until_str = await redis_client.get(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
|
lock_key = f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}'
|
||||||
|
locked_until_str = await redis_client.get(lock_key)
|
||||||
if locked_until_str:
|
if locked_until_str:
|
||||||
locked_until = timezone.from_str(locked_until_str)
|
locked_until = timezone.from_str(locked_until_str)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
if locked_until > now:
|
if locked_until > now:
|
||||||
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
|
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
|
||||||
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
|
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
|
||||||
|
await redis_client.delete(lock_key)
|
||||||
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
|
|
||||||
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -54,14 +53,11 @@ class UserPasswordHistoryService:
|
|||||||
if settings.USER_LOCK_THRESHOLD == 0:
|
if settings.USER_LOCK_THRESHOLD == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
failure_key = f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}'
|
||||||
|
failure_count = await redis_client.get(failure_key)
|
||||||
failure_count = int(failure_count) if failure_count else 0
|
failure_count = int(failure_count) if failure_count else 0
|
||||||
failure_count += 1
|
failure_count += 1
|
||||||
await redis_client.set(
|
await redis_client.set(failure_key, str(failure_count), ex=settings.USER_LOCK_SECONDS)
|
||||||
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
|
|
||||||
str(failure_count),
|
|
||||||
ex=settings.USER_LOCK_SECONDS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
||||||
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
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 User
|
from backend.app.admin.model import Role, User
|
||||||
from backend.app.admin.schema.user import (
|
from backend.app.admin.schema.user import (
|
||||||
AddUserParam,
|
AddUserParam,
|
||||||
ResetPasswordParam,
|
ResetPasswordParam,
|
||||||
@@ -13,12 +16,16 @@ 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.security.jwt import get_token, jwt_decode
|
from backend.common.response.response_code import CustomErrorCode
|
||||||
|
from backend.common.security.jwt import jwt_decode
|
||||||
|
from backend.common.security.token import get_token, revoke_user_tokens
|
||||||
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:
|
||||||
@@ -34,24 +41,44 @@ class UserService:
|
|||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
user = await user_dao.get(db, pk) if pk else await user_dao.get_by_username(db, username or '')
|
user = await user_dao.get_join(db, user_id=pk, username=username)
|
||||||
if not user:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_list(*, db: AsyncSession, username: str, phone: str, status: int) -> dict[str, Any]:
|
async def get_roles(*, db: AsyncSession, pk: int) -> Sequence[Role]:
|
||||||
|
"""
|
||||||
|
获取用户所有角色
|
||||||
|
|
||||||
|
: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(username=username, phone=phone, status=status)
|
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
|
||||||
return await paging_data(db, user_select)
|
data = 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:
|
||||||
@@ -68,6 +95,12 @@ 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)
|
||||||
|
|
||||||
@@ -81,7 +114,7 @@ class UserService:
|
|||||||
:param obj: 用户更新参数
|
:param obj: 用户更新参数
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
user = await user_dao.get(db, pk)
|
user = await user_dao.get_join(db, user_id=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):
|
||||||
@@ -90,6 +123,12 @@ 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
|
||||||
@@ -131,24 +170,19 @@ class UserService:
|
|||||||
user = await user_dao.get(db, pk)
|
user = await user_dao.get(db, pk)
|
||||||
if not user:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
|
multi_login = user.is_multi_login if pk != request.user.id else request.user.is_multi_login
|
||||||
new_multi_login = not multi_login
|
new_multi_login = not multi_login
|
||||||
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
|
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
|
||||||
token = get_token(request)
|
token = get_token(request)
|
||||||
token_payload = jwt_decode(token)
|
token_payload = jwt_decode(token)
|
||||||
if pk == user.id:
|
if pk == request.user.id:
|
||||||
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
||||||
if not new_multi_login:
|
if not new_multi_login:
|
||||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
await revoke_user_tokens(user.id, exclude_session_uuid=token_payload.session_uuid)
|
||||||
await redis_client.delete_by_prefix(
|
|
||||||
key_prefix,
|
|
||||||
exclude_keys=f'{key_prefix}:{token_payload.session_uuid}',
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# 系统管理员修改他人时,他人 token 全部失效
|
# 系统管理员修改他人时,他人 token 全部失效
|
||||||
if not new_multi_login:
|
if not new_multi_login:
|
||||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
await revoke_user_tokens(user.id)
|
||||||
await redis_client.delete_by_prefix(key_prefix)
|
|
||||||
case _:
|
case _:
|
||||||
raise errors.RequestError(msg='权限类型不存在')
|
raise errors.RequestError(msg='权限类型不存在')
|
||||||
|
|
||||||
@@ -171,13 +205,12 @@ class UserService:
|
|||||||
|
|
||||||
await validate_new_password(db, user.id, password)
|
await validate_new_password(db, user.id, password)
|
||||||
count = await user_dao.reset_password(db, user.id, password)
|
count = await user_dao.reset_password(db, user.id, password)
|
||||||
|
|
||||||
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
||||||
await password_security_service.save_password_history(db, history_obj)
|
await password_security_service.save_password_history(db, history_obj)
|
||||||
await user_dao.update_password_changed_time(db, user.id)
|
await user_dao.update_password_changed_time(db, user.id)
|
||||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
await revoke_user_tokens(user.id)
|
||||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -209,18 +242,26 @@ class UserService:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_email(*, db: AsyncSession, user_id: int, email: str) -> int:
|
async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int:
|
||||||
"""
|
"""
|
||||||
更新当前用户邮箱
|
更新当前用户邮箱
|
||||||
|
|
||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:param user_id: 用户 ID
|
:param user_id: 用户 ID
|
||||||
|
:param captcha: 邮箱验证码
|
||||||
:param email: 邮箱
|
:param email: 邮箱
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
captcha_key = f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}'
|
||||||
|
captcha_code = await redis_client.get(captcha_key)
|
||||||
|
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(captcha_key)
|
||||||
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
|
||||||
@@ -236,22 +277,19 @@ class UserService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
user = await user_dao.get(db, user_id)
|
user = await user_dao.get(db, user_id)
|
||||||
|
|
||||||
if user.password and not password_verify(obj.old_password, user.password):
|
if user.password and not password_verify(obj.old_password, user.password):
|
||||||
raise errors.RequestError(msg='原密码错误')
|
raise errors.RequestError(msg='原密码错误')
|
||||||
|
|
||||||
if obj.new_password != obj.confirm_password:
|
if obj.new_password != obj.confirm_password:
|
||||||
raise errors.RequestError(msg='两次密码输入不一致')
|
raise errors.RequestError(msg='两次密码输入不一致')
|
||||||
|
|
||||||
await validate_new_password(db, user_id, obj.new_password)
|
await validate_new_password(db, user_id, obj.new_password)
|
||||||
count = await user_dao.reset_password(db, user_id, obj.new_password)
|
count = await user_dao.reset_password(db, user_id, obj.new_password)
|
||||||
|
|
||||||
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
||||||
await password_security_service.save_password_history(db, history_obj)
|
await password_security_service.save_password_history(db, history_obj)
|
||||||
await user_dao.update_password_changed_time(db, user.id)
|
await user_dao.update_password_changed_time(db, user.id)
|
||||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
|
await revoke_user_tokens(user_id)
|
||||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -266,10 +304,11 @@ class UserService:
|
|||||||
user = await user_dao.get(db, pk)
|
user = await user_dao.get(db, pk)
|
||||||
if not user:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
|
|
||||||
count = await user_dao.delete(db, user.id)
|
count = await user_dao.delete(db, user.id)
|
||||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
await revoke_user_tokens(user.id)
|
||||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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,5 +1,9 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -9,9 +13,86 @@ 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,7 +1,9 @@
|
|||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## 任务介绍
|
||||||
|
|
||||||
|
当前任务使用 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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')
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 GetTaskRegisteredDetail
|
||||||
|
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[GetTaskRegisteredDetail]]:
|
||||||
|
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(GetTaskRegisteredDetail(name=task_ins.__doc__ or task, task=task))
|
||||||
|
else:
|
||||||
|
task_registered.append(GetTaskRegisteredDetail(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()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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: int) -> int:
|
||||||
|
"""
|
||||||
|
设置任务调度状态
|
||||||
|
|
||||||
|
:param db: 数据库会话
|
||||||
|
:param pk: 任务调度 ID
|
||||||
|
:param status: 状态
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
task_scheduler = await self.get(db, pk)
|
||||||
|
task_scheduler.status = 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)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from backend.app.task.model.result import TaskExtended as TaskResult # ruff:ignore[unused-import]
|
||||||
|
from backend.app.task.model.scheduler import TaskScheduler as TaskScheduler
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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__
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from backend.common.enums import StatusType
|
||||||
|
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='是否仅运行一次')
|
||||||
|
status: Mapped[int] = mapped_column(default=StatusType.enable.value, comment='状态(0停用 1正常)')
|
||||||
|
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
|
||||||
|
# 持有后台任务引用,避免 create_task 返回的任务在执行前被回收
|
||||||
|
_update_tasks: ClassVar[set[asyncio.Task]] = set()
|
||||||
|
|
||||||
|
@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]
|
||||||
|
task = asyncio.create_task(cls.update_changed_async())
|
||||||
|
cls._update_tasks.add(task)
|
||||||
|
task.add_done_callback(cls._update_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
# 事件监听器
|
||||||
|
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)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from backend.common.schema import SchemaBase
|
||||||
|
|
||||||
|
|
||||||
|
class GetTaskRegisteredDetail(SchemaBase):
|
||||||
|
"""已注册任务详情"""
|
||||||
|
|
||||||
|
name: str = Field(description='任务名称')
|
||||||
|
task: str = Field(description='任务函数')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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.enums import StatusType
|
||||||
|
from backend.common.schema import SchemaBase
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSchedulerSchemaBase(SchemaBase):
|
||||||
|
"""任务调度参数"""
|
||||||
|
|
||||||
|
name: str = Field(description='任务名称')
|
||||||
|
task: str = Field(description='要运行的 Celery 任务')
|
||||||
|
args: JsonValue | None = Field(None, description='任务可接收的位置参数')
|
||||||
|
kwargs: JsonValue | None = Field(None, description='任务可接收的关键字参数')
|
||||||
|
queue: str | None = Field(None, description='CELERY_TASK_QUEUES 中定义的队列')
|
||||||
|
exchange: str | None = Field(None, description='低级别 AMQP 路由的交换机')
|
||||||
|
routing_key: str | None = Field(None, description='低级别 AMQP 路由的路由密钥')
|
||||||
|
start_time: datetime | None = Field(None, description='任务开始触发的时间')
|
||||||
|
expire_time: datetime | None = Field(None, description='任务不再触发的截止时间')
|
||||||
|
expire_seconds: int | None = Field(None, description='任务不再触发的秒数时间差')
|
||||||
|
type: TaskSchedulerType = Field(description='任务调度类型(0间隔 1定时)')
|
||||||
|
interval_every: int | None = Field(None, description='任务再次运行前的间隔周期数')
|
||||||
|
interval_period: PeriodType | None = Field(None, description='任务运行之间的周期类型')
|
||||||
|
crontab: str = Field(default='* * * * *', description='Crontab 表达式')
|
||||||
|
one_off: bool = Field(default=False, description='是否仅运行一次')
|
||||||
|
remark: str | None = Field(None, description='备注')
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTaskSchedulerParam(TaskSchedulerSchemaBase):
|
||||||
|
"""创建任务调度参数"""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateTaskSchedulerParam(TaskSchedulerSchemaBase):
|
||||||
|
"""更新任务调度参数"""
|
||||||
|
|
||||||
|
|
||||||
|
class GetTaskSchedulerDetail(TaskSchedulerSchemaBase):
|
||||||
|
"""任务调度详情"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int = Field(description='任务调度 ID')
|
||||||
|
status: StatusType = 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='更新时间')
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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.enums import StatusType
|
||||||
|
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='任务调度不存在')
|
||||||
|
next_status = StatusType.disable if task_scheduler.status == StatusType.enable else StatusType.enable
|
||||||
|
count = await task_scheduler_dao.set_status(db, pk, status=next_status)
|
||||||
|
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()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from celery.backends.database.session import SessionManager as CelerySessionManager
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager(CelerySessionManager):
|
||||||
|
"""
|
||||||
|
重写 celery SessionManager
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
# 禁止自动创建 celery 内部定义的任务结果表
|
||||||
|
self.prepared = True
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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} 执行失败'))
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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'),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
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.enums import StatusType
|
||||||
|
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
|
||||||
|
self.enabled = model.status == StatusType.enable
|
||||||
|
|
||||||
|
async def _disable(self, model: TaskScheduler) -> None:
|
||||||
|
"""禁用任务"""
|
||||||
|
model.no_changes = True
|
||||||
|
self.model.status = model.status = StatusType.disable
|
||||||
|
self.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.status = StatusType.disable
|
||||||
|
|
||||||
|
def is_due(self) -> tuple[bool, int | float | datetime]:
|
||||||
|
"""任务到期状态"""
|
||||||
|
if self.model.status != StatusType.enable:
|
||||||
|
# 重新启用时延迟 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.status == StatusType.enable and self.model.total_run_count > 0:
|
||||||
|
self.model.status = StatusType.disable
|
||||||
|
self.model.total_run_count = 0
|
||||||
|
self.model.no_changes = False
|
||||||
|
save_fields = ('status',)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if 'enabled' in model_dict:
|
||||||
|
enabled = model_dict.pop('enabled')
|
||||||
|
model_dict['status'] = StatusType.enable if enabled else StatusType.disable
|
||||||
|
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.status == StatusType.enable:
|
||||||
|
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_key = f'{settings.CELERY_REDIS_PREFIX}:last_update'
|
||||||
|
last_update = run_await(redis_client.get)(last_update_key)
|
||||||
|
if not last_update:
|
||||||
|
run_await(redis_client.set)(last_update_key, 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.status == StatusType.enable,
|
||||||
|
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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user