Compare commits

..
28 Commits
Author SHA1 Message Date
Wu Clan 48574fb50a Update the token decode for logout interface (#629) 2025-05-25 14:16:36 +08:00
Wu Clan ee7fe3087d Update the token check for logout interface (#625) 2025-05-23 22:05:26 +08:00
Wu Clan 270ac3d941 Refactor the system token to online users (#624) 2025-05-23 20:18:47 +08:00
Wu Clan 2f76988076 Add the deepwiki badge to README (#623) 2025-05-23 12:09:04 +08:00
Wu Clan b275481742 Update the menu path and type columns (#622)
* Update the menu path and type columns

* Update build vben5 tree

* Update data
2025-05-22 21:22:24 +08:00
Wu Clan f3f1ceb595 Update the changelog for v1.4.0 (#621) 2025-05-22 14:41:03 +08:00
Wu Clan bcc764142b Update the OAuth2 module to plugin (#620)
* Update the OAuth2 module to plugin

* update create user social param
2025-05-22 14:36:23 +08:00
Wu Clan 9b0d9f8296 Update uv installation in docker deploy (#619) 2025-05-22 14:30:53 +08:00
Wu Clan 63ba7646c1 Update the build plugin api params (#615)
* Update the build plugin api params

* update seek

* fix lint
2025-05-20 19:45:22 +08:00
Wu Clan 1a7734185f Fix non-asyncio nested async IO (#610)
* Fix non-asyncio nested async IO

* Add redis init ping
2025-05-20 18:11:11 +08:00
Wu Clan 8541059d23 Update uninstall and build plugin api method (#614) 2025-05-20 18:10:29 +08:00
Wu Clan 61edc81333 Fix the plugin status update logic (#613) 2025-05-20 17:50:07 +08:00
Wu Clan 8998687adb Fix the task result schema param type (#611) 2025-05-20 15:10:15 +08:00
Wu Clan 85b0294da9 Update the new plugin status to changed (#607)
* Fix cache update after plugin uninstall

* Update new to changed
2025-05-17 09:21:11 +08:00
Wu Clan a8ecb4ae3b Add new plugin status check interface (#606) 2025-05-17 09:06:06 +08:00
Wu Clan a87ff74b56 Update the changelog for v1.3.0 (#605) 2025-05-16 14:16:59 +08:00
Wu Clan 3c5682988d Update the casbin RBAC module path (#604) 2025-05-16 11:49:18 +08:00
Wu Clan 1d32154498 Optimize the zip plugin install logic (#603) 2025-05-15 23:40:33 +08:00
Wu Clan f4290ddb8c Fix the fastapi cli startup event loop (#602) 2025-05-15 22:54:25 +08:00
Wu Clan 1e54b0ba42 Add plugin info config and interfaces (#601)
* Add plugin info config and interfaces

* Remove test data
2025-05-15 20:50:24 +08:00
Wu Clan 27b9cf4c64 Simplify apps and plugins config method (#600) 2025-05-13 13:16:06 +08:00
Wu Clan 87f29e52a1 Update the changelog for v1.2.0 (#598) 2025-05-05 19:46:41 +08:00
Wu Clan 16a0f9646d Update the SQL script for creat tables (#597) 2025-04-28 19:39:33 +08:00
Wu Clan 032364e48e Refactor the data rule to scope rule (#596)
* WIP

* update codes

* update codes

* update filter_data_permission

* Fix schema

* Fix issues
2025-04-28 18:15:52 +08:00
Wu Clan bc5d142920 Optimize the server information retrieval (#595)
* Optimize the server information retrieval

* Update import

* Fix typing
2025-04-28 15:41:53 +08:00
Wu Clan 9b3425d801 Update the default RBAC solution (#593) 2025-04-27 14:54:39 +08:00
Wu Clan 5842127c2b Update code generator table columns (#590)
* Update code generator table columns

* Update func doc
2025-04-24 20:27:08 +08:00
Wu Clan 193eba3c58 Update the changelog for v1.1.2 (#589) 2025-04-23 23:43:56 +08:00
129 changed files with 2180 additions and 2235 deletions
+4 -4
View File
@@ -2,13 +2,13 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v5.0.0
hooks: hooks:
- id: check-added-large-files # - id: check-added-large-files
- id: end-of-file-fixer # - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-toml - id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.11.2 rev: v0.11.9
hooks: hooks:
- id: ruff - id: ruff
args: args:
@@ -20,7 +20,7 @@ repos:
- id: ruff-format - id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.10 rev: 0.7.3
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
+76
View File
@@ -1,3 +1,75 @@
<a id="v1.4.0"></a>
# [v1.4.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.0) - 2025-05-22
## What's Changed
* Update changelog for v1.3.0 by [@wu-clan](https://github.com/wu-clan) in [#605](https://github.com/fastapi-practices/fastapi_best_architecture/pull/605)
* Add new plugin status check interface by [@wu-clan](https://github.com/wu-clan) in [#606](https://github.com/fastapi-practices/fastapi_best_architecture/pull/606)
* Update the new plugin status to changed by [@wu-clan](https://github.com/wu-clan) in [#607](https://github.com/fastapi-practices/fastapi_best_architecture/pull/607)
* Fix the task result schema param type by [@wu-clan](https://github.com/wu-clan) in [#611](https://github.com/fastapi-practices/fastapi_best_architecture/pull/611)
* Fix the plugin status update logic by [@wu-clan](https://github.com/wu-clan) in [#613](https://github.com/fastapi-practices/fastapi_best_architecture/pull/613)
* Update uninstall and build plugin api method by [@wu-clan](https://github.com/wu-clan) in [#614](https://github.com/fastapi-practices/fastapi_best_architecture/pull/614)
* Fix non-asyncio nested async IO by [@wu-clan](https://github.com/wu-clan) in [#610](https://github.com/fastapi-practices/fastapi_best_architecture/pull/610)
* Update the build plugin api params by [@wu-clan](https://github.com/wu-clan) in [#615](https://github.com/fastapi-practices/fastapi_best_architecture/pull/615)
* Update uv installation in docker deploy by [@wu-clan](https://github.com/wu-clan) in [#619](https://github.com/fastapi-practices/fastapi_best_architecture/pull/619)
* Update the OAuth2 module to plugin by [@wu-clan](https://github.com/wu-clan) in [#620](https://github.com/fastapi-practices/fastapi_best_architecture/pull/620)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
[Changes][v1.4.0]
<a id="v1.3.0"></a>
# [v1.3.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.3.0) - 2025-05-16
## What's Changed
* Update changelog for v1.2.0 by [@wu-clan](https://github.com/wu-clan) in [#598](https://github.com/fastapi-practices/fastapi_best_architecture/pull/598)
* Simplify apps and plugins config method by [@wu-clan](https://github.com/wu-clan) in [#600](https://github.com/fastapi-practices/fastapi_best_architecture/pull/600)
* Add plugin info config and interfaces by [@wu-clan](https://github.com/wu-clan) in [#601](https://github.com/fastapi-practices/fastapi_best_architecture/pull/601)
* Fix the fastapi cli startup event loop by [@wu-clan](https://github.com/wu-clan) in [#602](https://github.com/fastapi-practices/fastapi_best_architecture/pull/602)
* Optimize the zip plugin install logic by [@wu-clan](https://github.com/wu-clan) in [#603](https://github.com/fastapi-practices/fastapi_best_architecture/pull/603)
* Update the casbin RBAC module path by [@wu-clan](https://github.com/wu-clan) in [#604](https://github.com/fastapi-practices/fastapi_best_architecture/pull/604)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
[Changes][v1.3.0]
<a id="v1.2.0"></a>
# [v1.2.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.2.0) - 2025-05-01
## What's Changed
* Update changelog for v1.1.2 by [@wu-clan](https://github.com/wu-clan) in [#589](https://github.com/fastapi-practices/fastapi_best_architecture/pull/589)
* Update code generator table columns by [@wu-clan](https://github.com/wu-clan) in [#590](https://github.com/fastapi-practices/fastapi_best_architecture/pull/590)
* Update the default RBAC solution by [@wu-clan](https://github.com/wu-clan) in [#593](https://github.com/fastapi-practices/fastapi_best_architecture/pull/593)
* Optimize the server information retrieval by [@wu-clan](https://github.com/wu-clan) in [#595](https://github.com/fastapi-practices/fastapi_best_architecture/pull/595)
* Refactor the data rule to scope rule by [@wu-clan](https://github.com/wu-clan) in [#596](https://github.com/fastapi-practices/fastapi_best_architecture/pull/596)
* Update the SQL script for creat tables by [@wu-clan](https://github.com/wu-clan) in [#597](https://github.com/fastapi-practices/fastapi_best_architecture/pull/597)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
[Changes][v1.2.0]
<a id="v1.1.2"></a>
# [v1.1.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.2) - 2025-04-23
## What's Changed
* Update the changelog for v1.1.1 by [@wu-clan](https://github.com/wu-clan) in [#583](https://github.com/fastapi-practices/fastapi_best_architecture/pull/583)
* Fix the condition to query menu by title by [@wu-clan](https://github.com/wu-clan) in [#584](https://github.com/fastapi-practices/fastapi_best_architecture/pull/584)
* Fix cache cleanup when updating role menu by [@wu-clan](https://github.com/wu-clan) in [#585](https://github.com/fastapi-practices/fastapi_best_architecture/pull/585)
* Optimize the userinfo cache cleaning logic by [@wu-clan](https://github.com/wu-clan) in [#586](https://github.com/fastapi-practices/fastapi_best_architecture/pull/586)
* Bump fastapi pagination from 0.12.34 to 0.13.0 by [@wu-clan](https://github.com/wu-clan) in [#587](https://github.com/fastapi-practices/fastapi_best_architecture/pull/587)
* Update the routing style of the task app by [@wu-clan](https://github.com/wu-clan) in [#588](https://github.com/fastapi-practices/fastapi_best_architecture/pull/588)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2
[Changes][v1.1.2]
<a id="v1.1.1"></a> <a id="v1.1.1"></a>
# [v1.1.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.1) - 2025-04-18 # [v1.1.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.1) - 2025-04-18
@@ -460,6 +532,10 @@
[Changes][v1.0.0] [Changes][v1.0.0]
[v1.4.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
[v1.3.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
[v1.1.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2
[v1.1.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1 [v1.1.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
[v1.1.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0 [v1.1.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0
[v1.0.5]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5 [v1.0.5]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5
+1 -2
View File
@@ -2,8 +2,7 @@
ARG SERVER_TYPE=fastapi_server ARG SERVER_TYPE=fastapi_server
# === Python environment from uv === # === Python environment from uv ===
FROM python:3.10-slim AS builder FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Used for build Python packages # 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 \
+3 -43
View File
@@ -4,11 +4,9 @@
# FastAPI Best Architecture # FastAPI Best Architecture
English | [简体中文](./README.zh-CN.md)
Enterprise-level backend architecture solution Enterprise-level backend architecture solution
**🔥Continuously updated and maintained🔥** English | [简体中文](./README.zh-CN.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
@@ -21,12 +19,10 @@ Enterprise-level backend architecture solution
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344) ![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div> </div>
> [!NOTE]
> This repository as a template library open to any person or enterprise can be used for free!
## Pseudo 3-tier architecture ## Pseudo 3-tier architecture
The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating
@@ -45,43 +41,7 @@ pattern, use templates to transform it to your heart's content!
| data access | dao / mapper | crud | | data access | dao / mapper | crud |
| model | model / entity | model | | model | model / entity | model |
## Features ## Help
- [x] Global FastAPI PEP 593 Annotated parameter style
- [x] Comprehensive async/await + asgiref asynchronous design
- [x] Adheres to RESTful API specifications
- [x] Uses SQLAlchemy 2.0 with new syntax
- [x] Uses Pydantic v2 version
- [x] Implements role-menu RBAC access control
- [x] Integrates Casbin RBAC access control
- [x] Supports Celery asynchronous tasks
- [x] Custom-developed JWT authentication middleware
- [x] Supports global custom time zones
- [x] Supports Docker / Docker-compose deployment
- [x] Integrates Pytest unit testing
## Built-in Functions
- [x] User Management: Assign roles and permissions
- [x] Department Management: Configure organizational structure (company, department, team, etc.)
- [x] Menu Management: Set up menus and button-level permissions
- [x] Role Management: Configure roles, assign menus and permissions
- [x] Dictionary Management: Maintain common parameters and configurations
- [x] Parameter Management: Dynamically configure commonly used system parameters
- [x] Notification Announcements: Publish and maintain system notification and announcement information
- [x] Token Management: Detect online status, support forced logout
- [x] Multi-device Login: Support one-click switching between multi-device login modes
- [x] OAuth 2.0: Built-in custom-developed OAuth 2.0 authorization login
- [x] Plugin System: Hot-swappable plugin design to reduce coupling
- [x] Scheduled Tasks: Support scheduled, asynchronous tasks, and function calls
- [x] Code Generation: Automatically generate code with preview, write, and download support
- [x] Operation Logs: Record and query normal and abnormal operations
- [x] Login Logs: Record and query normal and abnormal logins
- [x] Cache Monitoring: Query system cache information and command statistics
- [x] Service Monitoring: View server hardware information and status
- [x] API Documentation: Automatically generate online interactive API documentation
## Development and deployment
For more details, please check For more details, please check
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
+3 -43
View File
@@ -4,11 +4,9 @@
# FastAPI Best Architecture # FastAPI Best Architecture
简体中文 | [English](./README.md)
企业级后端架构解决方案 企业级后端架构解决方案
**🔥持续更新维护中🔥** 简体中文 | [English](./README.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
@@ -21,12 +19,10 @@
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344) ![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div> </div>
> [!NOTE]
> 此仓库作为模板库公开,任何人或企业均可免费使用!
## 伪三层架构 ## 伪三层架构
mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷 mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷
@@ -43,43 +39,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
| 数据访问 | dao / mapper | crud | | 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model | | 模型 | model / entity | model |
## 特性 ## 帮助
- [x] 全局 FastAPI PEP 593 Annotated 参数风格
- [x] 全面 async/await + asgiref 异步设计
- [x] 遵循 RESTful API 规范
- [x] 使用 SQLAlchemy 2.0 全新语法
- [x] 使用 Pydantic v2 版本
- [x] 实现角色菜单 RBAC 访问控制
- [x] 集成 Casbin RBAC 访问控制
- [x] 支持 Celery 异步任务
- [x] 自研 JWT 认证中间件
- [x] 支持全局自定义时间时区
- [x] 支持 Docker / Docker-compose 部署
- [x] 集成 Pytest 单元测试
## 内置功能
- [x] 用户管理:分配角色和权限
- [x] 部门管理:配置组织架构(公司、部门、小组等)
- [x] 菜单管理:设置菜单及按钮级权限
- [x] 角色管理:配置角色、分配菜单和权限
- [x] 字典管理:维护常用参数和配置
- [x] 参数管理:系统常用参数动态配置
- [x] 通知公告:发布和维护系统通知公告信息
- [x] 令牌管理:检测在线状态,支持强制下线
- [x] 多端登录:支持一键切换多端登录模式
- [x] OAuth 2.0:内置自研 OAuth 2.0 授权登录
- [x] 插件系统:热插拔插件设计,降低耦合
- [x] 定时任务:支持定时,异步任务及函数调用
- [x] 代码生成:自动生成代码,支持预览、写入和下载
- [x] 操作日志:记录和查询正常和异常操作
- [x] 登录日志:记录和查询正常和异常登录
- [x] 缓存监控:查询系统缓存信息和命令统计
- [x] 服务监控:查看服务器硬件信息和状态
- [x] 接口文档:自动生成在线交互式 API 文档
## 开发部署
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) 更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
+6 -6
View File
@@ -15,18 +15,18 @@ REDIS_DATABASE=0
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log # Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b' OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# Admin # App Admin
# OAuth2 # OAuth2
OAUTH2_GITHUB_CLIENT_ID='test' OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test' OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test' OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test' OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# Task # App Task
# Celery # Celery
CELERY_BROKER_REDIS_DATABASE=1 CELERY_BROKER_REDIS_DATABASE=1
CELERY_BACKEND_REDIS_DATABASE=2 CELERY_BACKEND_REDIS_DATABASE=2
# Rabbitmq # Rabbitmq
RABBITMQ_HOST='127.0.0.1' CELERY_RABBITMQ_HOST='127.0.0.1'
RABBITMQ_PORT=5672 CELERY_RABBITMQ_PORT=5672
RABBITMQ_USERNAME='guest' CELERY_RABBITMQ_USERNAME='guest'
RABBITMQ_PASSWORD='guest' CELERY_RABBITMQ_PASSWORD='guest'
-2
View File
@@ -5,14 +5,12 @@ 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.log import router as log_router
from backend.app.admin.api.v1.monitor import router as monitor_router from backend.app.admin.api.v1.monitor import router as monitor_router
from backend.app.admin.api.v1.oauth2 import router as oauth2_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
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
v1.include_router(auth_router) v1.include_router(auth_router)
v1.include_router(oauth2_router)
v1.include_router(sys_router) v1.include_router(sys_router)
v1.include_router(log_router) v1.include_router(log_router)
v1.include_router(monitor_router) v1.include_router(monitor_router)
+1 -2
View File
@@ -11,7 +11,6 @@ from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwagge
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
router = APIRouter() router = APIRouter()
@@ -41,7 +40,7 @@ async def create_new_token(request: Request) -> ResponseSchemaModel[GetNewToken]
return response_base.success(data=data) return response_base.success(data=data)
@router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth]) @router.post('/logout', summary='用户登出')
async def user_logout(request: Request, response: Response) -> ResponseModel: async def user_logout(request: Request, response: Response) -> ResponseModel:
await auth_service.logout(request=request, response=response) await auth_service.logout(request=request, response=response)
return response_base.success() return response_base.success()
+3 -3
View File
@@ -5,9 +5,9 @@ from fastapi import APIRouter, Depends, Request
from fastapi_limiter.depends import RateLimiter from fastapi_limiter.depends import RateLimiter
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.admin.conf import admin_settings
from backend.app.admin.schema.captcha import GetCaptchaDetail from backend.app.admin.schema.captcha import GetCaptchaDetail
from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@@ -26,9 +26,9 @@ async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]
img, code = await run_in_threadpool(img_captcha, img_byte=img_type) img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
ip = request.state.ip ip = request.state.ip
await redis_client.set( await redis_client.set(
f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}', f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}',
code, code,
ex=admin_settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
) )
data = GetCaptchaDetail(image_type=img_type, image=img) data = GetCaptchaDetail(image_type=img_type, image=img)
return response_base.success(data=data) return response_base.success(data=data)
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from fastapi import APIRouter 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.redis import router as redis_router
from backend.app.admin.api.v1.monitor.server import router as server_router from backend.app.admin.api.v1.monitor.server import router as server_router
@@ -9,3 +10,4 @@ router = APIRouter(prefix='/monitors')
router.include_router(redis_router, prefix='/redis', tags=['redis监控']) router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
router.include_router(server_router, prefix='/server', tags=['服务器监控']) router.include_router(server_router, prefix='/server', tags=['服务器监控'])
router.include_router(token_router, prefix='/online', tags=['在线用户'])
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.token import GetTokenDetail, KickOutToken from backend.app.admin.schema.token import GetTokenDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
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, jwt_decode, revoke_token, superuser_verify from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
@@ -18,8 +18,8 @@ from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth]) @router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth])
async def get_tokens( async def get_online(
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
) -> ResponseSchemaModel[list[GetTokenDetail]]: ) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*') token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
@@ -82,8 +82,10 @@ async def get_tokens(
], ],
) )
async def kick_out( async def kick_out(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: KickOutToken request: Request,
pk: Annotated[int, Path(description='用户 ID')],
session_uuid: Annotated[str, Query(description='会话 UUID')],
) -> ResponseModel: ) -> ResponseModel:
superuser_verify(request) superuser_verify(request)
await revoke_token(str(pk), obj.session_uuid) await revoke_token(str(pk), session_uuid)
return response_base.success() return response_base.success()
@@ -1,11 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.oauth2.github import router as github_router
from backend.app.admin.api.v1.oauth2.linux_do import router as linux_do_router
router = APIRouter(prefix='/oauth2')
router.include_router(github_router, prefix='/github', tags=['GitHub OAuth2'])
router.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2'])
+3 -3
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3 # !/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
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_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.dept import router as dept_router
from backend.app.admin.api.v1.sys.menu import router as menu_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.role import router as role_router
from backend.app.admin.api.v1.sys.token import router as token_router
from backend.app.admin.api.v1.sys.upload import router as upload_router from backend.app.admin.api.v1.sys.upload import router as upload_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
@@ -18,6 +18,6 @@ router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
router.include_router(role_router, prefix='/roles', 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_rule_router, prefix='/data-rules', tags=['系统数据规则'])
router.include_router(token_router, prefix='/tokens', tags=['系统令牌']) router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
router.include_router(upload_router, prefix='/upload', tags=['系统上传']) router.include_router(upload_router, prefix='/upload', tags=['系统上传'])
router.include_router(plugin_router, prefix='/plugin', tags=['系统插件']) router.include_router(plugin_router, prefix='/plugin', tags=['系统插件'])
+7 -2
View File
@@ -4,7 +4,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleDetail, UpdateDataRuleParam from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
GetDataRuleColumnDetail,
GetDataRuleDetail,
UpdateDataRuleParam,
)
from backend.app.admin.service.data_rule_service import data_rule_service from backend.app.admin.service.data_rule_service import data_rule_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -25,7 +30,7 @@ async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
@router.get('/model/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth]) @router.get('/model/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
async def get_data_rule_model_columns( async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')], model: Annotated[str, Path(description='模型名称')],
) -> ResponseSchemaModel[list[str]]: ) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
models = await data_rule_service.get_columns(model=model) models = await data_rule_service.get_columns(model=model)
return response_base.success(data=models) return response_base.success(data=models)
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
GetDataScopeDetail,
GetDataScopeWithRelationDetail,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.service.data_scope_service import data_scope_service
from backend.common.pagination import DependsPagination, PageData, paging_data
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
router = APIRouter()
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
async def get_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeDetail]:
data = await data_scope_service.get(pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
async def get_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
data = await data_scope_service.get_rules(pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有数据范围',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_pagination_data_scopes(
db: CurrentSession,
name: Annotated[str | None, Query(description='范围名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
data_scope_select = await data_scope_service.get_select(name=name, status=status)
page_data = await paging_data(db, data_scope_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建数据范围',
dependencies=[
Depends(RequestPermission('data:scope:add')),
DependsRBAC,
],
)
async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel:
await data_scope_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新数据范围',
dependencies=[
Depends(RequestPermission('data:scope:edit')),
DependsRBAC,
],
)
async def update_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam
) -> ResponseModel:
count = await data_scope_service.update(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(
pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam
):
count = await data_scope_service.update_data_scope_rule(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_scope(pk: Annotated[list[int], Query(description='数据范围 ID 列表')]) -> ResponseModel:
count = await data_scope_service.delete(pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
+3 -2
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service from backend.app.admin.service.dept_service import dept_service
@@ -22,12 +22,13 @@ async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> Respons
@router.get('', summary='获取所有部门展示树', dependencies=[DependsJwtAuth]) @router.get('', summary='获取所有部门展示树', dependencies=[DependsJwtAuth])
async def get_all_depts( async def get_all_depts(
request: Request,
name: Annotated[str | None, Query(description='部门名称')] = None, name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query(description='部门负责人')] = None, leader: 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[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any]]]:
dept = await dept_service.get_dept_tree(name=name, leader=leader, phone=phone, status=status) dept = await dept_service.get_dept_tree(request=request, name=name, leader=leader, phone=phone, status=status)
return response_base.success(data=dept) return response_base.success(data=dept)
+68 -68
View File
@@ -1,98 +1,98 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import io from typing import Annotated, Any
import os.path
import zipfile
from typing import Annotated from fastapi import APIRouter, Depends, File, Path, UploadFile
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.params import Query from fastapi.params import Query
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from backend.common.exception import errors from backend.app.admin.service.plugin_service import plugin_service
from backend.common.response.response_schema import ResponseModel, response_base from backend.common.response.response_code import CustomResponseCode
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.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.core.path_conf import PLUGIN_DIR
from backend.plugin.tools import install_requirements_async
router = APIRouter() router = APIRouter()
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth])
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
plugins = await plugin_service.get_all()
return response_base.success(data=plugins)
@router.get('/changed', summary='插件状态是否变更', dependencies=[DependsJwtAuth])
async def plugin_changed() -> ResponseSchemaModel[bool]:
plugins = await plugin_service.changed()
return response_base.success(data=bool(plugins))
@router.post( @router.post(
'/install', '/install/zip',
summary='安装插件', summary='安装 zip 插件',
description='使用插件 zip 压缩包进行安装', description='使用插件 zip 压缩包进行安装',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:install')), Depends(RequestPermission('sys:plugin:install')),
DependsRBAC, DependsRBAC,
], ],
) )
async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: async def install_zip_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
contents = await file.read() await plugin_service.install_zip(file=file)
file_bytes = io.BytesIO(contents) return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
if not zipfile.is_zipfile(file_bytes):
raise errors.ForbiddenError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_dir_in_zip = f'{file.filename[:-4]}/backend/plugin/'
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)]
if not members_in_plugin_dir:
raise errors.ForbiddenError(msg='插件压缩包内容非法')
plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '')
if (
len(members_in_plugin_dir) <= 3
or f'{plugin_dir_in_zip}{plugin_name}/plugin.toml' not in members_in_plugin_dir
or f'{plugin_dir_in_zip}{plugin_name}/README.md' not in members_in_plugin_dir
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir_in_zip):
new_filename = member.filename.replace(plugin_dir_in_zip, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(PLUGIN_DIR, members)
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
await install_requirements_async()
return response_base.success()
@router.post( @router.post(
'/zip', '/install/git',
summary='安装 git 插件',
description='使用插件 git 仓库地址进行安装,不限制平台;如果需要凭证,需在 git 仓库地址中添加凭证信息',
dependencies=[
Depends(RequestPermission('sys:plugin:install')),
DependsRBAC,
],
)
async def install_git_plugin(repo_url: Annotated[str, Query(description='插件 git 仓库地址')]) -> ResponseModel:
await plugin_service.install_git(repo_url=repo_url)
return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
@router.delete(
'/uninstall',
summary='卸载插件',
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
dependencies=[
Depends(RequestPermission('sys:plugin:uninstall')),
DependsRBAC,
],
)
async def uninstall_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel:
await plugin_service.uninstall(plugin=plugin)
return response_base.success(res=CustomResponseCode.PLUGIN_UNINSTALL_SUCCESS)
@router.post(
'/status',
summary='更新插件状态',
dependencies=[
Depends(RequestPermission('sys:plugin:status')),
DependsRBAC,
],
)
async def update_plugin_status(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel:
await plugin_service.update_status(plugin=plugin)
return response_base.success()
@router.get(
'/zip/{plugin}',
summary='打包插件', summary='打包插件',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:zip')), Depends(RequestPermission('sys:plugin:zip')),
DependsRBAC, DependsRBAC,
], ],
) )
async def build_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> StreamingResponse: async def build_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
plugin_dir = os.path.join(PLUGIN_DIR, plugin) bio = await plugin_service.build(plugin=plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, arcname)
bio.seek(0)
return StreamingResponse( return StreamingResponse(
bio, bio,
media_type='application/x-zip-compressed', media_type='application/x-zip-compressed',
+12 -14
View File
@@ -10,10 +10,8 @@ from backend.app.admin.schema.role import (
GetRoleWithRelationDetail, GetRoleWithRelationDetail,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleRuleParam, UpdateRoleScopeParam,
) )
from backend.app.admin.service.data_rule_service import data_rule_service
from backend.app.admin.service.menu_service import menu_service
from backend.app.admin.service.role_service import role_service from backend.app.admin.service.role_service import role_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -35,7 +33,7 @@ async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
async def get_user_all_roles( async def get_user_all_roles(
pk: Annotated[int, Path(description='用户 ID')], pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[list[GetRoleDetail]]: ) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_by_user(pk=pk) data = await role_service.get_users(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -43,13 +41,13 @@ async def get_user_all_roles(
async def get_role_all_menus( async def get_role_all_menus(
pk: Annotated[int, Path(description='角色 ID')], pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any]]]:
menu = await menu_service.get_role_menu_tree(pk=pk) menu = await role_service.get_menu_tree(pk=pk)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}/rules', summary='获取角色所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
async def get_role_all_rules(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]: async def get_role_all_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]:
rule = await data_rule_service.get_role_rules(pk=pk) rule = await role_service.get_scopes(pk=pk)
return response_base.success(data=rule) return response_base.success(data=rule)
@@ -125,17 +123,17 @@ async def update_role_menus(
@router.put( @router.put(
'/{pk}/rule', '/{pk}/scope',
summary='更新角色数据规则', summary='更新角色数据范围',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:rule:edit')), Depends(RequestPermission('sys:role:scope:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
async def update_role_rules( async def update_role_scopes(
pk: Annotated[int, Path(description='角色 ID')], rule_ids: UpdateRoleRuleParam pk: Annotated[int, Path(description='角色 ID')], scope_ids: UpdateRoleScopeParam
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_rule(pk=pk, rule_ids=rule_ids) count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
-1
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, File, UploadFile
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BASE_PATH
class AdminSettings(BaseSettings):
"""Admin 配置"""
model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# .env OAuth2
OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# OAuth2
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
@lru_cache
def get_admin_settings() -> AdminSettings:
"""获取 admin 配置"""
return AdminSettings()
admin_settings = get_admin_settings()
+1 -1
View File
@@ -31,7 +31,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
:param name: 规则名称 :param name: 规则名称
:return: :return:
""" """
stmt = select(self.model).options(noload(self.model.roles)).order_by(desc(self.model.created_time)) stmt = select(self.model).options(noload(self.model.scope)).order_by(desc(self.model.created_time))
filters = [] filters = []
if name is not None: if name is not None:
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, DataScope
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam
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)
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)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope:
"""
获取数据范围关联数据
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
stmt = select(self.model).options(selectinload(self.model.rules)).where(self.model.id == pk)
data_scope = await db.execute(stmt)
return data_scope.scalars().first()
async def get_list(self, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表
:param name: 范围名称
:param status: 范围状态
:return:
"""
stmt = (
select(self.model)
.options(noload(self.model.rules), noload(self.model.roles))
.order_by(desc(self.model.created_time))
)
filters = []
if name is not None:
filters.append(self.model.name.like(f'%{name}%'))
if status is not None:
filters.append(self.model.status == status)
if filters:
stmt = stmt.where(and_(*filters))
return stmt
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(db, pk, obj)
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:param rule_ids: 数据规则 ID 列表
:return:
"""
current_data_scope = await self.get_with_relation(db, pk)
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules))
rules = await db.execute(stmt)
current_data_scope.rules = rules.scalars().all()
return len(current_data_scope.rules)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除数据范围
:param db: 数据库会话
:param pk: 范围 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
+11 -2
View File
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from fastapi import Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -9,6 +10,7 @@ from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.security.permission import filter_data_permission
class CRUDDept(CRUDPlus[Dept]): class CRUDDept(CRUDPlus[Dept]):
@@ -35,11 +37,18 @@ class CRUDDept(CRUDPlus[Dept]):
return await self.select_model_by_column(db, name=name, del_flag=0) return await self.select_model_by_column(db, name=name, del_flag=0)
async def get_all( async def get_all(
self, db: AsyncSession, name: str | None, leader: str | None, phone: str | None, status: int | None self,
request: Request,
db: AsyncSession,
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> Sequence[Dept]: ) -> Sequence[Dept]:
""" """
获取所有部门 获取所有部门
:param request: FastAPI 请求对象
:param db: 数据库会话 :param db: 数据库会话
:param name: 部门名称 :param name: 部门名称
:param leader: 负责人 :param leader: 负责人
@@ -56,7 +65,7 @@ class CRUDDept(CRUDPlus[Dept]):
filters.update(phone__startswith=phone) filters.update(phone__startswith=phone)
if status is not None: if status is not None:
filters.update(status=status) filters.update(status=status)
return await self.select_models_order(db, sort_columns='sort', **filters) return await self.select_models_order(db, 'sort', None, await filter_data_permission(db, request), **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None: async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
+12 -12
View File
@@ -7,12 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, Menu, Role, User from backend.app.admin.model import DataScope, Menu, Role, User
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleRuleParam, UpdateRoleScopeParam,
) )
@@ -39,7 +39,7 @@ class CRUDRole(CRUDPlus[Role]):
""" """
stmt = ( stmt = (
select(self.model) select(self.model)
.options(selectinload(self.model.menus), selectinload(self.model.rules)) .options(selectinload(self.model.menus), selectinload(self.model.scopes))
.where(self.model.id == role_id) .where(self.model.id == role_id)
) )
role = await db.execute(stmt) role = await db.execute(stmt)
@@ -54,7 +54,7 @@ class CRUDRole(CRUDPlus[Role]):
""" """
return await self.select_models(db) return await self.select_models(db)
async def get_by_user(self, db: AsyncSession, user_id: int) -> Sequence[Role]: async def get_users(self, db: AsyncSession, user_id: int) -> Sequence[Role]:
""" """
获取用户角色列表 获取用户角色列表
@@ -76,7 +76,7 @@ class CRUDRole(CRUDPlus[Role]):
""" """
stmt = ( stmt = (
select(self.model) select(self.model)
.options(noload(self.model.users), noload(self.model.menus), noload(self.model.rules)) .options(noload(self.model.users), noload(self.model.menus), noload(self.model.scopes))
.order_by(desc(self.model.created_time)) .order_by(desc(self.model.created_time))
) )
@@ -137,20 +137,20 @@ class CRUDRole(CRUDPlus[Role]):
current_role.menus = menus.scalars().all() current_role.menus = menus.scalars().all()
return len(current_role.menus) return len(current_role.menus)
async def update_rules(self, db: AsyncSession, role_id: int, rule_ids: UpdateRoleRuleParam) -> int: async def update_scopes(self, db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
""" """
更新角色数据规则 更新角色数据范围
:param db: 数据库会话 :param db: 数据库会话
:param role_id: 角色 ID :param role_id: 角色 ID
:param rule_ids: 权限规则 ID 列表 :param scope_ids: 权限范围 ID 列表
:return: :return:
""" """
current_role = await self.get_with_relation(db, role_id) current_role = await self.get_with_relation(db, role_id)
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes))
rules = await db.execute(stmt) scopes = await db.execute(stmt)
current_role.rules = rules.scalars().all() current_role.scopes = scopes.scalars().all()
return len(current_role.rules) return len(current_role.scopes)
async def delete(self, db: AsyncSession, role_id: list[int]) -> int: async def delete(self, db: AsyncSession, role_id: list[int]) -> int:
""" """
+2 -3
View File
@@ -189,8 +189,7 @@ class CRUDUser(CRUDPlus[User]):
select(self.model) select(self.model)
.options( .options(
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)), selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
noload(self.model.socials), selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.rules)),
) )
.order_by(desc(self.model.join_time)) .order_by(desc(self.model.join_time))
) )
@@ -311,7 +310,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
stmt = select(self.model).options( stmt = select(self.model).options(
selectinload(self.model.dept), selectinload(self.model.dept),
selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.rules)), selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes)),
) )
filters = [] filters = []
+1 -1
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from backend.app.admin.model.data_rule import DataRule from backend.app.admin.model.data_rule import DataRule
from backend.app.admin.model.data_scope import DataScope
from backend.app.admin.model.dept import Dept from backend.app.admin.model.dept import Dept
from backend.app.admin.model.login_log import LoginLog from backend.app.admin.model.login_log import LoginLog
from backend.app.admin.model.menu import Menu from backend.app.admin.model.menu import Menu
from backend.app.admin.model.opera_log import OperaLog from backend.app.admin.model.opera_log import OperaLog
from backend.app.admin.model.role import Role from backend.app.admin.model.role import Role
from backend.app.admin.model.user import User from backend.app.admin.model.user import User
from backend.app.admin.model.user_social import UserSocial
+10 -8
View File
@@ -4,14 +4,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import String from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_rule
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.model import Role from backend.app.admin.model import DataScope
class DataRule(Base): class DataRule(Base):
@@ -20,14 +19,17 @@ class DataRule(Base):
__tablename__ = 'sys_data_rule' __tablename__ = 'sys_data_rule'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(255), unique=True, comment='规则名称') name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称')
model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型') model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名')
column: Mapped[str] = mapped_column(String(20), comment='数据库字段') column: Mapped[str] = mapped_column(String(20), comment='模型字段')
operator: Mapped[int] = mapped_column(comment='运算符(0and、1or') operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column( expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in' comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in'
) )
value: Mapped[str] = mapped_column(String(255), comment='规则值') value: Mapped[str] = mapped_column(String(255), comment='规则值')
# 角色规则对多 # 数据范围规则对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='rules') scope_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_data_scope.id', ondelete='SET NULL'), default=None, comment='数据范围关联 ID'
)
scope: Mapped[DataScope] = relationship(init=False, back_populates='rules')
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_scope
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Role
class DataScope(Base):
"""数据范围表"""
__tablename__ = 'sys_data_scope'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
# 数据范围规则一对多
rules: Mapped[list[DataRule]] = relationship(init=False, back_populates='scope')
# 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
+7 -7
View File
@@ -20,16 +20,16 @@ sys_role_menu = Table(
Column('menu_id', Integer, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'), Column('menu_id', Integer, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'),
) )
sys_role_data_rule = Table( sys_role_data_scope = Table(
'sys_role_data_rule', 'sys_role_data_scope',
MappedBase.metadata, MappedBase.metadata,
Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'), Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'),
Column( Column(
'data_rule_id', 'data_scope_id',
Integer, Integer,
ForeignKey('sys_data_rule.id', ondelete='CASCADE'), ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True, primary_key=True,
comment='数据规则ID', comment='数据范围 ID',
), ),
) )
+2 -2
View File
@@ -24,10 +24,10 @@ class Menu(Base):
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(String(50), comment='菜单标题') title: Mapped[str] = mapped_column(String(50), comment='菜单标题')
name: Mapped[str] = mapped_column(String(50), comment='菜单名称') name: Mapped[str] = mapped_column(String(50), comment='菜单名称')
path: Mapped[str] = mapped_column(String(200), comment='路由地址') path: Mapped[str | None] = mapped_column(String(200), comment='路由地址')
sort: Mapped[int] = mapped_column(default=0, comment='排序') sort: Mapped[int] = mapped_column(default=0, comment='排序')
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标') icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标')
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮)') type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链')
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径') component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识') perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识')
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
+4 -4
View File
@@ -9,11 +9,11 @@ from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_rule, sys_role_menu, sys_user_role from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Menu, User from backend.app.admin.model import DataScope, Menu, User
class Role(Base): class Role(Base):
@@ -34,5 +34,5 @@ class Role(Base):
# 角色菜单多对多 # 角色菜单多对多
menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles') menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles')
# 角色数据规则多对多 # 角色数据范围多对多
rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='roles') scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
+1 -4
View File
@@ -15,7 +15,7 @@ from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role, UserSocial from backend.app.admin.model import Dept, Role
class User(Base): class User(Base):
@@ -55,8 +55,5 @@ class User(Base):
) )
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users') dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户社交信息一对多
socials: Mapped[list[UserSocial]] = relationship(init=False, back_populates='user')
# 用户角色多对多 # 用户角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users') roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
+7 -4
View File
@@ -14,7 +14,7 @@ class DataRuleSchemaBase(SchemaBase):
name: str = Field(description='规则名称') name: str = Field(description='规则名称')
model: str = Field(description='模型名称') model: str = Field(description='模型名称')
column: str = Field(description='字段名称') column: str = Field(description='字段名称')
operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.OR, description='操作符(AND/OR') operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.AND, description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型') expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型')
value: str = Field(description='规则值') value: str = Field(description='规则值')
@@ -36,6 +36,9 @@ class GetDataRuleDetail(DataRuleSchemaBase):
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间') updated_time: datetime | None = Field(None, description='更新时间')
def __hash__(self) -> int:
"""计算哈希值""" class GetDataRuleColumnDetail(SchemaBase):
return hash(self.name) """数据规则可用模型字段详情"""
key: str = Field(description='字段名')
comment: str = Field(description='字段评论')
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_rule import GetDataRuleDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DataScopeBase(SchemaBase):
"""数据范围基础模型"""
name: str = Field(description='名称')
status: StatusType = Field(StatusType.enable, description='状态')
class CreateDataScopeParam(DataScopeBase):
"""创建数据范围参数"""
class UpdateDataScopeParam(DataScopeBase):
"""更新数据范围参数"""
class UpdateDataScopeRuleParam(SchemaBase):
"""更新数据范围规则参数"""
rules: list[int] = Field(description='数据规则 ID 列表')
class GetDataScopeDetail(DataScopeBase):
"""数据范围详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='数据范围 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情"""
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
+2 -2
View File
@@ -13,11 +13,11 @@ class MenuSchemaBase(SchemaBase):
title: str = Field(description='菜单标题') title: str = Field(description='菜单标题')
name: str = Field(description='菜单名称') name: str = Field(description='菜单名称')
path: str = Field(description='路由路径') path: str | None = Field(None, description='路由地址')
parent_id: int | None = Field(None, description='菜单父级 ID') parent_id: int | None = Field(None, description='菜单父级 ID')
sort: int = Field(0, ge=0, description='排序') sort: int = Field(0, ge=0, description='排序')
icon: str | None = Field(None, description='图标') icon: str | None = Field(None, description='图标')
type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)') type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链')
component: str | None = Field(None, description='组件路径') component: str | None = Field(None, description='组件路径')
perms: str | None = Field(None, description='权限标识') perms: str | None = Field(None, description='权限标识')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(StatusType.enable, description='状态')
+5 -5
View File
@@ -4,7 +4,7 @@ from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_rule import GetDataRuleDetail from backend.app.admin.schema.data_scope import GetDataScopeDetail
from backend.app.admin.schema.menu import GetMenuDetail from backend.app.admin.schema.menu import GetMenuDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
@@ -32,10 +32,10 @@ class UpdateRoleMenuParam(SchemaBase):
menus: list[int] = Field(description='菜单 ID 列表') menus: list[int] = Field(description='菜单 ID 列表')
class UpdateRoleRuleParam(SchemaBase): class UpdateRoleScopeParam(SchemaBase):
"""更新角色规则参数""" """更新角色数据范围参数"""
rules: list[int] = Field(description='数据规则 ID 列表') scopes: list[int] = Field(description='数据范围 ID 列表')
class GetRoleDetail(RoleSchemaBase): class GetRoleDetail(RoleSchemaBase):
@@ -52,4 +52,4 @@ class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情""" """角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表') menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则详情列表') scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
-6
View File
@@ -35,12 +35,6 @@ class GetLoginToken(AccessTokenBase):
user: GetUserInfoDetail = Field(description='用户信息') user: GetUserInfoDetail = Field(description='用户信息')
class KickOutToken(SchemaBase):
"""踢出令牌"""
session_uuid: str = Field(description='会话 UUID')
class GetTokenDetail(SchemaBase): class GetTokenDetail(SchemaBase):
"""令牌详情""" """令牌详情"""
+13 -8
View File
@@ -5,7 +5,6 @@ from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTask, BackgroundTasks from starlette.background import BackgroundTask, BackgroundTasks
from backend.app.admin.conf import admin_settings
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
@@ -91,12 +90,12 @@ class AuthService:
user = None user = None
try: try:
user = await self.user_verify(db, obj.username, obj.password) user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
if not captcha_code: if not captcha_code:
raise errors.AuthorizationError(msg='验证码失效,请重新获取') raise errors.AuthorizationError(msg='验证码失效,请重新获取')
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'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
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)
a_token = await create_access_token( a_token = await create_access_token(
@@ -212,11 +211,17 @@ class AuthService:
:param response: FastAPI 响应对象 :param response: FastAPI 响应对象
:return: :return:
""" """
token = get_token(request) try:
token_payload = jwt_decode(token) token = get_token(request)
user_id = token_payload.id token_payload = jwt_decode(token)
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) user_id = token_payload.id
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
except errors.TokenError:
return
finally:
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
# 清理缓存
if request.user.is_multi_login: if request.user.is_multi_login:
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}')
if refresh_token: if refresh_token:
+9 -29
View File
@@ -5,13 +5,11 @@ from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from backend.app.admin.crud.crud_data_rule import data_rule_dao from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.model import DataRule from backend.app.admin.model import DataRule
from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleColumnDetail, UpdateDataRuleParam
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.import_parse import dynamic_import_data_model from backend.utils.import_parse import dynamic_import_data_model
@@ -32,28 +30,13 @@ class DataRuleService:
raise errors.NotFoundError(msg='数据规则不存在') raise errors.NotFoundError(msg='数据规则不存在')
return data_rule return data_rule
@staticmethod
async def get_role_rules(*, pk: int) -> list[int]:
"""
获取角色的数据规则列表
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
rule_ids = [rule.id for rule in role.rules]
return rule_ids
@staticmethod @staticmethod
async def get_models() -> list[str]: async def get_models() -> list[str]:
"""获取所有数据规则可用模型""" """获取所有数据规则可用模型"""
return list(settings.DATA_PERMISSION_MODELS.keys()) return list(settings.DATA_PERMISSION_MODELS.keys())
@staticmethod @staticmethod
async def get_columns(model: str) -> list[str]: async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
""" """
获取数据规则可用模型的字段列表 获取数据规则可用模型的字段列表
@@ -63,8 +46,11 @@ class DataRuleService:
if model not in settings.DATA_PERMISSION_MODELS: if model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则可用模型不存在') raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
model_columns = [ model_columns = [
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE GetDataRuleColumnDetail(key=column.key, comment=column.comment)
for column in model_ins.__table__.columns
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
] ]
return model_columns return model_columns
@@ -112,10 +98,10 @@ class DataRuleService:
data_rule = await data_rule_dao.get(db, pk) data_rule = await data_rule_dao.get(db, pk)
if not data_rule: if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在') raise errors.NotFoundError(msg='数据规则不存在')
if data_rule.name != obj.name:
if await data_rule_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj) count = await data_rule_dao.update(db, pk, obj)
for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -128,12 +114,6 @@ class DataRuleService:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await data_rule_dao.delete(db, pk) count = await data_rule_dao.delete(db, pk)
for _pk in pk:
data_rule = await data_rule_dao.get(db, _pk)
if data_rule:
for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select
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, UpdateDataScopeParam, UpdateDataScopeRuleParam
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
class DataScopeService:
"""数据范围服务类"""
@staticmethod
async def get(*, pk: int) -> DataScope:
"""
获取数据范围详情
:param pk: 范围 ID
:return:
"""
async with async_db_session() as db:
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@staticmethod
async def get_rules(*, pk: int) -> DataScope:
"""
获取数据范围规则
:param pk: 范围 ID
:return:
"""
async with async_db_session() as db:
data_scope = await data_scope_dao.get_with_relation(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@staticmethod
async def get_select(*, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表查询条件
:param name: 范围名称
:param status: 范围状态
:return:
"""
return await data_scope_dao.get_list(name, status)
@staticmethod
async def create(*, obj: CreateDataScopeParam) -> None:
"""
创建数据范围
:param obj: 数据范围参数
:return:
"""
async with async_db_session.begin() as db:
data_scope = await data_scope_dao.get_by_name(db, obj.name)
if data_scope:
raise errors.ForbiddenError(msg='数据范围已存在')
await data_scope_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateDataScopeParam) -> int:
"""
更新数据范围
:param pk: 范围 ID
:param obj: 数据范围更新参数
:return:
"""
async with async_db_session.begin() as db:
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
if data_scope.name != obj.name:
if await data_scope_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj)
for role in await data_scope.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_data_scope_rule(*, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param pk: 范围 ID
:param rule_ids: 规则 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await data_scope_dao.update_rules(db, pk, rule_ids)
return count
@staticmethod
async def delete(*, pk: list[int]) -> int:
"""
删除数据范围
:param pk: 范围 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await data_scope_dao.delete(db, pk)
for _pk in pk:
data_rule = await data_scope_dao.get(db, _pk)
if data_rule:
for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
data_scope_service: DataScopeService = DataScopeService()
+5 -2
View File
@@ -2,6 +2,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Any from typing import Any
from fastapi import Request
from backend.app.admin.crud.crud_dept import dept_dao from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
@@ -31,11 +33,12 @@ class DeptService:
@staticmethod @staticmethod
async def get_dept_tree( async def get_dept_tree(
*, name: str | None, leader: str | None, phone: str | None, status: int | None *, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
获取部门树形结构 获取部门树形结构
:param request: FastAPI 请求对象
:param name: 部门名称 :param name: 部门名称
:param leader: 部门负责人 :param leader: 部门负责人
:param phone: 联系电话 :param phone: 联系电话
@@ -43,7 +46,7 @@ class DeptService:
:return: :return:
""" """
async with async_db_session() as db: async with async_db_session() as db:
dept_select = await dept_dao.get_all(db=db, name=name, leader=leader, phone=phone, status=status) dept_select = await dept_dao.get_all(request, db, name, leader, phone, status)
tree_data = get_tree_data(dept_select) tree_data = get_tree_data(dept_select)
return tree_data return tree_data
-18
View File
@@ -5,7 +5,6 @@ from typing import Any
from fastapi import Request from fastapi import Request
from backend.app.admin.crud.crud_menu import menu_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 Menu from backend.app.admin.model import Menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
from backend.common.exception import errors from backend.common.exception import errors
@@ -46,23 +45,6 @@ class MenuService:
menu_tree = get_tree_data(menu_select) menu_tree = get_tree_data(menu_select)
return menu_tree return menu_tree
@staticmethod
async def get_role_menu_tree(*, pk: int) -> list[dict[str, Any]]:
"""
获取角色的菜单树形结构
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menu_ids = [menu.id for menu in role.menus]
menu_select = await menu_dao.get_role_menus(db, False, menu_ids)
menu_tree = get_tree_data(menu_select)
return menu_tree
@staticmethod @staticmethod
async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]: async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]:
""" """
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import json
import os
import shutil
import zipfile
from typing import Any
from dulwich import porcelain
from fastapi import UploadFile
from backend.common.enums import StatusType
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client
from backend.plugin.tools import install_requirements_async, uninstall_requirements_async
from backend.utils.re_verify import is_git_url
from backend.utils.timezone import timezone
class PluginService:
"""插件服务类"""
@staticmethod
async def get_all() -> list[dict[str, Any]]:
"""获取所有插件"""
keys = []
result = []
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'):
keys.append(key)
for info in await redis_client.mget(*keys):
result.append(json.loads(info))
return result
@staticmethod
async def changed() -> str | None:
"""插件状态是否变更"""
return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
@staticmethod
async def install_zip(*, file: UploadFile) -> None:
"""
通过 zip 压缩包安装插件
:param file: 插件 zip 压缩包
:return:
"""
contents = await file.read()
file_bytes = io.BytesIO(contents)
if not zipfile.is_zipfile(file_bytes):
raise errors.ForbiddenError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_dir = file.filename[:-4]
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir)]
if not members_in_plugin_dir:
raise errors.ForbiddenError(msg='插件压缩包内容非法')
plugin_name = (
members_in_plugin_dir[0]
.replace('/', '')
.replace('-master', '')
.replace('-main', '')
.replace('-dev', '')
)
if (
len(members_in_plugin_dir) <= 3
or f'{plugin_dir}/plugin.toml' not in members_in_plugin_dir
or f'{plugin_dir}/README.md' not in members_in_plugin_dir
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir):
new_filename = member.filename.replace(plugin_dir, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(os.path.join(PLUGIN_DIR, plugin_name), members)
await install_requirements_async(plugin_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def install_git(*, repo_url: str):
"""
通过 git 安装插件
:param repo_url: git 存储库的 URL
:return:
"""
match = is_git_url(repo_url)
if not match:
raise errors.ForbiddenError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
if repo_name in plugins:
raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
except Exception as e:
log.error(f'插件安装失败: {e}')
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
else:
await install_requirements_async(repo_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def uninstall(*, plugin: str):
"""
卸载插件
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
shutil.move(plugin_dir, bacup_dir)
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
await redis_client.hdel(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def update_status(*, plugin: str):
"""
更新插件状态
:param plugin: 插件名称
:return:
"""
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
if not plugin_info:
raise errors.ForbiddenError(msg='插件不存在')
plugin_info = json.loads(plugin_info)
# 更新持久缓存状态
new_status = (
str(StatusType.enable.value)
if plugin_info['plugin']['enable'] == str(StatusType.disable.value)
else str(StatusType.disable.value)
)
plugin_info['plugin']['enable'] = new_status
await redis_client.set(
f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False)
)
await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, new_status)
@staticmethod
async def build(*, plugin: str) -> io.BytesIO:
"""
打包插件为 zip 压缩包
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, arcname)
bio.seek(0)
return bio
plugin_service: PluginService = PluginService()
+46 -13
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Any, Sequence
from sqlalchemy import Select from sqlalchemy import Select
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.crud.crud_menu import menu_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.crud.crud_role import role_dao
from backend.app.admin.model import Role from backend.app.admin.model import Role
@@ -12,12 +12,13 @@ from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleRuleParam, UpdateRoleScopeParam,
) )
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data
class RoleService: class RoleService:
@@ -45,7 +46,7 @@ class RoleService:
return roles return roles
@staticmethod @staticmethod
async def get_by_user(*, pk: int) -> Sequence[Role]: async def get_users(*, pk: int) -> Sequence[Role]:
""" """
获取用户的角色列表 获取用户的角色列表
@@ -53,7 +54,7 @@ class RoleService:
:return: :return:
""" """
async with async_db_session() as db: async with async_db_session() as db:
roles = await role_dao.get_by_user(db, user_id=pk) roles = await role_dao.get_users(db, user_id=pk)
return roles return roles
@staticmethod @staticmethod
@@ -67,6 +68,38 @@ class RoleService:
""" """
return await role_dao.get_list(name=name, status=status) return await role_dao.get_list(name=name, status=status)
@staticmethod
async def get_menu_tree(*, pk: int) -> list[dict[str, Any]]:
"""
获取角色的菜单树形结构
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menu_ids = [menu.id for menu in role.menus]
menu_select = await menu_dao.get_role_menus(db, False, menu_ids)
menu_tree = get_tree_data(menu_select)
return menu_tree
@staticmethod
async def get_scopes(*, pk: int) -> list[int]:
"""
获取角色数据范围列表
:param pk:
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
scope_ids = [scope.id for scope in role.scopes]
return scope_ids
@staticmethod @staticmethod
async def create(*, obj: CreateRoleParam) -> None: async def create(*, obj: CreateRoleParam) -> None:
""" """
@@ -126,23 +159,23 @@ class RoleService:
return count return count
@staticmethod @staticmethod
async def update_role_rule(*, pk: int, rule_ids: UpdateRoleRuleParam) -> int: async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
""" """
更新角色数据规则 更新角色数据范围
:param pk: 角色 ID :param pk: 角色 ID
:param rule_ids: 权限规则 ID 列表 :param scope_ids: 权限规则 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
for rule_id in rule_ids.rules: for scope_id in scope_ids.scopes:
rule = await data_rule_dao.get(db, rule_id) scope = await data_scope_dao.get(db, scope_id)
if not rule: if not scope:
raise errors.NotFoundError(msg='数据规则不存在') raise errors.NotFoundError(msg='数据范围不存在')
count = await role_dao.update_rules(db, pk, rule_ids) count = await role_dao.update_scopes(db, pk, scope_ids)
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+1 -1
View File
@@ -191,7 +191,7 @@ class UserService:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if request.user.username != username: if request.user.username != username:
raise errors.AuthorizationError raise errors.AuthorizationError(msg='你只能修改自己的信息')
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='用户不存在')
+9 -10
View File
@@ -5,7 +5,6 @@ from typing import Any
import celery import celery
import celery_aio_pool import celery_aio_pool
from backend.app.task.conf import task_settings
from backend.core.conf import settings from backend.core.conf import settings
__all__ = ['celery_app'] __all__ = ['celery_app']
@@ -13,14 +12,14 @@ __all__ = ['celery_app']
def get_broker_url() -> str: def get_broker_url() -> str:
"""获取消息代理 URL""" """获取消息代理 URL"""
if task_settings.CELERY_BROKER == 'redis': if settings.CELERY_BROKER == 'redis':
return ( return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
) )
return ( return (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@' f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}' f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
) )
@@ -28,16 +27,16 @@ def get_result_backend() -> str:
"""获取结果后端 URL""" """获取结果后端 URL"""
return ( return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BACKEND_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BACKEND_REDIS_DATABASE}'
) )
def get_result_backend_transport_options() -> dict[str, Any]: def get_result_backend_transport_options() -> dict[str, Any]:
"""获取结果后端传输选项""" """获取结果后端传输选项"""
return { return {
'global_keyprefix': task_settings.CELERY_BACKEND_REDIS_PREFIX, 'global_keyprefix': settings.CELERY_BACKEND_REDIS_PREFIX,
'retry_policy': { 'retry_policy': {
'timeout': task_settings.CELERY_BACKEND_REDIS_TIMEOUT, 'timeout': settings.CELERY_BACKEND_REDIS_TIMEOUT,
}, },
} }
@@ -55,7 +54,7 @@ def init_celery() -> celery.Celery:
'fba_celery', 'fba_celery',
enable_utc=False, enable_utc=False,
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
beat_schedule=task_settings.CELERY_SCHEDULE, beat_schedule=settings.CELERY_SCHEDULE,
broker_url=get_broker_url(), broker_url=get_broker_url(),
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
result_backend=get_result_backend(), result_backend=get_result_backend(),
@@ -65,7 +64,7 @@ def init_celery() -> celery.Celery:
) )
# 自动发现任务 # 自动发现任务
app.autodiscover_tasks(task_settings.CELERY_TASK_PACKAGES) app.autodiscover_tasks(settings.CELERY_TASK_PACKAGES)
return app return app
+2 -2
View File
@@ -7,15 +7,15 @@ from typing import Any
from celery import Task from celery import Task
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from backend.app.task.conf import task_settings
from backend.common.socketio.actions import task_notification from backend.common.socketio.actions import task_notification
from backend.core.conf import settings
class TaskBase(Task): class TaskBase(Task):
"""Celery 任务基类""" """Celery 任务基类"""
autoretry_for = (SQLAlchemyError,) autoretry_for = (SQLAlchemyError,)
max_retries = task_settings.CELERY_TASK_MAX_RETRIES max_retries = settings.CELERY_TASK_MAX_RETRIES
async def before_start(self, task_id: str, args, kwargs) -> None: async def before_start(self, task_id: str, args, kwargs) -> None:
""" """
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from typing import Any, Literal
from celery.schedules import crontab
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BASE_PATH
class TaskSettings(BaseSettings):
"""Celery 任务配置"""
model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# .env 环境
ENVIRONMENT: Literal['dev', 'pro']
# .env Redis 配置
CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int
# .env RabbitMQ 配置
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
RABBITMQ_HOST: str
RABBITMQ_PORT: int
RABBITMQ_USERNAME: str
RABBITMQ_PASSWORD: str
# Celery 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5
CELERY_TASK_PACKAGES: list[str] = [
'app.task.celery_task',
'app.task.celery_task.db_log',
]
CELERY_TASK_MAX_RETRIES: int = 5
# Celery 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': {
'task': 'task_demo_async',
'schedule': 10,
},
'exec-every-sunday': {
'task': 'delete_db_opera_log',
'schedule': crontab('0', '0', day_of_week='6'),
},
'exec-every-15-of-month': {
'task': 'delete_db_login_log',
'schedule': crontab('0', '0', day_of_month='15'),
},
}
@model_validator(mode='before')
@classmethod
def validate_celery_broker(cls, values: Any) -> Any:
"""生产环境强制使用 RabbitMQ 作为消息代理"""
if values['ENVIRONMENT'] == 'pro':
values['CELERY_BROKER'] = 'rabbitmq'
return values
@lru_cache
def get_task_settings() -> TaskSettings:
"""获取 Celery 任务配置"""
return TaskSettings()
task_settings = get_task_settings()
+3 -3
View File
@@ -19,11 +19,11 @@ class TaskResult(SchemaBase):
"""任务执行结果""" """任务执行结果"""
result: str = Field(description='任务执行结果') result: str = Field(description='任务执行结果')
traceback: str = Field(description='错误堆栈信息') traceback: str | None = Field(None, description='错误堆栈信息')
status: str = Field(description='任务状态') status: str = Field(description='任务状态')
name: str = Field(description='任务名称') name: str | None = Field(None, description='任务名称')
args: list[Any] | None = Field(None, description='任务函数位置参数') args: list[Any] | None = Field(None, description='任务函数位置参数')
kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数') kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
worker: str = Field(description='执行任务的 worker') worker: str | None = Field(None, description='执行任务的 worker')
retries: int | None = Field(None, description='重试次数') retries: int | None = Field(None, description='重试次数')
queue: str | None = Field(None, description='任务队列') queue: str | None = Field(None, description='任务队列')
+2
View File
@@ -44,6 +44,8 @@ class MenuType(IntEnum):
directory = 0 directory = 0
menu = 1 menu = 1
button = 2 button = 2
embedded = 3
link = 4
class RoleDataRuleOperatorType(IntEnum): class RoleDataRuleOperatorType(IntEnum):
+4
View File
@@ -39,6 +39,10 @@ class CustomResponseCode(CustomCodeBase):
HTTP_503 = (503, '服务器暂时无法处理请求') HTTP_503 = (503, '服务器暂时无法处理请求')
HTTP_504 = (504, '网关超时') HTTP_504 = (504, '网关超时')
# Plugin
PLUGIN_INSTALL_SUCCESS = (200, '插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务')
PLUGIN_UNINSTALL_SUCCESS = (200, '插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
class CustomErrorCode(CustomCodeBase): class CustomErrorCode(CustomCodeBase):
"""自定义错误状态码""" """自定义错误状态码"""
+1 -1
View File
@@ -40,7 +40,7 @@ class ResponseModel(BaseModel):
class ResponseSchemaModel(ResponseModel, Generic[SchemaT]): class ResponseSchemaModel(ResponseModel, Generic[SchemaT]):
""" """
包含返回数据 schema 的通用型统一返回模型仅适用于非分页接口 包含返回数据 schema 的通用型统一返回模型
示例:: 示例::
+54 -35
View File
@@ -4,7 +4,9 @@ from typing import TYPE_CHECKING
from fastapi import Request from fastapi import Request
from sqlalchemy import ColumnElement, and_, or_ from sqlalchemy import ColumnElement, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.exception.errors import ServerError from backend.common.exception.errors import ServerError
@@ -12,7 +14,7 @@ from backend.core.conf import settings
from backend.utils.import_parse import dynamic_import_data_model from backend.utils.import_parse import dynamic_import_data_model
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.schema.data_rule import GetDataRuleDetail from backend.app.admin.model import DataRule
class RequestPermission: class RequestPermission:
@@ -47,33 +49,48 @@ class RequestPermission:
request.state.permission = self.value request.state.permission = self.value
def filter_data_permission(request: Request) -> ColumnElement[bool]: async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnElement[bool]:
""" """
过滤数据权限控制用户可见数据范围 过滤数据权限控制用户可见数据范围
使用场景 使用场景
- 用户登录前台后控制其能看到哪些数据 - 控制用户能看到哪些数据
- 根据用户角色和规则过滤数据访问权限
:param db: 数据库会话
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
# 获取用户角色和规则 # 获取用户角色和数据范围
data_rules = [] data_scopes = []
for role in request.user.roles: for role in request.user.roles:
data_rules.extend(role.rules) for scope in role.scopes:
user_data_rules: list[GetDataRuleDetail] = list(dict.fromkeys(data_rules)) if scope.status:
data_scopes.append(scope)
# 超级管理员和无规则用户不做过滤 # 超级管理员和无规则用户不做过滤
if request.user.is_superuser or not user_data_rules: if request.user.is_superuser or not data_scopes:
return or_(1 == 1) return or_(1 == 1)
# 获取数据范围规则
data_rule_list: list[DataRule] = []
for data_scope in data_scopes:
data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope.id)
data_rule_list.extend(data_scope_with_relation.rules)
# 去重
seen_data_rule_ids = set()
new_data_rule_list = []
for rule in data_rule_list:
if rule.id not in seen_data_rule_ids:
seen_data_rule_ids.add(rule.id)
new_data_rule_list.append(rule)
where_and_list = [] where_and_list = []
where_or_list = [] where_or_list = []
for rule in user_data_rules: for data_rule in new_data_rule_list:
# 验证规则模型 # 验证规则模型
rule_model = rule.model rule_model = data_rule.model
if rule_model not in settings.DATA_PERMISSION_MODELS: if rule_model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则模型不存在') raise errors.NotFoundError(msg='数据规则模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model]) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
@@ -82,39 +99,41 @@ def filter_data_permission(request: Request) -> ColumnElement[bool]:
model_columns = [ model_columns = [
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
] ]
column = rule.column column = data_rule.column
if column not in model_columns: if column not in model_columns:
raise errors.NotFoundError(msg='数据规则模型列不存在') raise errors.NotFoundError(msg='数据规则模型列不存在')
# 构建过滤条件 # 构建过滤条件
column_obj = getattr(model_ins, column) column_obj = getattr(model_ins, column)
rule_expression = rule.expression rule_expression = data_rule.expression
condition = None condition = None
if rule_expression == RoleDataRuleExpressionType.eq: match rule_expression:
condition = column_obj == rule.value case RoleDataRuleExpressionType.eq:
elif rule_expression == RoleDataRuleExpressionType.ne: condition = column_obj == data_rule.value
condition = column_obj != rule.value case RoleDataRuleExpressionType.ne:
elif rule_expression == RoleDataRuleExpressionType.gt: condition = column_obj != data_rule.value
condition = column_obj > rule.value case RoleDataRuleExpressionType.gt:
elif rule_expression == RoleDataRuleExpressionType.ge: condition = column_obj > data_rule.value
condition = column_obj >= rule.value case RoleDataRuleExpressionType.ge:
elif rule_expression == RoleDataRuleExpressionType.lt: condition = column_obj >= data_rule.value
condition = column_obj < rule.value case RoleDataRuleExpressionType.lt:
elif rule_expression == RoleDataRuleExpressionType.le: condition = column_obj < data_rule.value
condition = column_obj <= rule.value case RoleDataRuleExpressionType.le:
elif rule_expression == RoleDataRuleExpressionType.in_: condition = column_obj <= data_rule.value
values = rule.value.split(',') if isinstance(rule.value, str) else rule.value case RoleDataRuleExpressionType.in_:
condition = column_obj.in_(values) values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
elif rule.expression == RoleDataRuleExpressionType.not_in: condition = column_obj.in_(values)
values = rule.value.split(',') if isinstance(rule.value, str) else rule.value case RoleDataRuleExpressionType.not_in:
condition = ~column_obj.in_(values) values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
condition = column_obj.not_in(values)
# 根据运算符添加到对应列表 # 根据运算符添加到对应列表
if condition is not None: if condition is not None:
if rule.operator == RoleDataRuleOperatorType.AND: match data_rule.operator:
where_and_list.append(condition) case RoleDataRuleOperatorType.AND:
elif rule.operator == RoleDataRuleOperatorType.OR: where_and_list.append(condition)
where_or_list.append(condition) case RoleDataRuleOperatorType.OR:
where_or_list.append(condition)
# 组合所有条件 # 组合所有条件
where_list = [] where_list = []
+1 -1
View File
@@ -70,7 +70,7 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
raise AuthorizationError raise AuthorizationError
else: else:
try: try:
casbin_rbac = import_module_cached('backend.plugin.casbin.utils.rbac') casbin_rbac = import_module_cached('backend.plugin.casbin_rbac.rbac')
casbin_verify = getattr(casbin_rbac, 'casbin_verify') casbin_verify = getattr(casbin_rbac, 'casbin_verify')
except (ImportError, AttributeError) as e: except (ImportError, AttributeError) as e:
log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}') log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}')
+4 -5
View File
@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import socketio import socketio
from backend.app.task.conf import task_settings
from backend.common.log import log from backend.common.log import log
from backend.common.security.jwt import jwt_authentication from backend.common.security.jwt import jwt_authentication
from backend.core.conf import settings from backend.core.conf import settings
@@ -13,13 +12,13 @@ sio = socketio.AsyncServer(
# 集成 Celery 实现消息订阅 # 集成 Celery 实现消息订阅
client_manager=socketio.AsyncRedisManager( client_manager=socketio.AsyncRedisManager(
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
) )
if task_settings.CELERY_BROKER == 'redis' if settings.CELERY_BROKER == 'redis'
else socketio.AsyncAioPikaManager( else socketio.AsyncAioPikaManager(
( (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@' f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}' f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
) )
), ),
async_mode='asgi', async_mode='asgi',
+69 -4
View File
@@ -3,6 +3,7 @@
from functools import lru_cache from functools import lru_cache
from typing import Any, Literal from typing import Any, Literal
from celery.schedules import crontab
from pydantic import model_validator from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -77,7 +78,7 @@ class Settings(BaseSettings):
JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天 JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
# RBAC # RBAC
RBAC_ROLE_MENU_MODE: bool = False RBAC_ROLE_MENU_MODE: bool = True
RBAC_ROLE_MENU_EXCLUDE: list[str] = [ RBAC_ROLE_MENU_EXCLUDE: list[str] = [
'sys:monitor:redis', 'sys:monitor:redis',
'sys:monitor:server', 'sys:monitor:server',
@@ -89,11 +90,12 @@ class Settings(BaseSettings):
# 数据权限配置 # 数据权限配置
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义 DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
'Api': 'backend.plugin.casbin.model.Api', '部门': 'backend.app.admin.model.Dept',
} }
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列 DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
'id', 'id',
'sort', 'sort',
'del_flag',
'created_time', 'created_time',
'updated_time', 'updated_time',
] ]
@@ -179,17 +181,80 @@ class Settings(BaseSettings):
'confirm_password', 'confirm_password',
] ]
# 插件配置 # Plugin 配置
PLUGIN_PIP_CHINA: bool = True PLUGIN_PIP_CHINA: bool = True
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/' PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
# App Admin
# .env OAuth2
OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# OAuth2
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
# App Task
# .env Redis
CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int
# .env RabbitMQ
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
CELERY_RABBITMQ_HOST: str
CELERY_RABBITMQ_PORT: int
CELERY_RABBITMQ_USERNAME: str
CELERY_RABBITMQ_PASSWORD: str
# 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5
CELERY_TASK_PACKAGES: list[str] = [
'app.task.celery_task',
'app.task.celery_task.db_log',
]
CELERY_TASK_MAX_RETRIES: int = 5
# 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': {
'task': 'task_demo_async',
'schedule': 10,
},
'exec-every-sunday': {
'task': 'delete_db_opera_log',
'schedule': crontab('0', '0', day_of_week='6'),
},
'exec-every-15-of-month': {
'task': 'delete_db_login_log',
'schedule': crontab('0', '0', day_of_month='15'),
},
}
# Plugin Code Generator
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
# Plugin Config
CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy']
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def check_env(cls, values: Any) -> Any: def check_env(cls, values: Any) -> Any:
"""生产环境下禁用 OpenAPI 文档和静态文件服务""" """检查环境变量"""
if values.get('ENVIRONMENT') == 'pro': if values.get('ENVIRONMENT') == 'pro':
# FastAPI
values['FASTAPI_OPENAPI_URL'] = None values['FASTAPI_OPENAPI_URL'] = None
values['FASTAPI_STATIC_FILES'] = False values['FASTAPI_STATIC_FILES'] = False
# Task
values['CELERY_BROKER'] = 'rabbitmq'
return values return values
-2
View File
@@ -40,8 +40,6 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
""" """
# 创建数据库表 # 创建数据库表
await create_table() await create_table()
# 连接 redis
await redis_client.open()
# 初始化 limiter # 初始化 limiter
await FastAPILimiter.init( await FastAPILimiter.init(
redis=redis_client, redis=redis_client,
+1 -1
View File
@@ -71,7 +71,7 @@ class JwtAuthMiddleware(AuthenticationBackend):
except TokenError as exc: except TokenError as exc:
raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers) raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers)
except Exception as e: except Exception as e:
log.error(f'JWT 授权异常:{e}') log.exception(f'JWT 授权异常:{e}')
raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error')) raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性 # 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request
from backend.common.pagination import DependsPagination, PageData, paging_data
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
from backend.plugin.casbin.schema.api import CreateApiParam, GetApiDetail, UpdateApiParam
from backend.plugin.casbin.service.api_service import api_service
router = APIRouter()
@router.get('/all', summary='获取所有接口', dependencies=[DependsJwtAuth])
async def get_all_apis() -> ResponseSchemaModel[list[GetApiDetail]]:
data = await api_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取接口详情', dependencies=[DependsJwtAuth])
async def get_api(pk: Annotated[int, Path(description='API ID')]) -> ResponseSchemaModel[GetApiDetail]:
api = await api_service.get(pk=pk)
return response_base.success(data=api)
@router.get(
'',
summary='分页获取所有接口',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_pagination_apis(
request: Request,
db: CurrentSession,
name: Annotated[str | None, Query(description='API 名称')] = None,
method: Annotated[str | None, Query(description='请求方法')] = None,
path: Annotated[str | None, Query(description='API 路径')] = None,
) -> ResponseSchemaModel[PageData[GetApiDetail]]:
api_select = await api_service.get_select(request=request, name=name, method=method, path=path)
page_data = await paging_data(db, api_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建接口',
dependencies=[
Depends(RequestPermission('sys:api:add')),
DependsRBAC,
],
)
async def create_api(obj: CreateApiParam) -> ResponseModel:
await api_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新接口',
dependencies=[
Depends(RequestPermission('sys:api:edit')),
DependsRBAC,
],
)
async def update_api(pk: Annotated[int, Path(description='API ID')], obj: UpdateApiParam) -> ResponseModel:
count = await api_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除接口',
dependencies=[
Depends(RequestPermission('sys:api:del')),
DependsRBAC,
],
)
async def delete_api(pk: Annotated[list[int], Query(description='API ID 列表')]) -> ResponseModel:
count = await api_service.delete(pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from backend.common.pagination import DependsPagination, PageData, paging_data
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
from backend.plugin.casbin.schema.casbin_rule import (
CreateGroupParam,
CreatePolicyParam,
DeleteAllPoliciesParam,
DeleteGroupParam,
DeletePolicyParam,
GetPolicyDetail,
UpdatePoliciesParam,
UpdatePolicyParam,
)
from backend.plugin.casbin.service.casbin_service import casbin_service
router = APIRouter()
@router.get(
'',
summary='分页获取所有权限策略',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_pagination_casbin(
db: CurrentSession,
ptype: Annotated[str | None, Query(description='策略类型:p / g')] = None,
sub: Annotated[str | None, Query(description='用户 UUID / 角色 ID')] = None,
) -> ResponseSchemaModel[PageData[GetPolicyDetail]]:
casbin_select = await casbin_service.get_casbin_list(ptype=ptype, sub=sub)
page_data = await paging_data(db, casbin_select)
return response_base.success(data=page_data)
@router.get('/policies', summary='获取所有 P 权限策略', dependencies=[DependsJwtAuth])
async def get_all_policies(
role: Annotated[int | None, Query(description='角色 ID')] = None,
) -> ResponseSchemaModel[list[list[str]]]:
policies = await casbin_service.get_policy_list(role=role)
return response_base.success(data=policies)
@router.post(
'/policy',
summary='添加 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:add')),
DependsRBAC,
],
)
async def create_policy(p: CreatePolicyParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_policy(p=p)
return response_base.success(data=data)
@router.post(
'/policies',
summary='添加多组 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:group:add')),
DependsRBAC,
],
)
async def create_policies(ps: list[CreatePolicyParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_policies(ps=ps)
return response_base.success(data=data)
@router.put(
'/policy',
summary='更新 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:edit')),
DependsRBAC,
],
)
async def update_policy(obj: UpdatePolicyParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.update_policy(obj=obj)
return response_base.success(data=data)
@router.put(
'/policies',
summary='更新多组 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:group:edit')),
DependsRBAC,
],
)
async def update_policies(obj: UpdatePoliciesParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.update_policies(obj=obj)
return response_base.success(data=data)
@router.delete(
'/policy',
summary='删除 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:del')),
DependsRBAC,
],
)
async def delete_policy(p: DeletePolicyParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_policy(p=p)
return response_base.success(data=data)
@router.delete(
'/policies',
summary='删除多组 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:group:del')),
DependsRBAC,
],
)
async def delete_policies(ps: list[DeletePolicyParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_policies(ps=ps)
return response_base.success(data=data)
@router.delete(
'/policies/all',
summary='删除所有 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:empty')),
DependsRBAC,
],
)
async def delete_all_policies(sub: DeleteAllPoliciesParam) -> ResponseModel:
count = await casbin_service.delete_all_policies(sub=sub)
if count > 0:
return response_base.success()
return response_base.fail()
@router.get('/groups', summary='获取所有 G 权限策略', dependencies=[DependsJwtAuth])
async def get_all_groups() -> ResponseSchemaModel[list[list[str]]]:
data = await casbin_service.get_group_list()
return response_base.success(data=data)
@router.post(
'/group',
summary='添加 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:add')),
DependsRBAC,
],
)
async def create_group(g: CreateGroupParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_group(g=g)
return response_base.success(data=data)
@router.post(
'/groups',
summary='添加多组 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:group:add')),
DependsRBAC,
],
)
async def create_groups(gs: list[CreateGroupParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_groups(gs=gs)
return response_base.success(data=data)
@router.delete(
'/group',
summary='删除 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:del')),
DependsRBAC,
],
)
async def delete_group(g: DeleteGroupParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_group(g=g)
return response_base.success(data=data)
@router.delete(
'/groups',
summary='删除多组 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:group:del')),
DependsRBAC,
],
)
async def delete_groups(gs: list[DeleteGroupParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_groups(gs=gs)
return response_base.success(data=data)
@router.delete(
'/groups/all',
summary='删除所有 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:empty')),
DependsRBAC,
],
)
async def delete_all_groups(uuid: Annotated[UUID, Query()]) -> ResponseModel:
count = await casbin_service.delete_all_groups(uuid=uuid)
if count > 0:
return response_base.success()
return response_base.fail()
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
from backend.core.conf import settings
class CasbinSettings(BaseSettings):
"""Casbin 配置"""
# RBAC
RBAC_CASBIN_EXCLUDE: set[tuple[str, str]] = {
('POST', f'{settings.FASTAPI_API_V1_PATH}/auth/logout'),
('POST', f'{settings.FASTAPI_API_V1_PATH}/auth/token/new'),
}
@lru_cache
def get_casbin_settings() -> CasbinSettings:
"""获取 Casbin 配置"""
return CasbinSettings()
casbin_settings = get_casbin_settings()
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from fastapi import Request
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.common.security.permission import filter_data_permission
from backend.plugin.casbin.model import Api
from backend.plugin.casbin.schema.api import CreateApiParam, UpdateApiParam
class CRUDApi(CRUDPlus[Api]):
"""API 数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> Api | None:
"""
获取 API
:param db: 数据库会话
:param pk: API ID
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, request: Request, name: str = None, method: str = None, path: str = None) -> Select:
"""
获取 API 列表
:param request: FastAPI 请求对象
:param name: API 名称
:param method: 请求方法
:param path: API 路径
:return:
"""
filters = {}
if name is not None:
filters.update(name__like=f'%{name}%')
if method is not None:
filters.update(method=method)
if path is not None:
filters.update(path__like=f'%{path}%')
stmt = await self.select_order('created_time', 'desc', **filters)
return stmt.where(filter_data_permission(request))
async def get_all(self, db: AsyncSession) -> Sequence[Api]:
"""
获取所有 API
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def get_by_name(self, db: AsyncSession, name: str) -> Api | None:
"""
通过名称获取 API
:param db: 数据库会话
:param name: API 名称
:return:
"""
return await self.select_model_by_column(db, name=name)
async def create(self, db: AsyncSession, obj: CreateApiParam) -> None:
"""
创建 API
:param db: 数据库会话
:param obj: 创建 API 参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateApiParam) -> int:
"""
更新 API
:param db: 数据库会话
:param pk: API ID
:param obj: 更新 API 参数
:return:
"""
return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除 API
:param db: 数据库会话
:param pk: API ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
api_dao: CRUDApi = CRUDApi(Api)
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from uuid import UUID
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.plugin.casbin.model import CasbinRule
from backend.plugin.casbin.schema.casbin_rule import DeleteAllPoliciesParam
class CRUDCasbin(CRUDPlus[CasbinRule]):
"""Casbin 规则数据库操作类"""
async def get_list(self, ptype: str, sub: str) -> Select:
"""
获取策略列表
:param ptype: 策略类型
:param sub: 用户 UUID / 角色 ID
:return:
"""
return await self.select_order('id', 'desc', ptype=ptype, v0__like=f'%{sub}%')
async def delete_policies_by_sub(self, db: AsyncSession, sub: DeleteAllPoliciesParam) -> int:
"""
删除角色所有 P 策略
:param db: 数据库会话
:param sub: 删除所有 P 策略参数
:return:
"""
filters = [sub.role]
if sub.uuid:
filters.append(sub.uuid)
return await self.delete_model_by_column(db, allow_multiple=True, v0__mor={'eq': filters})
async def delete_groups_by_uuid(self, db: AsyncSession, uuid: UUID) -> int:
"""
删除用户所有 G 策略
:param db: 数据库会话
:param uuid: 用户 UUID
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, v0=str(uuid))
casbin_dao: CRUDCasbin = CRUDCasbin(CasbinRule)
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.plugin.casbin.model.api import Api
from backend.plugin.casbin.model.casbin_rule import CasbinRule
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
class Api(Base):
"""API 表"""
__tablename__ = 'sys_api'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='API 名称')
method: Mapped[str] = mapped_column(String(16), comment='请求方法')
path: Mapped[str] = mapped_column(String(500), comment='API 路径')
remark: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='备注')
@@ -1,34 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import MappedBase, id_key
class CasbinRule(MappedBase):
"""Casbin 规则表"""
__tablename__ = 'sys_casbin_rule'
id: Mapped[id_key]
ptype: Mapped[str] = mapped_column(String(255), comment='策略类型: p / g')
v0: Mapped[str] = mapped_column(String(255), comment='用户 UUID / 角色 ID')
v1: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='API 路径 / 角色名称')
v2: Mapped[str | None] = mapped_column(String(255), comment='请求方法')
v3: Mapped[str | None] = mapped_column(String(255), comment='预留字段')
v4: Mapped[str | None] = mapped_column(String(255), comment='预留字段')
v5: Mapped[str | None] = mapped_column(String(255), comment='预留字段')
def __str__(self) -> str:
arr = [self.ptype]
for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5):
if v is None:
break
arr.append(v)
return ', '.join(arr)
def __repr__(self) -> str:
return f'<CasbinRule {self.id}: "{str(self)}">'
-10
View File
@@ -1,10 +0,0 @@
[app]
include = 'admin'
[api.api]
prefix = '/apis'
tags = '系统API'
[api.casbin]
prefix = '/casbin'
tags = '系统Casbin权限'
-2
View File
@@ -1,2 +0,0 @@
casbin>=1.38.0
casbin_async_sqlalchemy_adapter>=1.7.0
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import MethodType
from backend.common.schema import SchemaBase
class ApiSchemaBase(SchemaBase):
"""API 基础模型"""
name: str = Field(description='API 名称')
method: MethodType = Field(MethodType.GET, description='请求方法')
path: str = Field(description='API 路径')
remark: str | None = Field(None, description='备注')
class CreateApiParam(ApiSchemaBase):
"""创建 API 参数"""
class UpdateApiParam(ApiSchemaBase):
"""更新 API 参数"""
class GetApiDetail(ApiSchemaBase):
"""API 详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='API ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import ConfigDict, Field
from backend.common.enums import MethodType
from backend.common.schema import SchemaBase
class CreatePolicyParam(SchemaBase):
"""创建 P 策略参数"""
sub: str = Field(description='用户 UUID / 角色 ID')
path: str = Field(description='API 路径')
method: MethodType = Field(MethodType.GET, description='请求方法')
class UpdatePolicyParam(SchemaBase):
"""更新 P 策略参数"""
old: CreatePolicyParam = Field(description='原策略')
new: CreatePolicyParam = Field(description='新策略')
class UpdatePoliciesParam(SchemaBase):
"""批量更新策略参数"""
old: list[CreatePolicyParam] = Field(description='原策略列表')
new: list[CreatePolicyParam] = Field(description='新策略列表')
class DeletePolicyParam(CreatePolicyParam):
"""删除策略参数"""
class DeleteAllPoliciesParam(SchemaBase):
"""删除所有策略参数"""
uuid: str | None = Field(None, description='用户 UUID')
role: str = Field(description='角色')
class CreateGroupParam(SchemaBase):
"""创建 G 策略参数"""
uuid: str = Field(description='用户 UUID')
role: str = Field(description='角色')
class DeleteGroupParam(CreateGroupParam):
"""删除 G 策略参数"""
class GetPolicyDetail(SchemaBase):
"""策略详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='规则 ID')
ptype: str = Field(description='规则类型, p / g')
v0: str = Field(description='用户 UUID / 角色 ID')
v1: str = Field(description='API 路径 / 角色')
v2: str | None = Field(None, description='请求方法')
v3: str | None = Field(None, description='预留字段')
v4: str | None = Field(None, description='预留字段')
v5: str | None = Field(None, description='预留字段')
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from fastapi import Request
from sqlalchemy import Select
from backend.common.exception import errors
from backend.database.db import async_db_session
from backend.plugin.casbin.crud.crud_api import api_dao
from backend.plugin.casbin.model import Api
from backend.plugin.casbin.schema.api import CreateApiParam, UpdateApiParam
class ApiService:
"""API 服务类"""
@staticmethod
async def get(*, pk: int) -> Api:
"""
获取 API
:param pk: API ID
:return:
"""
async with async_db_session() as db:
api = await api_dao.get(db, pk)
if not api:
raise errors.NotFoundError(msg='接口不存在')
return api
@staticmethod
async def get_select(*, request: Request, name: str = None, method: str = None, path: str = None) -> Select:
"""
获取 API 查询对象
:param request: 请求对象
:param name: API 名称
:param method: 请求方法
:param path: API 路径
:return:
"""
return await api_dao.get_list(request=request, name=name, method=method, path=path)
@staticmethod
async def get_all() -> Sequence[Api]:
"""获取所有 API"""
async with async_db_session() as db:
apis = await api_dao.get_all(db)
return apis
@staticmethod
async def create(*, obj: CreateApiParam) -> None:
"""
创建 API
:param obj: 创建 API 参数
:return:
"""
async with async_db_session.begin() as db:
api = await api_dao.get_by_name(db, obj.name)
if api:
raise errors.ForbiddenError(msg='接口已存在')
await api_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateApiParam) -> int:
"""
更新 API
:param pk: API ID
:param obj: 更新 API 参数
:return:
"""
async with async_db_session.begin() as db:
api = await api_dao.get(db, pk)
if not api:
raise errors.NotFoundError(msg='接口不存在')
count = await api_dao.update(db, pk, obj)
return count
@staticmethod
async def delete(*, pk: list[int]) -> int:
"""
删除 API
:param pk: API ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await api_dao.delete(db, pk)
return count
api_service: ApiService = ApiService()
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from uuid import UUID
from sqlalchemy import Select
from backend.common.exception import errors
from backend.database.db import async_db_session
from backend.plugin.casbin.crud.crud_casbin import casbin_dao
from backend.plugin.casbin.schema.casbin_rule import (
CreateGroupParam,
CreatePolicyParam,
DeleteAllPoliciesParam,
DeleteGroupParam,
DeletePolicyParam,
UpdatePoliciesParam,
UpdatePolicyParam,
)
from backend.plugin.casbin.utils.rbac import casbin_enforcer
class CasbinService:
"""Casbin 权限服务类"""
@staticmethod
async def get_casbin_list(*, ptype: str, sub: str) -> Select:
"""
获取 Casbin 规则列表
:param ptype: 策略类型
:param sub: 用户 UUID / 角色 ID
:return:
"""
return await casbin_dao.get_list(ptype, sub)
@staticmethod
async def get_policy_list(*, role: int | None) -> list:
"""
获取 P 策略列表
:param role: 角色ID
:return:
"""
enforcer = await casbin_enforcer()
if role is not None:
data = enforcer.get_filtered_named_policy('p', 0, str(role))
else:
data = enforcer.get_policy()
return data
@staticmethod
async def create_policy(*, p: CreatePolicyParam) -> bool:
"""
创建 P 策略
:param p: 策略参数
:return:
"""
enforcer = await casbin_enforcer()
data = await enforcer.add_policy(p.sub, p.path, p.method)
if not data:
raise errors.ForbiddenError(msg='权限已存在')
return data
@staticmethod
async def create_policies(*, ps: list[CreatePolicyParam]) -> bool:
"""
批量创建 P 策略
:param ps: 策略参数列表
:return:
"""
enforcer = await casbin_enforcer()
data = await enforcer.add_policies([list(p.model_dump().values()) for p in ps])
if not data:
raise errors.ForbiddenError(msg='权限已存在')
return data
@staticmethod
async def update_policy(*, obj: UpdatePolicyParam) -> bool:
"""
更新 P 策略
:param obj: 更新 P 策略参数
:return:
"""
old_obj = obj.old
new_obj = obj.new
enforcer = await casbin_enforcer()
_p = enforcer.has_policy(old_obj.sub, old_obj.path, old_obj.method)
if not _p:
raise errors.NotFoundError(msg='权限不存在')
data = await enforcer.update_policy(
[old_obj.sub, old_obj.path, old_obj.method],
[new_obj.sub, new_obj.path, new_obj.method],
)
return data
@staticmethod
async def update_policies(*, obj: UpdatePoliciesParam) -> bool:
"""
批量更新 P 策略
:param obj: 更新 P 策略参数
:return:
"""
enforcer = await casbin_enforcer()
data = await enforcer.update_policies(
[list(o.model_dump().values()) for o in obj.old],
[list(n.model_dump().values()) for n in obj.new],
)
return data
@staticmethod
async def delete_policy(*, p: DeletePolicyParam) -> bool:
"""
删除 P 策略
:param p: 删除参数
:return:
"""
enforcer = await casbin_enforcer()
_p = enforcer.has_policy(p.sub, p.path, p.method)
if not _p:
raise errors.NotFoundError(msg='权限不存在')
data = await enforcer.remove_policy(p.sub, p.path, p.method)
return data
@staticmethod
async def delete_policies(*, ps: list[DeletePolicyParam]) -> bool:
"""
批量删除 P 策略
:param ps: 删除参数列表
:return:
"""
enforcer = await casbin_enforcer()
data = await enforcer.remove_policies([list(p.model_dump().values()) for p in ps])
if not data:
raise errors.NotFoundError(msg='权限不存在')
return data
@staticmethod
async def delete_all_policies(*, sub: DeleteAllPoliciesParam) -> int:
"""
删除所有 P 策略
:param sub: 删除参数
:return:
"""
async with async_db_session.begin() as db:
count = await casbin_dao.delete_policies_by_sub(db, sub)
return count
@staticmethod
async def get_group_list() -> list:
"""获取 G 策略列表"""
enforcer = await casbin_enforcer()
data = enforcer.get_grouping_policy()
return data
@staticmethod
async def create_group(*, g: CreateGroupParam) -> bool:
"""
创建 G 策略
:param g: 创建 G 策略参数
:return:
"""
enforcer = await casbin_enforcer()
data = await enforcer.add_grouping_policy(g.uuid, g.role)
if not data:
raise errors.ForbiddenError(msg='权限已存在')
return data
@staticmethod
async def create_groups(*, gs: list[CreateGroupParam]) -> bool:
"""
批量创建 G 策略
:param gs: 创建参数列表
:return:
"""
enforcer = await casbin_enforcer()
data = await enforcer.add_grouping_policies([list(g.model_dump().values()) for g in gs])
if not data:
raise errors.ForbiddenError(msg='权限已存在')
return data
@staticmethod
async def delete_group(*, g: DeleteGroupParam) -> bool:
"""
删除 G 策略
:param g: 删除参数
:return:
"""
enforcer = await casbin_enforcer()
_g = enforcer.has_grouping_policy(g.uuid, g.role)
if not _g:
raise errors.NotFoundError(msg='权限不存在')
data = await enforcer.remove_grouping_policy(g.uuid, g.role)
return data
@staticmethod
async def delete_groups(*, gs: list[DeleteGroupParam]) -> bool:
"""
批量删除 G 策略
:param gs: 删除参数列表
:return: 是否成功
"""
enforcer = await casbin_enforcer()
data = await enforcer.remove_grouping_policies([list(g.model_dump().values()) for g in gs])
if not data:
raise errors.NotFoundError(msg='权限不存在')
return data
@staticmethod
async def delete_all_groups(*, uuid: UUID) -> int:
"""
删除所有 G 策略
:param uuid: 用户uuid
:return: 删除数量
"""
async with async_db_session.begin() as db:
count = await casbin_dao.delete_groups_by_uuid(db, uuid)
return count
casbin_service: CasbinService = CasbinService()
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import casbin
import casbin_async_sqlalchemy_adapter
from fastapi import Request
from backend.common.exception.errors import AuthorizationError
from backend.database.db import async_engine
from backend.plugin.casbin.conf import casbin_settings
from backend.plugin.casbin.model import CasbinRule
async def casbin_enforcer() -> casbin.AsyncEnforcer:
"""获取 casbin 执行器"""
# 模型定义:https://casbin.org/zh/docs/category/model
_CASBIN_RBAC_MODEL_CONF_TEXT = """
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && (keyMatch(r.obj, p.obj) || keyMatch3(r.obj, p.obj)) && (r.act == p.act || p.act == "*")
"""
adapter = casbin_async_sqlalchemy_adapter.Adapter(async_engine, db_class=CasbinRule)
model = casbin.AsyncEnforcer.new_model(text=_CASBIN_RBAC_MODEL_CONF_TEXT)
enforcer = casbin.AsyncEnforcer(model, adapter)
await enforcer.load_policy()
return enforcer
async def casbin_verify(request: Request) -> None:
"""
Casbin 权限校验
:param request: FastAPI 请求对象
:return:
"""
method = request.method
path = request.url.path
# casbin 鉴权白名单
if (method, path) in casbin_settings.RBAC_CASBIN_EXCLUDE:
return
# casbin 权限校验
user_uuid = request.user.uuid
enforcer = await casbin_enforcer()
if not enforcer.enforce(user_uuid, path, method):
raise AuthorizationError
+2 -2
View File
@@ -9,7 +9,7 @@ from backend.common.response.response_schema import ResponseModel, ResponseSchem
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.plugin.code_generator.conf import generator_settings from backend.core.conf import settings
from backend.plugin.code_generator.schema.gen import ImportParam from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service from backend.plugin.code_generator.service.gen_service import gen_service
@@ -69,5 +69,5 @@ async def download_code(pk: Annotated[int, Path(description='业务 ID')]):
return StreamingResponse( return StreamingResponse(
bio, bio,
media_type='application/x-zip-compressed', media_type='application/x-zip-compressed',
headers={'Content-Disposition': f'attachment; filename={generator_settings.DOWNLOAD_ZIP_FILENAME}.zip'}, headers={'Content-Disposition': f'attachment; filename={settings.CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME}.zip'},
) )
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
class GeneratorSettings(BaseSettings):
"""代码生成配置"""
# 代码下载
DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
@lru_cache
def get_generator_settings() -> GeneratorSettings:
"""获取代码生成配置"""
return GeneratorSettings()
generator_settings = get_generator_settings()
@@ -30,7 +30,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
:param name: 表名 :param name: 表名
:return: :return:
""" """
return await self.select_model_by_column(db, table_name_en=name) return await self.select_model_by_column(db, table_name=name)
async def get_all(self, db: AsyncSession) -> Sequence[GenBusiness]: async def get_all(self, db: AsyncSession) -> Sequence[GenBusiness]:
""" """
@@ -20,13 +20,13 @@ class GenBusiness(Base):
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
app_name: Mapped[str] = mapped_column(String(50), comment='应用名称(英文)') app_name: Mapped[str] = mapped_column(String(50), comment='应用名称(英文)')
table_name_en: Mapped[str] = mapped_column(String(255), unique=True, comment='表名称(英文)') table_name: Mapped[str] = mapped_column(String(255), unique=True, comment='表名称(英文)')
table_name_zh: Mapped[str] = mapped_column(String(255), comment='表名称(中文)') doc_comment: Mapped[str] = mapped_column(String(255), comment='档注释(用于函数/参数文档')
table_simple_name_zh: Mapped[str] = mapped_column(String(255), comment='表名称(中文简称)')
table_comment: Mapped[str | None] = mapped_column(String(255), default=None, comment='表描述') table_comment: Mapped[str | None] = mapped_column(String(255), default=None, comment='表描述')
# relate_model_fk: Mapped[int | None] = mapped_column(default=None, comment='关联表外键') # relate_model_fk: Mapped[int | None] = mapped_column(default=None, comment='关联表外键')
schema_name: Mapped[str | None] = mapped_column(String(255), default=None, comment='Schema 名称 (默认为英文表名称)') class_name: Mapped[str | None] = mapped_column(String(50), default=None, comment='基础类名(默认为英文表名称')
filename: Mapped[str | None] = mapped_column(String(20), default=None, comment='基础文件名(默认为英文表名称') schema_name: Mapped[str | None] = mapped_column(String(50), default=None, comment='Schema 名称 (默认为英文表名称)')
filename: Mapped[str | None] = mapped_column(String(50), default=None, comment='基础文件名(默认为英文表名称)')
default_datetime_column: Mapped[bool] = mapped_column(default=True, comment='是否存在默认时间列') default_datetime_column: Mapped[bool] = mapped_column(default=True, comment='是否存在默认时间列')
api_version: Mapped[str] = mapped_column(String(20), default='v1', comment='代码生成 api 版本,默认为 v1') api_version: Mapped[str] = mapped_column(String(20), default='v1', comment='代码生成 api 版本,默认为 v1')
gen_path: Mapped[str | None] = mapped_column(String(255), default=None, comment='代码生成路径(默认为 app 根路径)') gen_path: Mapped[str | None] = mapped_column(String(255), default=None, comment='代码生成路径(默认为 app 根路径)')
@@ -1,2 +1,8 @@
[plugin]
summary = '代码生成'
version = '0.0.1'
description = '生成通用业务代码'
author = 'wu-clan'
[app] [app]
router = ['v1'] router = ['v1']
@@ -11,10 +11,10 @@ class GenBusinessSchemaBase(SchemaBase):
"""代码生成业务基础模型""" """代码生成业务基础模型"""
app_name: str = Field(description='应用名称(英文)') app_name: str = Field(description='应用名称(英文)')
table_name_en: str = Field(description='表名称(英文)') table_name: str = Field(description='表名称(英文)')
table_name_zh: str = Field(description='表名称(中文)') doc_comment: str = Field(description='档注释(用于函数/参数文档')
table_simple_name_zh: str = Field(description='表名称(中文简称)')
table_comment: str | None = Field(None, description='表描述') table_comment: str | None = Field(None, description='表描述')
class_name: str | None = Field(None, description='基础类名 (默认为英文表名称)')
schema_name: str | None = Field(None, description='Schema 名称 (默认为英文表名称)') schema_name: str | None = Field(None, description='Schema 名称 (默认为英文表名称)')
filename: str | None = Field(None, description='基础文件名(默认为英文表名称)') filename: str | None = Field(None, description='基础文件名(默认为英文表名称)')
default_datetime_column: bool = Field(True, description='是否存在默认时间列') default_datetime_column: bool = Field(True, description='是否存在默认时间列')
@@ -41,7 +41,7 @@ class GenBusinessService:
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
business = await gen_business_dao.get_by_name(db, obj.table_name_en) business = await gen_business_dao.get_by_name(db, obj.table_name)
if business: if business:
raise errors.ForbiddenError(msg='代码生成业务已存在') raise errors.ForbiddenError(msg='代码生成业务已存在')
await gen_business_dao.create(db, obj) await gen_business_dao.create(db, obj)
@@ -61,10 +61,10 @@ class GenService:
new_business = GenBusiness( new_business = GenBusiness(
**CreateGenBusinessParam( **CreateGenBusinessParam(
app_name=obj.app, app_name=obj.app,
table_name_en=table_name, table_name=table_name,
table_name_zh=table_info[1] or ' '.join(table_name.split('_')), doc_comment=table_info[1] or table_name.split('_')[-1],
table_simple_name_zh=table_info[1] or table_name.split('_')[-1],
table_comment=table_info[1], table_comment=table_info[1],
class_name=table_name,
schema_name=table_name, schema_name=table_name,
filename=table_name, filename=table_name,
).model_dump() ).model_dump()
@@ -194,8 +194,8 @@ class GenService:
if code_folder.name == 'model': if code_folder.name == 'model':
async with aiofiles.open(init_filepath, 'a', encoding='utf-8') as f: async with aiofiles.open(init_filepath, 'a', encoding='utf-8') as f:
await f.write( await f.write(
f'from backend.app.{business.app_name}.model.{business.table_name_en} ' f'from backend.app.{business.app_name}.model.{business.table_name} '
f'import {to_pascal(business.table_name_en)}\n', f'import {to_pascal(business.table_name)}\n',
) )
# 写入代码文件 # 写入代码文件
@@ -229,8 +229,8 @@ class GenService:
zf.writestr( zf.writestr(
init_filepath, init_filepath,
f'{gen_template.init_content}' f'{gen_template.init_content}'
f'from backend.app.{business.app_name}.model.{business.table_name_en} ' f'from backend.app.{business.app_name}.model.{business.table_name} '
f'import {to_pascal(business.table_name_en)}\n', f'import {to_pascal(business.table_name)}\n',
) )
# api __init__.py # api __init__.py
@@ -4,8 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.{{ app_name }}.schema.{{ table_name_en }} import Create{{ schema_name }}Param, Get{{ schema_name }}Detail, Update{{ schema_name }}Param from backend.app.{{ app_name }}.schema.{{ table_name }} import Create{{ schema_name }}Param, Get{{ schema_name }}Detail, Update{{ schema_name }}Param
from backend.app.{{ app_name }}.service.{{ table_name_en }}_service import {{ table_name_en }}_service from backend.app.{{ app_name }}.service.{{ table_name }}_service import {{ table_name }}_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
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.common.security.jwt import DependsJwtAuth
@@ -16,49 +16,49 @@ from backend.database.db import CurrentSession
router = APIRouter() router = APIRouter()
@router.get('/{pk}', summary='获取{{ table_simple_name_zh }}详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取{{ doc_comment }}详情', dependencies=[DependsJwtAuth])
async def get_{{ table_name_en }}(pk: Annotated[int, Path(description='{{ table_simple_name_zh }} ID')]) -> ResponseSchemaModel[Get{{ schema_name }}Detail]: async def get_{{ table_name }}(pk: Annotated[int, Path(description='{{ doc_comment }} ID')]) -> ResponseSchemaModel[Get{{ schema_name }}Detail]:
{{ table_name_en }} = await {{ table_name_en }}_service.get(pk=pk) {{ table_name }} = await {{ table_name }}_service.get(pk=pk)
return response_base.success(data={{ table_name_en }}) return response_base.success(data={{ table_name }})
@router.get( @router.get(
'', '',
summary='分页获取所有{{ table_simple_name_zh }}', summary='分页获取所有{{ doc_comment }}',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_{{ table_name_en }}s(db: CurrentSession) -> ResponseSchemaModel[PageData[Get{{ schema_name }}Detail]]: async def get_pagination_{{ table_name }}s(db: CurrentSession) -> ResponseSchemaModel[PageData[Get{{ schema_name }}Detail]]:
{{ table_name_en }}_select = await {{ table_name_en }}_service.get_select() {{ table_name }}_select = await {{ table_name }}_service.get_select()
page_data = await paging_data(db, {{ table_name_en }}_select) page_data = await paging_data(db, {{ table_name }}_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@router.post( @router.post(
'', '',
summary='创建{{ table_simple_name_zh }}', summary='创建{{ doc_comment }}',
dependencies=[ dependencies=[
Depends(RequestPermission('{{ permission }}:add')), Depends(RequestPermission('{{ permission }}:add')),
DependsRBAC, DependsRBAC,
], ],
) )
async def create_{{ table_name_en }}(obj: Create{{ schema_name }}Param) -> ResponseModel: async def create_{{ table_name }}(obj: Create{{ schema_name }}Param) -> ResponseModel:
await {{ table_name_en }}_service.create(obj=obj) await {{ table_name }}_service.create(obj=obj)
return response_base.success() return response_base.success()
@router.put( @router.put(
'/{pk}', '/{pk}',
summary='更新{{ table_simple_name_zh }}', summary='更新{{ doc_comment }}',
dependencies=[ dependencies=[
Depends(RequestPermission('{{ permission }}:edit')), Depends(RequestPermission('{{ permission }}:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
async def update_{{ table_name_en }}(pk: Annotated[int, Path(description='{{ table_simple_name_zh }} ID')], obj: Update{{ schema_name }}Param) -> ResponseModel: async def update_{{ table_name }}(pk: Annotated[int, Path(description='{{ doc_comment }} ID')], obj: Update{{ schema_name }}Param) -> ResponseModel:
count = await {{ table_name_en }}_service.update(pk=pk, obj=obj) count = await {{ table_name }}_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -66,14 +66,14 @@ async def update_{{ table_name_en }}(pk: Annotated[int, Path(description='{{ tab
@router.delete( @router.delete(
'', '',
summary='批量删除{{ table_simple_name_zh }}', summary='批量删除{{ doc_comment }}',
dependencies=[ dependencies=[
Depends(RequestPermission('{{ permission }}:del')), Depends(RequestPermission('{{ permission }}:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_{{ table_name_en }}(pk: Annotated[list[int], Query(description='{{ table_simple_name_zh }} ID 列表')]) -> ResponseModel: async def delete_{{ table_name }}(pk: Annotated[list[int], Query(description='{{ doc_comment }} ID 列表')]) -> ResponseModel:
count = await {{ table_name_en }}_service.delete(pk=pk) count = await {{ table_name }}_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -6,28 +6,28 @@ from sqlalchemy import 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
from backend.app.{{ app_name }}.model import {{ table_name_class }} from backend.app.{{ app_name }}.model import {{ class_name }}
from backend.app.{{ app_name }}.schema.{{ table_name_en }} import Create{{ schema_name }}Param, Update{{ schema_name }}Param from backend.app.{{ app_name }}.schema.{{ table_name }} import Create{{ schema_name }}Param, Update{{ schema_name }}Param
class CRUD{{ table_name_class }}(CRUDPlus[{{ schema_name }}]): class CRUD{{ class_name }}(CRUDPlus[{{ schema_name }}]):
async def get(self, db: AsyncSession, pk: int) -> {{ table_name_class }} | None: async def get(self, db: AsyncSession, pk: int) -> {{ class_name }} | None:
""" """
获取{{ table_name_zh }} 获取{{ doc_comment }}
:param db: 数据库会话 :param db: 数据库会话
:param pk: {{ table_simple_name_zh }} ID :param pk: {{ doc_comment }} ID
:return: :return:
""" """
return await self.select_model(db, pk) return await self.select_model(db, pk)
async def get_list(self) -> Select: async def get_list(self) -> Select:
"""获取{{ table_name_zh }}列表""" """获取{{ doc_comment }}列表"""
return await self.select_order('created_time', 'desc') return await self.select_order('created_time', 'desc')
async def get_all(self, db: AsyncSession) -> Sequence[{{ table_name_class }}]: async def get_all(self, db: AsyncSession) -> Sequence[{{ class_name }}]:
""" """
获取所有{{ table_name_zh }} 获取所有{{ doc_comment }}
:param db: 数据库会话 :param db: 数据库会话
:return: :return:
@@ -36,34 +36,34 @@ class CRUD{{ table_name_class }}(CRUDPlus[{{ schema_name }}]):
async def create(self, db: AsyncSession, obj: Create{{ schema_name }}Param) -> None: async def create(self, db: AsyncSession, obj: Create{{ schema_name }}Param) -> None:
""" """
创建{{ table_name_zh }} 创建{{ doc_comment }}
:param db: 数据库会话 :param db: 数据库会话
:param obj: 创建{{ table_simple_name_zh }} 参数 :param obj: 创建{{ doc_comment }}参数
:return: :return:
""" """
await self.create_model(db, obj) await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: Update{{ schema_name }}Param) -> int: async def update(self, db: AsyncSession, pk: int, obj: Update{{ schema_name }}Param) -> int:
""" """
更新{{ table_name_zh }} 更新{{ doc_comment }}
:param db: 数据库会话 :param db: 数据库会话
:param pk: {{ table_simple_name_zh }} ID :param pk: {{ doc_comment }} ID
:param obj: 更新 {{ table_simple_name_zh }} 参数 :param obj: 更新 {{ doc_comment }}参数
:return: :return:
""" """
return await self.update_model(db, pk, obj) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pk: list[int]) -> int:
""" """
删除{{ table_name_zh }} 删除{{ doc_comment }}
:param db: 数据库会话 :param db: 数据库会话
:param pk: {{ table_simple_name_zh }} ID :param pk: {{ doc_comment }} ID
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
{{ table_name_en }}_dao: CRUD{{ table_name_class }} = CRUD{{ table_name_class }}({{ table_name_class }}) {{ instance_name }}_dao: CRUD{{ class_name }} = CRUD{{ class_name }}({{ class_name }})
@@ -15,10 +15,10 @@ from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import {% if default_datetime_column %}Base{% else %}MappedBase{% endif %}, id_key from backend.common.model import {% if default_datetime_column %}Base{% else %}MappedBase{% endif %}, id_key
class {{ table_name_class }}({% if default_datetime_column %}Base{% else %}MappedBase{% endif %}): class {{ class_name }}({% if default_datetime_column %}Base{% else %}MappedBase{% endif %}):
"""{{ table_name_zh }}""" """{{ table_comment }}"""
__tablename__ = '{{ table_name_en }}' __tablename__ = '{{ table_name }}'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
{% for model in models %} {% for model in models %}
@@ -8,7 +8,7 @@ from backend.common.schema import SchemaBase
class {{ schema_name }}SchemaBase(SchemaBase): class {{ schema_name }}SchemaBase(SchemaBase):
"""{{ table_simple_name_zh }}基础模型""" """{{ doc_comment }}基础模型"""
{% for model in models %} {% for model in models %}
{{ model.name }}: {% if model.nullable %}{{ model.pd_type }} | None = Field(None, description='{{ model.comment }}'){% else %}{{ model.pd_type }} = Field(description='{{ model.comment }}'){% endif %} {{ model.name }}: {% if model.nullable %}{{ model.pd_type }} | None = Field(None, description='{{ model.comment }}'){% else %}{{ model.pd_type }} = Field(description='{{ model.comment }}'){% endif %}
@@ -16,15 +16,15 @@ class {{ schema_name }}SchemaBase(SchemaBase):
class Create{{ schema_name }}Param({{ schema_name }}SchemaBase): class Create{{ schema_name }}Param({{ schema_name }}SchemaBase):
"""创建{{ table_simple_name_zh }}参数""" """创建{{ doc_comment }}参数"""
class Update{{ schema_name }}Param({{ schema_name }}SchemaBase): class Update{{ schema_name }}Param({{ schema_name }}SchemaBase):
"""更新{{ table_simple_name_zh }}参数""" """更新{{ doc_comment }}参数"""
class Get{{ schema_name }}Detail({{ schema_name }}SchemaBase): class Get{{ schema_name }}Detail({{ schema_name }}SchemaBase):
"""{{ table_simple_name_zh }}详情""" """{{ doc_comment }}详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -4,75 +4,75 @@ from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from backend.app.{{ app_name }}.crud.crud_{{ table_name_en }} import {{ table_name_en }}_dao from backend.app.{{ app_name }}.crud.crud_{{ table_name }} import {{ table_name }}_dao
from backend.app.{{ app_name }}.model import {{ table_name_class }} from backend.app.{{ app_name }}.model import {{ class_name }}
from backend.app.{{ app_name }}.schema.{{ table_name_en }} import Create{{ schema_name }}Param, Update{{ schema_name }}Param from backend.app.{{ app_name }}.schema.{{ table_name }} import Create{{ schema_name }}Param, Update{{ schema_name }}Param
from backend.common.exception import errors from backend.common.exception import errors
from backend.database.db import async_db_session from backend.database.db import async_db_session
class {{ table_name_class }}Service: class {{ class_name }}Service:
@staticmethod @staticmethod
async def get(*, pk: int) -> {{ table_name_class }}: async def get(*, pk: int) -> {{ class_name }}:
""" """
获取{{ table_simple_name_zh }} 获取{{ doc_comment }}
:param pk: {{ table_simple_name_zh }} ID :param pk: {{ doc_comment }} ID
:return: :return:
""" """
async with async_db_session() as db: async with async_db_session() as db:
{{ table_name_en }} = await {{ table_name_en }}_dao.get(db, pk) {{ table_name }} = await {{ table_name }}_dao.get(db, pk)
if not {{ table_name_en }}: if not {{ table_name }}:
raise errors.NotFoundError(msg='{{ table_simple_name_zh }}不存在') raise errors.NotFoundError(msg='{{ doc_comment }}不存在')
return {{ table_name_en }} return {{ table_name }}
@staticmethod @staticmethod
async def get_select() -> Select: async def get_select() -> Select:
"""获取{{ table_simple_name_zh }}查询对象""" """获取{{ doc_comment }}查询对象"""
return await {{ table_name_en }}_dao.get_list() return await {{ table_name }}_dao.get_list()
@staticmethod @staticmethod
async def get_all() -> Sequence[{{ table_name_class }}]: async def get_all() -> Sequence[{{ class_name }}]:
"""获取所有{{ table_simple_name_zh }}""" """获取所有{{ doc_comment }}"""
async with async_db_session() as db: async with async_db_session() as db:
{{ table_name_en }}s = await {{ table_name_en }}_dao.get_all(db) {{ table_name }}s = await {{ table_name }}_dao.get_all(db)
return {{ table_name_en }}s return {{ table_name }}s
@staticmethod @staticmethod
async def create(*, obj: Create{{ schema_name }}Param) -> None: async def create(*, obj: Create{{ schema_name }}Param) -> None:
""" """
创建{{ table_simple_name_zh }} 创建{{ doc_comment }}
:param obj: 创建{{ table_simple_name_zh }}参数 :param obj: 创建{{ doc_comment }}参数
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
await {{ table_name_en }}_dao.create(db, obj) await {{ table_name }}_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: Update{{ schema_name }}Param) -> int: async def update(*, pk: int, obj: Update{{ schema_name }}Param) -> int:
""" """
更新{{ table_simple_name_zh }} 更新{{ doc_comment }}
:param pk: {{ table_simple_name_zh }} ID :param pk: {{ doc_comment }} ID
:param obj: 更新{{ table_simple_name_zh }}参数 :param obj: 更新{{ doc_comment }}参数
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await {{ table_name_en }}_dao.update(db, pk, obj) count = await {{ table_name }}_dao.update(db, pk, obj)
return count return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
""" """
删除{{ table_simple_name_zh }} 删除{{ doc_comment }}
:param pk: {{ table_simple_name_zh }} ID 列表 :param pk: {{ doc_comment }} ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await {{ table_name_en }}_dao.delete(db, pk) count = await {{ table_name }}_dao.delete(db, pk)
return count return count
{{ table_name_en }}_service: {{ table_name_class }}Service = {{ table_name_class }}Service() {{ instance_name }}_service: {{ class_name }}Service = {{ class_name }}Service()
@@ -88,14 +88,14 @@ class GenTemplate:
""" """
return { return {
'app_name': business.app_name, 'app_name': business.app_name,
'table_name_en': to_snake(business.table_name_en), 'table_name': to_snake(business.table_name),
'table_name_class': to_pascal(business.table_name_en), 'doc_comment': business.doc_comment,
'table_name_zh': business.table_name_zh,
'table_simple_name_zh': business.table_simple_name_zh,
'table_comment': business.table_comment, 'table_comment': business.table_comment,
'class_name': to_pascal(business.class_name),
'instance_name': to_snake(business.class_name),
'schema_name': to_pascal(business.schema_name), 'schema_name': to_pascal(business.schema_name),
'default_datetime_column': business.default_datetime_column, 'default_datetime_column': business.default_datetime_column,
'permission': str(business.table_name_en.replace('_', ':')), 'permission': str(business.table_name.replace('_', ':')),
'database_type': settings.DATABASE_TYPE, 'database_type': settings.DATABASE_TYPE,
'models': models, 'models': models,
} }
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
class ConfigSettings(BaseSettings):
"""参数配置"""
# 参数
CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy']
@lru_cache
def get_config_settings() -> ConfigSettings:
"""获取参数配置"""
return ConfigSettings()
config_settings = get_config_settings()
+4 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy import 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
from backend.plugin.config.conf import config_settings from backend.core.conf import settings
from backend.plugin.config.model import Config from backend.plugin.config.model import Config
from backend.plugin.config.schema.config import CreateConfigParam, UpdateConfigParam from backend.plugin.config.schema.config import CreateConfigParam, UpdateConfigParam
@@ -22,7 +22,7 @@ class CRUDConfig(CRUDPlus[Config]):
:param pk: 参数配置 ID :param pk: 参数配置 ID
:return: :return:
""" """
return await self.select_model_by_column(db, id=pk, type__not_in=config_settings.CONFIG_BUILT_IN_TYPES) return await self.select_model_by_column(db, id=pk, type__not_in=settings.CONFIG_BUILT_IN_TYPES)
async def get_by_type(self, db: AsyncSession, type: str) -> Sequence[Config]: async def get_by_type(self, db: AsyncSession, type: str) -> Sequence[Config]:
""" """
@@ -63,7 +63,7 @@ class CRUDConfig(CRUDPlus[Config]):
:param type: 参数配置类型 :param type: 参数配置类型
:return: :return:
""" """
filters = {'type__not_in': config_settings.CONFIG_BUILT_IN_TYPES} filters = {'type__not_in': settings.CONFIG_BUILT_IN_TYPES}
if name is not None: if name is not None:
filters.update(name__like=f'%{name}%') filters.update(name__like=f'%{name}%')
if type is not None: if type is not None:
@@ -100,7 +100,7 @@ class CRUDConfig(CRUDPlus[Config]):
:return: :return:
""" """
return await self.delete_model_by_column( return await self.delete_model_by_column(
db, allow_multiple=True, id__in=pk, type__not_in=config_settings.CONFIG_BUILT_IN_TYPES db, allow_multiple=True, id__in=pk, type__not_in=settings.CONFIG_BUILT_IN_TYPES
) )
+6
View File
@@ -1,3 +1,9 @@
[plugin]
summary = '参数配置'
version = '0.0.1'
description = '通常用于前端工程数据展示'
author = 'wu-clan'
[app] [app]
include = 'admin' include = 'admin'
@@ -5,8 +5,8 @@ from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
from backend.plugin.config.conf import config_settings
from backend.plugin.config.crud.crud_config import config_dao from backend.plugin.config.crud.crud_config import config_dao
from backend.plugin.config.model import Config from backend.plugin.config.model import Config
from backend.plugin.config.schema.config import ( from backend.plugin.config.schema.config import (
@@ -83,7 +83,7 @@ class ConfigService:
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if obj.type in config_settings.CONFIG_BUILT_IN_TYPES: if obj.type in settings.CONFIG_BUILT_IN_TYPES:
raise errors.ForbiddenError(msg='非法类型参数') raise errors.ForbiddenError(msg='非法类型参数')
config = await config_dao.get_by_key(db, obj.key) config = await config_dao.get_by_key(db, obj.key)
if config: if config:
+6
View File
@@ -1,3 +1,9 @@
[plugin]
summary = '数据字典'
version = '0.0.1'
description = '通常用于约束前端工程数据展示'
author = 'wu-clan'
[app] [app]
include = 'admin' include = 'admin'
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
class PluginConfigError(Exception):
"""插件信息错误"""
class PluginInjectError(Exception):
"""插件注入错误"""
+4 -2
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query, Request
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -17,7 +17,9 @@ router = APIRouter()
@router.get('/{pk}', summary='获取通知公告详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取通知公告详情', dependencies=[DependsJwtAuth])
async def get_notice(pk: Annotated[int, Path(description='通知公告 ID')]) -> ResponseSchemaModel[GetNoticeDetail]: async def get_notice(
request: Request, pk: Annotated[int, Path(description='通知公告 ID')]
) -> ResponseSchemaModel[GetNoticeDetail]:
notice = await notice_service.get(pk=pk) notice = await notice_service.get(pk=pk)
return response_base.success(data=notice) return response_base.success(data=notice)
+6
View File
@@ -1,3 +1,9 @@
[plugin]
summary = '通知公告'
version = '0.0.1'
description = '发布系统内部通知、公告'
author = 'wu-clan'
[app] [app]
include = 'admin' include = 'admin'
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.core.conf import settings
from backend.plugin.oauth2.api.v1.github import router as github_router
from backend.plugin.oauth2.api.v1.linux_do import router as linux_do_router
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/oauth2')
v1.include_router(github_router, prefix='/github', tags=['Github OAuth2'])
v1.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2'])

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