mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfe35ea85 | ||
|
|
4a118c166a | ||
|
|
5f3823cb6d | ||
|
|
19ef52da9b | ||
|
|
39c3339a8d | ||
|
|
9f52629dcb | ||
|
|
1cddd6e2d5 | ||
|
|
ed10e6ff47 | ||
|
|
48574fb50a | ||
|
|
ee7fe3087d | ||
|
|
270ac3d941 | ||
|
|
2f76988076 | ||
|
|
b275481742 | ||
|
|
f3f1ceb595 | ||
|
|
bcc764142b | ||
|
|
9b0d9f8296 | ||
|
|
63ba7646c1 | ||
|
|
1a7734185f | ||
|
|
8541059d23 | ||
|
|
61edc81333 | ||
|
|
8998687adb | ||
|
|
85b0294da9 | ||
|
|
a8ecb4ae3b | ||
|
|
a87ff74b56 | ||
|
|
3c5682988d | ||
|
|
1d32154498 | ||
|
|
f4290ddb8c | ||
|
|
1e54b0ba42 | ||
|
|
27b9cf4c64 | ||
|
|
87f29e52a1 | ||
|
|
16a0f9646d | ||
|
|
032364e48e | ||
|
|
bc5d142920 | ||
|
|
9b3425d801 | ||
|
|
5842127c2b | ||
|
|
193eba3c58 | ||
|
|
338e50e255 | ||
|
|
65500d7a53 | ||
|
|
0833cd5443 | ||
|
|
e8c844ead7 | ||
|
|
1d5f2dc5f0 | ||
|
|
9a36154564 | ||
|
|
5f8ae74ea5 | ||
|
|
da8e7c1b01 | ||
|
|
f513b98f15 |
@@ -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
|
||||||
|
|||||||
+129
@@ -1,3 +1,125 @@
|
|||||||
|
<a id="v1.4.1"></a>
|
||||||
|
# [v1.4.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.1) - 2025-05-25
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Update changelog for v1.4.0 by [@wu-clan](https://github.com/wu-clan) in [#621](https://github.com/fastapi-practices/fastapi_best_architecture/pull/621)
|
||||||
|
* Update the menu path and type columns by [@wu-clan](https://github.com/wu-clan) in [#622](https://github.com/fastapi-practices/fastapi_best_architecture/pull/622)
|
||||||
|
* Add the deepwiki badge to README by [@wu-clan](https://github.com/wu-clan) in [#623](https://github.com/fastapi-practices/fastapi_best_architecture/pull/623)
|
||||||
|
* Refactor the system token to online users by [@wu-clan](https://github.com/wu-clan) in [#624](https://github.com/fastapi-practices/fastapi_best_architecture/pull/624)
|
||||||
|
* Update the token check for logout interface by [@wu-clan](https://github.com/wu-clan) in [#625](https://github.com/fastapi-practices/fastapi_best_architecture/pull/625)
|
||||||
|
* Update the token decode for logout interface by [@wu-clan](https://github.com/wu-clan) in [#629](https://github.com/fastapi-practices/fastapi_best_architecture/pull/629)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
|
||||||
|
|
||||||
|
[Changes][v1.4.1]
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
# [v1.1.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.1) - 2025-04-18
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Update changelog for v1.1.0 by [@wu-clan](https://github.com/wu-clan) in [#580](https://github.com/fastapi-practices/fastapi_best_architecture/pull/580)
|
||||||
|
* Fix the plugin system route injection by [@wu-clan](https://github.com/wu-clan) in [#581](https://github.com/fastapi-practices/fastapi_best_architecture/pull/581)
|
||||||
|
* Fix list query in the dict plugin by [@wu-clan](https://github.com/wu-clan) in [#582](https://github.com/fastapi-practices/fastapi_best_architecture/pull/582)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
|
||||||
|
|
||||||
|
[Changes][v1.1.1]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.0"></a>
|
||||||
|
# [v1.1.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.0) - 2025-04-17
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Update the changelog for v1.0.5 by [@wu-clan](https://github.com/wu-clan) in [#572](https://github.com/fastapi-practices/fastapi_best_architecture/pull/572)
|
||||||
|
* Update the default value for some functions by [@wu-clan](https://github.com/wu-clan) in [#573](https://github.com/fastapi-practices/fastapi_best_architecture/pull/573)
|
||||||
|
* Optimize the file structure of code generator by [@wu-clan](https://github.com/wu-clan) in [#574](https://github.com/fastapi-practices/fastapi_best_architecture/pull/574)
|
||||||
|
* Update casbin RBAC verify to dynamic import by [@wu-clan](https://github.com/wu-clan) in [#576](https://github.com/fastapi-practices/fastapi_best_architecture/pull/576)
|
||||||
|
* Update unique columns in dict models by [@wu-clan](https://github.com/wu-clan) in [#577](https://github.com/fastapi-practices/fastapi_best_architecture/pull/577)
|
||||||
|
* Update the code generator to plugin by [@wu-clan](https://github.com/wu-clan) in [#578](https://github.com/fastapi-practices/fastapi_best_architecture/pull/578)
|
||||||
|
* Fix avatar url type of update avatar by [@huyuwei1996](https://github.com/huyuwei1996) in [#575](https://github.com/fastapi-practices/fastapi_best_architecture/pull/575)
|
||||||
|
* Update code generator file and table naming by [@wu-clan](https://github.com/wu-clan) in [#579](https://github.com/fastapi-practices/fastapi_best_architecture/pull/579)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0
|
||||||
|
|
||||||
|
[Changes][v1.1.0]
|
||||||
|
|
||||||
|
|
||||||
<a id="v1.0.5"></a>
|
<a id="v1.0.5"></a>
|
||||||
# [v1.0.5](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.5) - 2025-04-09
|
# [v1.0.5](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.5) - 2025-04-09
|
||||||
|
|
||||||
@@ -427,6 +549,13 @@
|
|||||||
[Changes][v1.0.0]
|
[Changes][v1.0.0]
|
||||||
|
|
||||||
|
|
||||||
|
[v1.4.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
|
||||||
|
[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.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
|
||||||
[v1.0.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
|
[v1.0.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
|
||||||
[v1.0.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
|
[v1.0.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
|
||||||
|
|||||||
+1
-2
@@ -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 \
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
@@ -21,12 +19,10 @@ Enterprise-level backend architecture solution
|
|||||||

|

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

|

|
||||||
|
[](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
@@ -4,11 +4,9 @@
|
|||||||
|
|
||||||
# FastAPI Best Architecture
|
# FastAPI Best Architecture
|
||||||
|
|
||||||
简体中文 | [English](./README.md)
|
|
||||||
|
|
||||||
企业级后端架构解决方案
|
企业级后端架构解决方案
|
||||||
|
|
||||||
**🔥持续更新维护中🔥**
|
简体中文 | [English](./README.md)
|
||||||
|
|
||||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
@@ -21,12 +19,10 @@
|
|||||||

|

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

|

|
||||||
|
[](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/)
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'])
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
|
|||||||
router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
|
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=['系统插件'])
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
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
|
||||||
@@ -16,16 +21,16 @@ from backend.database.db import CurrentSession
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get('/models', summary='获取支持过滤的数据库模型', dependencies=[DependsJwtAuth])
|
@router.get('/models', summary='获取数据规则可用模型', dependencies=[DependsJwtAuth])
|
||||||
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
|
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
|
||||||
models = await data_rule_service.get_models()
|
models = await data_rule_service.get_models()
|
||||||
return response_base.success(data=models)
|
return response_base.success(data=models)
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|
||||||
@@ -36,7 +41,7 @@ async def get_all_data_rules() -> ResponseSchemaModel[list[GetDataRuleDetail]]:
|
|||||||
return response_base.success(data=data)
|
return response_base.success(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get('/{pk}', summary='获取数据权限规则详情', dependencies=[DependsJwtAuth])
|
@router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
|
||||||
async def get_data_rule(
|
async def get_data_rule(
|
||||||
pk: Annotated[int, Path(description='数据规则 ID')],
|
pk: Annotated[int, Path(description='数据规则 ID')],
|
||||||
) -> ResponseSchemaModel[GetDataRuleDetail]:
|
) -> ResponseSchemaModel[GetDataRuleDetail]:
|
||||||
@@ -46,7 +51,7 @@ async def get_data_rule(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
'',
|
'',
|
||||||
summary='分页获取所有数据权限规则',
|
summary='分页获取所有数据规则',
|
||||||
dependencies=[
|
dependencies=[
|
||||||
DependsJwtAuth,
|
DependsJwtAuth,
|
||||||
DependsPagination,
|
DependsPagination,
|
||||||
@@ -62,7 +67,7 @@ async def get_pagination_data_rules(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
'',
|
'',
|
||||||
summary='创建数据权限规则',
|
summary='创建数据规则',
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Depends(RequestPermission('data:rule:add')),
|
Depends(RequestPermission('data:rule:add')),
|
||||||
DependsRBAC,
|
DependsRBAC,
|
||||||
@@ -75,7 +80,7 @@ async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel:
|
|||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
'/{pk}',
|
'/{pk}',
|
||||||
summary='更新数据权限规则',
|
summary='更新数据规则',
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Depends(RequestPermission('data:rule:edit')),
|
Depends(RequestPermission('data:rule:edit')),
|
||||||
DependsRBAC,
|
DependsRBAC,
|
||||||
@@ -92,16 +97,14 @@ async def update_data_rule(
|
|||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
'',
|
'',
|
||||||
summary='批量删除数据权限规则',
|
summary='批量删除数据规则',
|
||||||
dependencies=[
|
dependencies=[
|
||||||
Depends(RequestPermission('data:rule:del')),
|
Depends(RequestPermission('data:rule:del')),
|
||||||
DependsRBAC,
|
DependsRBAC,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def delete_data_rule(
|
async def delete_data_rule(pk: Annotated[list[int], Query(description='数据规则 ID 列表')]) -> ResponseModel:
|
||||||
request: Request, pk: Annotated[list[int], Query(description='数据规则 ID 列表')]
|
count = await data_rule_service.delete(pk=pk)
|
||||||
) -> ResponseModel:
|
|
||||||
count = await data_rule_service.delete(request=request, pk=pk)
|
|
||||||
if count > 0:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,8 +68,8 @@ async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: Up
|
|||||||
DependsRBAC,
|
DependsRBAC,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def delete_dept(request: Request, pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
|
async def delete_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
|
||||||
count = await dept_service.delete(request=request, pk=pk)
|
count = await dept_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()
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben5', dependencies=[DependsJwtAuth])
|
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben5', dependencies=[DependsJwtAuth])
|
||||||
async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any]]]:
|
async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
|
||||||
menu = await menu_service.get_user_menu_tree(request=request)
|
menu = await menu_service.get_sidebar(request=request)
|
||||||
return response_base.success(data=menu)
|
return response_base.success(data=menu)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: Up
|
|||||||
DependsRBAC,
|
DependsRBAC,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def delete_menu(request: Request, pk: Annotated[int, Path(description='菜单 ID 列表')]) -> ResponseModel:
|
async def delete_menu(pk: Annotated[int, Path(description='菜单 ID 列表')]) -> ResponseModel:
|
||||||
count = await menu_service.delete(request=request, pk=pk)
|
count = await menu_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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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, Request
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
from backend.app.admin.schema.role import (
|
from backend.app.admin.schema.role import (
|
||||||
CreateRoleParam,
|
CreateRoleParam,
|
||||||
@@ -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,21 +33,21 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth])
|
@router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth])
|
||||||
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] | None]]:
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,26 +114,26 @@ async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: Up
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def update_role_menus(
|
async def update_role_menus(
|
||||||
request: Request, pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam
|
pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam
|
||||||
) -> ResponseModel:
|
) -> ResponseModel:
|
||||||
count = await role_service.update_role_menu(request=request, pk=pk, menu_ids=menu_ids)
|
count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids)
|
||||||
if count > 0:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
request: Request, 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(request=request, 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()
|
||||||
@@ -149,8 +147,8 @@ async def update_role_rules(
|
|||||||
DependsRBAC,
|
DependsRBAC,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def delete_role(request: Request, pk: Annotated[list[int], Query(description='角色 ID 列表')]) -> ResponseModel:
|
async def delete_role(pk: Annotated[list[int], Query(description='角色 ID 列表')]) -> ResponseModel:
|
||||||
count = await role_service.delete(request=request, pk=pk)
|
count = await role_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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ from fastapi import APIRouter, Depends, Path, Query, Request
|
|||||||
|
|
||||||
from backend.app.admin.schema.user import (
|
from backend.app.admin.schema.user import (
|
||||||
AddUserParam,
|
AddUserParam,
|
||||||
AvatarParam,
|
|
||||||
GetCurrentUserInfoWithRelationDetail,
|
GetCurrentUserInfoWithRelationDetail,
|
||||||
GetUserInfoWithRelationDetail,
|
GetUserInfoWithRelationDetail,
|
||||||
RegisterUserParam,
|
RegisterUserParam,
|
||||||
ResetPasswordParam,
|
ResetPasswordParam,
|
||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
UpdateUserRoleParam,
|
|
||||||
)
|
)
|
||||||
from backend.app.admin.service.user_service import user_service
|
from backend.app.admin.service.user_service import user_service
|
||||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||||
@@ -70,31 +68,6 @@ async def update_user(
|
|||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
'/{username}/role',
|
|
||||||
summary='更新用户角色',
|
|
||||||
dependencies=[
|
|
||||||
Depends(RequestPermission('sys:user:role:edit')),
|
|
||||||
DependsRBAC,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def update_user_role(
|
|
||||||
request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserRoleParam
|
|
||||||
) -> ResponseModel:
|
|
||||||
await user_service.update_roles(request=request, username=username, obj=obj)
|
|
||||||
return response_base.success()
|
|
||||||
|
|
||||||
|
|
||||||
@router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth])
|
|
||||||
async def update_avatar(
|
|
||||||
request: Request, username: Annotated[str, Path(description='用户名')], avatar: AvatarParam
|
|
||||||
) -> ResponseModel:
|
|
||||||
count = await user_service.update_avatar(request=request, username=username, avatar=avatar)
|
|
||||||
if count > 0:
|
|
||||||
return response_base.success()
|
|
||||||
return response_base.fail()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
'',
|
'',
|
||||||
summary='分页获取所有用户',
|
summary='分页获取所有用户',
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -12,7 +12,7 @@ from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRu
|
|||||||
|
|
||||||
|
|
||||||
class CRUDDataRule(CRUDPlus[DataRule]):
|
class CRUDDataRule(CRUDPlus[DataRule]):
|
||||||
"""数据权限规则数据库操作类"""
|
"""数据规则数据库操作类"""
|
||||||
|
|
||||||
async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
|
async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
|
||||||
"""
|
"""
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
|||||||
:param title: 菜单标题
|
:param title: 菜单标题
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
return await self.select_model_by_column(db, title=title, menu_type__ne=2)
|
return await self.select_model_by_column(db, title=title, type__ne=2)
|
||||||
|
|
||||||
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
|
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
|
||||||
"""
|
"""
|
||||||
@@ -45,12 +45,12 @@ class CRUDMenu(CRUDPlus[Menu]):
|
|||||||
"""
|
"""
|
||||||
filters = {}
|
filters = {}
|
||||||
if title is not None:
|
if title is not None:
|
||||||
filters.update(title=f'%{title}%')
|
filters.update(title_like=f'%{title}%')
|
||||||
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', **filters)
|
return await self.select_models_order(db, 'sort', **filters)
|
||||||
|
|
||||||
async def get_role_menus(self, db: AsyncSession, superuser: bool, menu_ids: list[int]) -> Sequence[Menu]:
|
async def get_sidebar(self, db: AsyncSession, superuser: bool, menu_ids: list[int | None]) -> Sequence[Menu]:
|
||||||
"""
|
"""
|
||||||
获取角色菜单列表
|
获取角色菜单列表
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ from sqlalchemy_crud_plus import CRUDPlus
|
|||||||
from backend.app.admin.model import Dept, Role, User
|
from backend.app.admin.model import Dept, Role, User
|
||||||
from backend.app.admin.schema.user import (
|
from backend.app.admin.schema.user import (
|
||||||
AddUserParam,
|
AddUserParam,
|
||||||
AvatarParam,
|
|
||||||
RegisterUserParam,
|
RegisterUserParam,
|
||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
UpdateUserRoleParam,
|
|
||||||
)
|
)
|
||||||
from backend.common.security.jwt import get_hash_password
|
from backend.common.security.jwt import get_hash_password
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
@@ -97,14 +95,13 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
dict_obj.update({'salt': salt})
|
dict_obj.update({'salt': salt})
|
||||||
new_user = self.model(**dict_obj)
|
new_user = self.model(**dict_obj)
|
||||||
|
|
||||||
role_list = []
|
stmt = select(Role).where(Role.id.in_(obj.roles))
|
||||||
for role_id in obj.roles:
|
roles = await db.execute(stmt)
|
||||||
role_list.append(await db.get(Role, role_id))
|
new_user.roles = roles.scalars().all()
|
||||||
new_user.roles.extend(role_list)
|
|
||||||
|
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
|
|
||||||
async def update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int:
|
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
|
||||||
"""
|
"""
|
||||||
更新用户信息
|
更新用户信息
|
||||||
|
|
||||||
@@ -113,36 +110,14 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
:param obj: 更新用户参数
|
:param obj: 更新用户参数
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
return await self.update_model(db, input_user, obj)
|
role_ids = obj.roles
|
||||||
|
del obj.roles
|
||||||
|
count = await self.update_model(db, input_user.id, obj)
|
||||||
|
|
||||||
@staticmethod
|
stmt = select(Role).where(Role.id.in_(role_ids))
|
||||||
async def update_role(db: AsyncSession, input_user: User, obj: UpdateUserRoleParam) -> None:
|
roles = await db.execute(stmt)
|
||||||
"""
|
input_user.roles = roles.scalars().all()
|
||||||
更新用户角色
|
return count
|
||||||
|
|
||||||
:param db: 数据库会话
|
|
||||||
:param input_user: 用户对象
|
|
||||||
:param obj: 更新角色参数
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
for i in list(input_user.roles):
|
|
||||||
input_user.roles.remove(i)
|
|
||||||
|
|
||||||
role_list = []
|
|
||||||
for role_id in obj.roles:
|
|
||||||
role_list.append(await db.get(Role, role_id))
|
|
||||||
input_user.roles.extend(role_list)
|
|
||||||
|
|
||||||
async def update_avatar(self, db: AsyncSession, input_user: int, avatar: AvatarParam) -> int:
|
|
||||||
"""
|
|
||||||
更新用户头像
|
|
||||||
|
|
||||||
:param db: 数据库会话
|
|
||||||
:param input_user: 用户 ID
|
|
||||||
:param avatar: 头像地址
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
return await self.update_model(db, input_user, {'avatar': str(avatar.url)})
|
|
||||||
|
|
||||||
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -189,8 +164,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 +285,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,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
|
|
||||||
|
|||||||
@@ -4,30 +4,32 @@ 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):
|
||||||
"""数据权限规则表"""
|
"""数据规则表"""
|
||||||
|
|
||||||
__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='运算符(0:and、1:or)')
|
operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)')
|
||||||
expression: Mapped[int] = mapped_column(
|
expression: Mapped[int] = mapped_column(
|
||||||
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)'
|
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_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')
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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正常)')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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='字段评论')
|
||||||
|
|||||||
@@ -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='数据规则列表')
|
||||||
@@ -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='状态')
|
||||||
|
|||||||
@@ -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='数据范围列表')
|
||||||
|
|||||||
@@ -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):
|
||||||
"""令牌详情"""
|
"""令牌详情"""
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class UserInfoSchemaBase(SchemaBase):
|
|||||||
dept_id: int | None = Field(None, description='部门 ID')
|
dept_id: int | None = Field(None, description='部门 ID')
|
||||||
username: str = Field(description='用户名')
|
username: str = Field(description='用户名')
|
||||||
nickname: str = Field(description='昵称')
|
nickname: str = Field(description='昵称')
|
||||||
|
avatar: HttpUrl | None = Field(None, description='头像')
|
||||||
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
|
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
|
||||||
phone: CustomPhoneNumber | None = Field(None, description='手机号')
|
phone: CustomPhoneNumber | None = Field(None, description='手机号')
|
||||||
|
|
||||||
@@ -62,19 +63,9 @@ class UserInfoSchemaBase(SchemaBase):
|
|||||||
class UpdateUserParam(UserInfoSchemaBase):
|
class UpdateUserParam(UserInfoSchemaBase):
|
||||||
"""更新用户参数"""
|
"""更新用户参数"""
|
||||||
|
|
||||||
|
|
||||||
class UpdateUserRoleParam(SchemaBase):
|
|
||||||
"""更新用户角色参数"""
|
|
||||||
|
|
||||||
roles: list[int] = Field(description='角色 ID 列表')
|
roles: list[int] = Field(description='角色 ID 列表')
|
||||||
|
|
||||||
|
|
||||||
class AvatarParam(SchemaBase):
|
|
||||||
"""更新头像参数"""
|
|
||||||
|
|
||||||
url: HttpUrl = Field(description='头像 http 地址')
|
|
||||||
|
|
||||||
|
|
||||||
class GetUserInfoDetail(UserInfoSchemaBase):
|
class GetUserInfoDetail(UserInfoSchemaBase):
|
||||||
"""用户信息详情"""
|
"""用户信息详情"""
|
||||||
|
|
||||||
|
|||||||
@@ -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.ForbiddenError(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(
|
||||||
@@ -122,7 +121,7 @@ class AuthService:
|
|||||||
except errors.NotFoundError as e:
|
except errors.NotFoundError as e:
|
||||||
log.error('登陆错误: 用户名不存在')
|
log.error('登陆错误: 用户名不存在')
|
||||||
raise errors.NotFoundError(msg=e.msg)
|
raise errors.NotFoundError(msg=e.msg)
|
||||||
except (errors.AuthorizationError, errors.CustomError) as e:
|
except (errors.ForbiddenError, errors.CustomError) as e:
|
||||||
if not user:
|
if not user:
|
||||||
log.error('登陆错误: 用户密码有误')
|
log.error('登陆错误: 用户密码有误')
|
||||||
task = BackgroundTask(
|
task = BackgroundTask(
|
||||||
@@ -137,7 +136,7 @@ class AuthService:
|
|||||||
msg=e.msg,
|
msg=e.msg,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
raise errors.AuthorizationError(msg=e.msg, background=task)
|
raise errors.RequestError(msg=e.msg, background=task)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f'登陆错误: {e}')
|
log.error(f'登陆错误: {e}')
|
||||||
raise e
|
raise e
|
||||||
@@ -212,11 +211,17 @@ class AuthService:
|
|||||||
:param response: FastAPI 响应对象
|
:param response: FastAPI 响应对象
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
token = get_token(request)
|
token = get_token(request)
|
||||||
token_payload = jwt_decode(token)
|
token_payload = jwt_decode(token)
|
||||||
user_id = token_payload.id
|
user_id = token_payload.id
|
||||||
refresh_token = request.cookies.get(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)
|
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:
|
||||||
|
|||||||
@@ -2,22 +2,19 @@
|
|||||||
# -*- 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 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
|
||||||
|
|
||||||
|
|
||||||
class DataRuleService:
|
class DataRuleService:
|
||||||
"""数据权限规则服务类"""
|
"""数据规则服务类"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get(*, pk: int) -> DataRule:
|
async def get(*, pk: int) -> DataRule:
|
||||||
@@ -33,39 +30,27 @@ 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]:
|
||||||
"""
|
"""
|
||||||
获取数据模型的字段列表
|
获取数据规则可用模型的字段列表
|
||||||
|
|
||||||
:param model: 模型名称
|
:param model: 模型名称
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
@@ -97,7 +82,7 @@ class DataRuleService:
|
|||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
data_rule = await data_rule_dao.get_by_name(db, obj.name)
|
data_rule = await data_rule_dao.get_by_name(db, obj.name)
|
||||||
if data_rule:
|
if data_rule:
|
||||||
raise errors.ForbiddenError(msg='数据权限规则已存在')
|
raise errors.ForbiddenError(msg='数据规则已存在')
|
||||||
await data_rule_dao.create(db, obj)
|
await data_rule_dao.create(db, obj)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -112,22 +97,23 @@ class DataRuleService:
|
|||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
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)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(*, request: Request, pk: list[int]) -> int:
|
async def delete(*, pk: list[int]) -> int:
|
||||||
"""
|
"""
|
||||||
删除数据规则
|
删除数据规则
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param pk: 规则 ID 列表
|
:param pk: 规则 ID 列表
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.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()
|
||||||
@@ -33,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: 联系电话
|
||||||
@@ -45,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
|
||||||
|
|
||||||
@@ -93,24 +94,23 @@ class DeptService:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(*, request: Request, pk: int) -> int:
|
async def delete(*, pk: int) -> int:
|
||||||
"""
|
"""
|
||||||
删除部门
|
删除部门
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param pk: 部门 ID
|
:param pk: 部门 ID
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
dept = await dept_dao.get_with_relation(db, pk)
|
dept = await dept_dao.get_with_relation(db, pk)
|
||||||
dept_user = dept.users
|
if dept.users:
|
||||||
if dept_user:
|
|
||||||
raise errors.ForbiddenError(msg='部门下存在用户,无法删除')
|
raise errors.ForbiddenError(msg='部门下存在用户,无法删除')
|
||||||
children = await dept_dao.get_children(db, pk)
|
children = await dept_dao.get_children(db, pk)
|
||||||
if children:
|
if children:
|
||||||
raise errors.ForbiddenError(msg='部门下存在子部门,无法删除')
|
raise errors.ForbiddenError(msg='部门下存在子部门,无法删除')
|
||||||
count = await dept_dao.delete(db, pk)
|
count = await dept_dao.delete(db, pk)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
for user in dept.users:
|
||||||
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -42,44 +41,34 @@ class MenuService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
async with async_db_session() as db:
|
async with async_db_session() as db:
|
||||||
menu_select = await menu_dao.get_all(db, title=title, status=status)
|
menu_data = await menu_dao.get_all(db, title=title, status=status)
|
||||||
menu_tree = get_tree_data(menu_select)
|
menu_tree = get_tree_data(menu_data)
|
||||||
return menu_tree
|
return menu_tree
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_role_menu_tree(*, pk: int) -> list[dict[str, Any]]:
|
async def get_sidebar(*, request: Request) -> list[dict[str, Any] | None]:
|
||||||
"""
|
"""
|
||||||
获取角色的菜单树形结构
|
获取用户的菜单侧边栏
|
||||||
|
|
||||||
: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_user_menu_tree(*, request: Request) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取用户的菜单树形结构
|
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
:param request: FastAPI 请求对象
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
async with async_db_session() as db:
|
async with async_db_session() as db:
|
||||||
roles = request.user.roles
|
roles = request.user.roles
|
||||||
menu_ids = []
|
|
||||||
menu_tree = []
|
menu_tree = []
|
||||||
if roles:
|
if roles:
|
||||||
|
unique_menus = {}
|
||||||
for role in roles:
|
for role in roles:
|
||||||
menu_ids.extend([menu.id for menu in role.menus])
|
for menu in role.menus:
|
||||||
menu_select = await menu_dao.get_role_menus(db, request.user.is_superuser, menu_ids)
|
unique_menus[menu.id] = menu
|
||||||
menu_tree = get_vben5_tree_data(menu_select)
|
all_ids = set(unique_menus.keys())
|
||||||
|
valid_menu_ids = [
|
||||||
|
menu_id
|
||||||
|
for menu_id, menu in unique_menus.items()
|
||||||
|
if menu.parent_id is None or menu.parent_id in all_ids
|
||||||
|
]
|
||||||
|
menu_data = await menu_dao.get_sidebar(db, request.user.is_superuser, valid_menu_ids)
|
||||||
|
menu_tree = get_vben5_tree_data(menu_data)
|
||||||
return menu_tree
|
return menu_tree
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -123,14 +112,16 @@ class MenuService:
|
|||||||
if obj.parent_id == menu.id:
|
if obj.parent_id == menu.id:
|
||||||
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
||||||
count = await menu_dao.update(db, pk, obj)
|
count = await menu_dao.update(db, pk, obj)
|
||||||
|
for role in await menu.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
|
||||||
async def delete(*, request: Request, pk: int) -> int:
|
async def delete(*, pk: int) -> int:
|
||||||
"""
|
"""
|
||||||
删除菜单
|
删除菜单
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param pk: 菜单 ID
|
:param pk: 菜单 ID
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
@@ -138,8 +129,12 @@ class MenuService:
|
|||||||
children = await menu_dao.get_children(db, pk)
|
children = await menu_dao.get_children(db, pk)
|
||||||
if children:
|
if children:
|
||||||
raise errors.ForbiddenError(msg='菜单下存在子菜单,无法删除')
|
raise errors.ForbiddenError(msg='菜单下存在子菜单,无法删除')
|
||||||
|
menu = await menu_dao.get(db, pk)
|
||||||
count = await menu_dao.delete(db, pk)
|
count = await menu_dao.delete(db, pk)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
if menu:
|
||||||
|
for role in await menu.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,184 @@
|
|||||||
|
#!/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_namelist = zf.namelist()
|
||||||
|
plugin_name = plugin_namelist[0].split('/')[0]
|
||||||
|
if not plugin_namelist or plugin_name not in file.filename:
|
||||||
|
raise errors.ForbiddenError(msg='插件压缩包内容非法')
|
||||||
|
if (
|
||||||
|
len(plugin_namelist) <= 3
|
||||||
|
or f'{plugin_name}/plugin.toml' not in plugin_namelist
|
||||||
|
or f'{plugin_name}/README.md' not in plugin_namelist
|
||||||
|
):
|
||||||
|
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_name):
|
||||||
|
new_filename = member.filename.replace(plugin_name, '')
|
||||||
|
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, os.path.join(plugin, arcname))
|
||||||
|
|
||||||
|
bio.seek(0)
|
||||||
|
return bio
|
||||||
|
|
||||||
|
|
||||||
|
plugin_service: PluginService = PluginService()
|
||||||
@@ -1,11 +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 fastapi import Request
|
|
||||||
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
|
||||||
@@ -13,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:
|
||||||
@@ -46,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]:
|
||||||
"""
|
"""
|
||||||
获取用户的角色列表
|
获取用户的角色列表
|
||||||
|
|
||||||
@@ -54,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
|
||||||
@@ -68,6 +68,36 @@ 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] | None]:
|
||||||
|
"""
|
||||||
|
获取角色的菜单树形结构
|
||||||
|
|
||||||
|
: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_tree = get_tree_data(role.menus) if role.menus else []
|
||||||
|
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:
|
||||||
"""
|
"""
|
||||||
@@ -100,20 +130,21 @@ class RoleService:
|
|||||||
if role:
|
if role:
|
||||||
raise errors.ForbiddenError(msg='角色已存在')
|
raise errors.ForbiddenError(msg='角色已存在')
|
||||||
count = await role_dao.update(db, pk, obj)
|
count = await role_dao.update(db, pk, obj)
|
||||||
|
for user in await role.awaitable_attrs.users:
|
||||||
|
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_role_menu(*, request: Request, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
|
async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||||
"""
|
"""
|
||||||
更新角色菜单
|
更新角色菜单
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param pk: 角色 ID
|
:param pk: 角色 ID
|
||||||
:param menu_ids: 菜单 ID 列表
|
:param menu_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_with_relation(db, pk)
|
||||||
if not role:
|
if not role:
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
for menu_id in menu_ids.menus:
|
for menu_id in menu_ids.menus:
|
||||||
@@ -121,45 +152,47 @@ class RoleService:
|
|||||||
if not menu:
|
if not menu:
|
||||||
raise errors.NotFoundError(msg='菜单不存在')
|
raise errors.NotFoundError(msg='菜单不存在')
|
||||||
count = await role_dao.update_menus(db, pk, menu_ids)
|
count = await role_dao.update_menus(db, pk, menu_ids)
|
||||||
if pk in [role.id for role in request.user.roles]:
|
for user in await role.awaitable_attrs.users:
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_role_rule(*, request: Request, pk: int, rule_ids: UpdateRoleRuleParam) -> int:
|
async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||||
"""
|
"""
|
||||||
更新角色数据权限
|
更新角色数据范围
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
: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)
|
||||||
if pk in [role.id for role in request.user.roles]:
|
for user in await role.awaitable_attrs.users:
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(*, request: Request, pk: list[int]) -> int:
|
async def delete(*, pk: list[int]) -> int:
|
||||||
"""
|
"""
|
||||||
删除角色
|
删除角色
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param pk: 角色 ID 列表
|
:param pk: 角色 ID 列表
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
count = await role_dao.delete(db, pk)
|
count = await role_dao.delete(db, pk)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
for _pk in pk:
|
||||||
|
role = await role_dao.get(db, _pk)
|
||||||
|
if role:
|
||||||
|
for user in await role.awaitable_attrs.users:
|
||||||
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ 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.user import (
|
from backend.app.admin.schema.user import (
|
||||||
AddUserParam,
|
AddUserParam,
|
||||||
AvatarParam,
|
|
||||||
RegisterUserParam,
|
RegisterUserParam,
|
||||||
ResetPasswordParam,
|
ResetPasswordParam,
|
||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
UpdateUserRoleParam,
|
|
||||||
)
|
)
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.security.jwt import get_hash_password, get_token, jwt_decode, password_verify, superuser_verify
|
from backend.common.security.jwt import get_hash_password, get_token, jwt_decode, password_verify, superuser_verify
|
||||||
@@ -135,68 +133,29 @@ class UserService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
if not request.user.is_superuser and request.user.username != username:
|
if request.user.username != username:
|
||||||
raise errors.ForbiddenError(msg='你只能修改自己的信息')
|
raise errors.ForbiddenError(msg='你只能修改自己的信息')
|
||||||
input_user = await user_dao.get_with_relation(db, username=username)
|
user = await user_dao.get_with_relation(db, username=username)
|
||||||
if not input_user:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
if input_user.username != obj.username:
|
if user.username != obj.username:
|
||||||
_username = await user_dao.get_by_username(db, obj.username)
|
_username = await user_dao.get_by_username(db, obj.username)
|
||||||
if _username:
|
if _username:
|
||||||
raise errors.ForbiddenError(msg='用户名已注册')
|
raise errors.ForbiddenError(msg='用户名已注册')
|
||||||
if input_user.nickname != obj.nickname:
|
if user.nickname != obj.nickname:
|
||||||
nickname = await user_dao.get_by_nickname(db, obj.nickname)
|
nickname = await user_dao.get_by_nickname(db, obj.nickname)
|
||||||
if nickname:
|
if nickname:
|
||||||
raise errors.ForbiddenError(msg='昵称已注册')
|
raise errors.ForbiddenError(msg='昵称已注册')
|
||||||
if input_user.email != obj.email:
|
if user.email != obj.email:
|
||||||
email = await user_dao.check_email(db, obj.email)
|
email = await user_dao.check_email(db, obj.email)
|
||||||
if email:
|
if email:
|
||||||
raise errors.ForbiddenError(msg='邮箱已注册')
|
raise errors.ForbiddenError(msg='邮箱已注册')
|
||||||
count = await user_dao.update_userinfo(db, input_user.id, obj)
|
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
|
||||||
return count
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_roles(*, request: Request, username: str, obj: UpdateUserRoleParam) -> None:
|
|
||||||
"""
|
|
||||||
更新用户角色
|
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param username: 用户名
|
|
||||||
:param obj: 角色更新参数
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
async with async_db_session.begin() as db:
|
|
||||||
if not request.user.is_superuser and request.user.username != username:
|
|
||||||
raise errors.AuthorizationError
|
|
||||||
input_user = await user_dao.get_with_relation(db, username=username)
|
|
||||||
if not input_user:
|
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
|
||||||
for role_id in obj.roles:
|
for role_id in obj.roles:
|
||||||
role = await role_dao.get(db, role_id)
|
role = await role_dao.get(db, role_id)
|
||||||
if not role:
|
if not role:
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
await user_dao.update_role(db, input_user, obj)
|
count = await user_dao.update(db, user, obj)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{input_user.id}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_avatar(*, request: Request, username: str, avatar: AvatarParam) -> int:
|
|
||||||
"""
|
|
||||||
更新用户头像
|
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param username: 用户名
|
|
||||||
:param avatar: 头像参数
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
async with async_db_session.begin() as db:
|
|
||||||
if not request.user.is_superuser and request.user.username != username:
|
|
||||||
raise errors.AuthorizationError
|
|
||||||
input_user = await user_dao.get_by_username(db, username)
|
|
||||||
if not input_user:
|
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
|
||||||
count = await user_dao.update_avatar(db, input_user.id, avatar)
|
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -230,7 +189,7 @@ class UserService:
|
|||||||
raise errors.ForbiddenError(msg='非法操作')
|
raise errors.ForbiddenError(msg='非法操作')
|
||||||
super_status = await user_dao.get_super(db, pk)
|
super_status = await user_dao.get_super(db, pk)
|
||||||
count = await user_dao.set_super(db, pk, not super_status)
|
count = await user_dao.set_super(db, pk, not super_status)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -251,7 +210,7 @@ class UserService:
|
|||||||
raise errors.ForbiddenError(msg='非法操作')
|
raise errors.ForbiddenError(msg='非法操作')
|
||||||
staff_status = await user_dao.get_staff(db, pk)
|
staff_status = await user_dao.get_staff(db, pk)
|
||||||
count = await user_dao.set_staff(db, pk, not staff_status)
|
count = await user_dao.set_staff(db, pk, not staff_status)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -272,7 +231,7 @@ class UserService:
|
|||||||
raise errors.ForbiddenError(msg='非法操作')
|
raise errors.ForbiddenError(msg='非法操作')
|
||||||
status = await user_dao.get_status(db, pk)
|
status = await user_dao.get_status(db, pk)
|
||||||
count = await user_dao.set_status(db, pk, 0 if status == 1 else 1)
|
count = await user_dao.set_status(db, pk, 0 if status == 1 else 1)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -289,32 +248,22 @@ class UserService:
|
|||||||
user = await user_dao.get(db, pk)
|
user = await user_dao.get(db, pk)
|
||||||
if not user:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
user_id = request.user.id
|
multi_login = await user_dao.get_multi_login(db, pk) if pk != user.id else request.user.is_multi_login
|
||||||
multi_login = await user_dao.get_multi_login(db, pk) if pk != user_id else request.user.is_multi_login
|
new_multi_login = not multi_login
|
||||||
count = await user_dao.set_multi_login(db, pk, not multi_login)
|
count = await user_dao.set_multi_login(db, pk, new_multi_login)
|
||||||
# 删除当前用户缓存
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
|
|
||||||
token = get_token(request)
|
token = get_token(request)
|
||||||
token_payload = jwt_decode(token)
|
token_payload = jwt_decode(token)
|
||||||
latest_multi_login = await user_dao.get_multi_login(db, pk)
|
if pk == user.id:
|
||||||
# 超级用户修改自身时,除当前 token 外,其他 token 失效
|
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
||||||
if pk == user_id:
|
if not new_multi_login:
|
||||||
if not latest_multi_login:
|
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{pk}'
|
|
||||||
await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}')
|
await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}')
|
||||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
|
||||||
if refresh_token:
|
|
||||||
key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{pk}'
|
|
||||||
await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{refresh_token}')
|
|
||||||
# 超级用户修改他人时,其他 token 将全部失效
|
|
||||||
else:
|
else:
|
||||||
if not latest_multi_login:
|
# 系统管理员修改他人时,他人 token 全部失效
|
||||||
key_prefix = [f'{settings.TOKEN_REDIS_PREFIX}:{pk}']
|
if not new_multi_login:
|
||||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||||
if refresh_token:
|
await redis_client.delete_prefix(key_prefix)
|
||||||
key_prefix.append(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{pk}')
|
|
||||||
for prefix in key_prefix:
|
|
||||||
await redis_client.delete_prefix(prefix)
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ from fastapi import APIRouter
|
|||||||
from backend.app.task.api.v1.task import router as task_router
|
from backend.app.task.api.v1.task import router as task_router
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
|
|
||||||
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH, tags=['任务'])
|
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
|
||||||
|
|
||||||
v1.include_router(task_router, prefix='/tasks')
|
v1.include_router(task_router, prefix='/tasks', tags=['任务'])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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='任务队列')
|
||||||
|
|||||||
@@ -44,17 +44,19 @@ 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):
|
||||||
"""数据权限规则运算符"""
|
"""数据规则运算符"""
|
||||||
|
|
||||||
AND = 0
|
AND = 0
|
||||||
OR = 1
|
OR = 1
|
||||||
|
|
||||||
|
|
||||||
class RoleDataRuleExpressionType(IntEnum):
|
class RoleDataRuleExpressionType(IntEnum):
|
||||||
"""数据权限规则表达式"""
|
"""数据规则表达式"""
|
||||||
|
|
||||||
eq = 0 # ==
|
eq = 0 # ==
|
||||||
ne = 1 # !=
|
ne = 1 # !=
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
|
|||||||
from fastapi import Depends, Query
|
from fastapi import Depends, Query
|
||||||
from fastapi_pagination import pagination_ctx
|
from fastapi_pagination import pagination_ctx
|
||||||
from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
|
from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
|
||||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||||
from fastapi_pagination.links.bases import create_links
|
from fastapi_pagination.links.bases import create_links
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
|
|||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
items: list,
|
items: list,
|
||||||
total: int,
|
|
||||||
params: _CustomPageParams,
|
params: _CustomPageParams,
|
||||||
|
total: int = 0,
|
||||||
) -> _CustomPage[T]:
|
) -> _CustomPage[T]:
|
||||||
page = params.page
|
page = params.page
|
||||||
size = params.size
|
size = params.size
|
||||||
@@ -119,7 +119,7 @@ async def paging_data(db: AsyncSession, select: Select) -> dict[str, Any]:
|
|||||||
:param select: SQL 查询语句
|
:param select: SQL 查询语句
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
paginated_data: _CustomPage = await paginate(db, select)
|
paginated_data: _CustomPage = await apaginate(db, select)
|
||||||
page_data = paginated_data.model_dump()
|
page_data = paginated_data.model_dump()
|
||||||
return page_data
|
return page_data
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
"""自定义错误状态码"""
|
"""自定义错误状态码"""
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ResponseModel(BaseModel):
|
|||||||
|
|
||||||
class ResponseSchemaModel(ResponseModel, Generic[SchemaT]):
|
class ResponseSchemaModel(ResponseModel, Generic[SchemaT]):
|
||||||
"""
|
"""
|
||||||
包含返回数据 schema 的通用型统一返回模型,仅适用于非分页接口
|
包含返回数据 schema 的通用型统一返回模型
|
||||||
|
|
||||||
示例::
|
示例::
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
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
|
||||||
from backend.core.conf import settings
|
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:
|
|
||||||
from backend.app.admin.schema.data_rule import GetDataRuleDetail
|
|
||||||
|
|
||||||
|
|
||||||
class RequestPermission:
|
class RequestPermission:
|
||||||
"""
|
"""
|
||||||
@@ -47,33 +45,47 @@ 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 = []
|
unique_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:
|
||||||
|
unique_data_scopes[scope.id] = scope
|
||||||
|
|
||||||
|
# 转换为列表
|
||||||
|
data_scopes = list(unique_data_scopes.values())
|
||||||
|
|
||||||
# 超级管理员和无规则用户不做过滤
|
# 超级管理员和无规则用户不做过滤
|
||||||
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)
|
||||||
|
|
||||||
|
# 获取数据范围规则
|
||||||
|
unique_data_rules = {}
|
||||||
|
for data_scope in data_scopes:
|
||||||
|
data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope.id)
|
||||||
|
for rule in data_scope_with_relation.rules:
|
||||||
|
unique_data_rules[rule.id] = rule
|
||||||
|
|
||||||
|
# 转换为列表
|
||||||
|
data_rule_list = list(unique_data_rules.values())
|
||||||
|
|
||||||
where_and_list = []
|
where_and_list = []
|
||||||
where_or_list = []
|
where_or_list = []
|
||||||
|
|
||||||
for rule in user_data_rules:
|
for data_rule in 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,38 +94,40 @@ 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_:
|
||||||
|
values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
|
||||||
condition = column_obj.in_(values)
|
condition = column_obj.in_(values)
|
||||||
elif rule.expression == RoleDataRuleExpressionType.not_in:
|
case RoleDataRuleExpressionType.not_in:
|
||||||
values = rule.value.split(',') if isinstance(rule.value, str) else rule.value
|
values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
|
||||||
condition = ~column_obj.in_(values)
|
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:
|
||||||
|
case RoleDataRuleOperatorType.AND:
|
||||||
where_and_list.append(condition)
|
where_and_list.append(condition)
|
||||||
elif rule.operator == RoleDataRuleOperatorType.OR:
|
case RoleDataRuleOperatorType.OR:
|
||||||
where_or_list.append(condition)
|
where_or_list.append(condition)
|
||||||
|
|
||||||
# 组合所有条件
|
# 组合所有条件
|
||||||
|
|||||||
@@ -60,17 +60,22 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
|
|||||||
if path_auth_perm in settings.RBAC_ROLE_MENU_EXCLUDE:
|
if path_auth_perm in settings.RBAC_ROLE_MENU_EXCLUDE:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 已分配菜单权限校验
|
# 菜单去重
|
||||||
allow_perms = []
|
unique_menus = {}
|
||||||
for role in user_roles:
|
for role in user_roles:
|
||||||
for menu in role.menus:
|
for menu in role.menus:
|
||||||
|
unique_menus[menu.id] = menu
|
||||||
|
|
||||||
|
# 已分配菜单权限校验
|
||||||
|
allow_perms = []
|
||||||
|
for menu in list(unique_menus.values()):
|
||||||
if menu.perms and menu.status == StatusType.enable:
|
if menu.perms and menu.status == StatusType.enable:
|
||||||
allow_perms.extend(menu.perms.split(','))
|
allow_perms.extend(menu.perms.split(','))
|
||||||
if path_auth_perm not in allow_perms:
|
if path_auth_perm not in allow_perms:
|
||||||
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}')
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from backend.database.redis import redis_client
|
|||||||
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
|
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
|
||||||
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
||||||
from backend.middleware.state_middleware import StateMiddleware
|
from backend.middleware.state_middleware import StateMiddleware
|
||||||
from backend.plugin.tools import plugin_router_inject
|
from backend.plugin.tools import build_final_router
|
||||||
from backend.utils.demo_site import demo_site
|
from backend.utils.demo_site import demo_site
|
||||||
from backend.utils.health_check import ensure_unique_route_names, http_limit_callback
|
from backend.utils.health_check import ensure_unique_route_names, http_limit_callback
|
||||||
from backend.utils.openapi import simplify_operation_ids
|
from backend.utils.openapi import simplify_operation_ids
|
||||||
@@ -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,
|
||||||
@@ -157,12 +155,8 @@ def register_router(app: FastAPI) -> None:
|
|||||||
"""
|
"""
|
||||||
dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None
|
dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None
|
||||||
|
|
||||||
# 插件路由
|
# API
|
||||||
plugin_router_inject()
|
router = build_final_router()
|
||||||
|
|
||||||
# 系统路由(必须在插件路由注入后导入)
|
|
||||||
from backend.app.router import router
|
|
||||||
|
|
||||||
app.include_router(router, dependencies=dependencies)
|
app.include_router(router, dependencies=dependencies)
|
||||||
|
|
||||||
# Extra
|
# Extra
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|
||||||
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
|
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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)}">'
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[app]
|
|
||||||
include = 'admin'
|
|
||||||
|
|
||||||
[api.api]
|
|
||||||
prefix = '/apis'
|
|
||||||
tags = '系统API'
|
|
||||||
|
|
||||||
[api.casbin]
|
|
||||||
prefix = '/casbin'
|
|
||||||
tags = '系统Casbin权限'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
casbin>=1.38.0
|
|
||||||
casbin_async_sqlalchemy_adapter>=1.7.0
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
@@ -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
|
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.plugin.code_generator.api.v1.business import router as gen_business_router
|
from backend.plugin.code_generator.api.v1.business import router as business_router
|
||||||
from backend.plugin.code_generator.api.v1.column import router as gen_model_router
|
from backend.plugin.code_generator.api.v1.column import router as model_router
|
||||||
from backend.plugin.code_generator.api.v1.gen import router as gen_router
|
from backend.plugin.code_generator.api.v1.gen import router as gen_router
|
||||||
|
|
||||||
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成'])
|
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成'])
|
||||||
|
|
||||||
v1.include_router(gen_router)
|
v1.include_router(gen_router)
|
||||||
v1.include_router(gen_business_router, prefix='/businesses')
|
v1.include_router(business_router, prefix='/businesses')
|
||||||
v1.include_router(gen_model_router, prefix='/models')
|
v1.include_router(model_router, prefix='/models')
|
||||||
|
|||||||
@@ -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'},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -175,6 +175,7 @@ class GenService:
|
|||||||
code_folder.mkdir(parents=True, exist_ok=True)
|
code_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
init_filepath = code_folder.joinpath('__init__.py')
|
init_filepath = code_folder.joinpath('__init__.py')
|
||||||
|
if not os.path.exists(init_filepath):
|
||||||
async with aiofiles.open(init_filepath, 'w', encoding='utf-8') as f:
|
async with aiofiles.open(init_filepath, 'w', encoding='utf-8') as f:
|
||||||
await f.write(gen_template.init_content)
|
await f.write(gen_template.init_content)
|
||||||
|
|
||||||
@@ -194,8 +195,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 +230,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user