Compare commits

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

* Update build vben5 tree

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

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

* update seek

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

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

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

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

* update codes

* update codes

* update filter_data_permission

* Fix schema

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

* Update import

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

* Update func doc
2025-04-24 20:27:08 +08:00
Wu Clan 193eba3c58 Update the changelog for v1.1.2 (#589) 2025-04-23 23:43:56 +08:00
Wu Clan 338e50e255 Update the routing style of the task app (#588)
* Update the routing style of the task app

* Fix prefix
2025-04-23 19:33:36 +08:00
Wu Clan 65500d7a53 Bump fastapi pagination from 0.12.34 to 0.13.0 (#587) 2025-04-23 13:04:09 +08:00
Wu Clan 0833cd5443 Optimize the userinfo cache cleaning logic (#586) 2025-04-23 12:40:27 +08:00
Wu Clan e8c844ead7 Fix cache cleanup when updating role menu (#585) 2025-04-21 17:58:01 +08:00
Wu Clan 1d5f2dc5f0 Fix the condition to query menu by title (#584) 2025-04-21 17:57:12 +08:00
Wu Clanandwu-clan 9a36154564 Update the changelog for v1.1.1 (#583)
This commit was created by changelog-from-release in 'Release changelog' CI workflow

Co-authored-by: wu-clan <wu-clan@users.noreply.github.com>
2025-04-18 23:19:01 +08:00
Wu Clan 5f8ae74ea5 Fix list query in the dict plugin (#582) 2025-04-18 23:16:03 +08:00
Wu Clan da8e7c1b01 Fix the plugin system route injection (#581) 2025-04-18 23:09:25 +08:00
Wu Clan f513b98f15 Update the changelog for v1.1.0 (#580) 2025-04-17 17:54:52 +08:00
Wu Clan 80cef6dde7 Update code generator file and table naming (#579)
* Update code generator file and table naming

* Update column table comment
2025-04-17 17:47:20 +08:00
Huyuwei 2d504f281e Fix avatar url type of update avatar (#575) 2025-04-17 15:02:19 +08:00
Wu Clan fc34c98f0a Update the code generator to plugin (#578)
* Update the code generator to plugin

* Fix get all tables return type
2025-04-17 11:06:43 +08:00
Wu Clan 1cc020efdd Update unique columns in dict models (#577) 2025-04-17 11:06:29 +08:00
Wu Clan bb33e99e33 Update casbin RBAC verify to dynamic import (#576) 2025-04-17 11:06:15 +08:00
Wu Clan 6c70b34ae6 Optimize the file structure of code generator (#574) 2025-04-15 19:54:18 +08:00
Wu Clan 960d9f6215 Update the default value for some functions (#573)
* Update the default value for some functions

* Fix lint
2025-04-14 14:22:05 +08:00
Wu Clan 085f08ec3b Update the changelog for v1.0.5 (#572) 2025-04-09 11:55:49 +08:00
Wu Clan 2a8722f56e Fix the log rule in gitignore (#571) 2025-04-09 11:53:55 +08:00
Wu Clan 4372b9bbc2 Bump fastapi oauth2 from 0.0.1a2 to 0.0.1 (#570) 2025-04-09 11:49:04 +08:00
Wu Clan db81319dc4 Fix the failure hook of celery task (#569) 2025-04-08 12:09:29 +08:00
Wu Clan 644f7a0413 Fix the login password verification (#568)
* Fix the login password verification

* Update the check criteria
2025-04-07 11:04:39 +08:00
Wu Clan b93ff19ebe Update the number of pagination le (#565) 2025-04-03 19:32:26 +08:00
Wu Clan 4c007cb47d Update custom validation error messages (#566) 2025-04-03 19:32:11 +08:00
Wu Clan c9e9a45288 Update the vben5 tree data structure (#564) 2025-04-02 23:18:55 +08:00
Wu Clan d44a5491f5 Update menu and add vben5 compatibility (#563)
* Update menu and add vben5 compatibility

* Update sidebar api description
2025-04-02 22:20:19 +08:00
Wu Clan 289cf5b974 Update dict data and type to plugin (#562) 2025-04-02 14:04:06 +08:00
Wu Clan e207622a70 Update the system config to plugin (#561) 2025-04-02 13:43:24 +08:00
Wu Clan 38d9d9fda5 Add python 3.13 to GitHub ci (#560) 2025-03-28 14:22:45 +08:00
Wu Clan 996895e775 Bump dependencies and pre-commits (#559) 2025-03-28 14:22:20 +08:00
Wu Clan 0675e53f2a Update the changelog for v1.0.4 (#558) 2025-03-28 10:15:11 +08:00
194 changed files with 3609 additions and 3279 deletions
+2 -2
View File
@@ -12,13 +12,13 @@ jobs:
name: lint ${{ matrix.python-version }} name: lint ${{ matrix.python-version }}
strategy: strategy:
matrix: matrix:
python-version: [ '3.10', '3.11', '3.12' ] python-version: [ '3.10', '3.11', '3.12', '3.13' ]
fail-fast: false fail-fast: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v3 uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }}
+4 -4
View File
@@ -2,13 +2,13 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v5.0.0
hooks: hooks:
- id: check-added-large-files # - id: check-added-large-files
- id: end-of-file-fixer # - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-toml - id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.9.5 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.5.29 rev: 0.7.3
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
+165
View File
@@ -1,3 +1,160 @@
<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>
# [v1.0.5](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.5) - 2025-04-09
## What's Changed
* Update the changelog for v1.0.4 by [@wu-clan](https://github.com/wu-clan) in [#558](https://github.com/fastapi-practices/fastapi_best_architecture/pull/558)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#559](https://github.com/fastapi-practices/fastapi_best_architecture/pull/559)
* Add python 3.13 to GitHub ci by [@wu-clan](https://github.com/wu-clan) in [#560](https://github.com/fastapi-practices/fastapi_best_architecture/pull/560)
* Update the system config to plugin by [@wu-clan](https://github.com/wu-clan) in [#561](https://github.com/fastapi-practices/fastapi_best_architecture/pull/561)
* Update dict data and type to plugin by [@wu-clan](https://github.com/wu-clan) in [#562](https://github.com/fastapi-practices/fastapi_best_architecture/pull/562)
* Update menu and add vben5 compatibility by [@wu-clan](https://github.com/wu-clan) in [#563](https://github.com/fastapi-practices/fastapi_best_architecture/pull/563)
* Update the vben5 tree data structure by [@wu-clan](https://github.com/wu-clan) in [#564](https://github.com/fastapi-practices/fastapi_best_architecture/pull/564)
* Update custom validation error messages by [@wu-clan](https://github.com/wu-clan) in [#566](https://github.com/fastapi-practices/fastapi_best_architecture/pull/566)
* Update the number of pagination le by [@wu-clan](https://github.com/wu-clan) in [#565](https://github.com/fastapi-practices/fastapi_best_architecture/pull/565)
* Fix the login password verification by [@wu-clan](https://github.com/wu-clan) in [#568](https://github.com/fastapi-practices/fastapi_best_architecture/pull/568)
* Fix the failure hook of celery task by [@wu-clan](https://github.com/wu-clan) in [#569](https://github.com/fastapi-practices/fastapi_best_architecture/pull/569)
* Bump fastapi oauth2 from 0.0.1a2 to 0.0.1 by [@wu-clan](https://github.com/wu-clan) in [#570](https://github.com/fastapi-practices/fastapi_best_architecture/pull/570)
* Fix the log rule in gitignore by [@wu-clan](https://github.com/wu-clan) in [#571](https://github.com/fastapi-practices/fastapi_best_architecture/pull/571)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5
[Changes][v1.0.5]
<a id="v1.0.4"></a>
# [v1.0.4](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.4) - 2025-03-28
## What's Changed
* Update the changelog for v1.0.3 by [@wu-clan](https://github.com/wu-clan) in [#543](https://github.com/fastapi-practices/fastapi_best_architecture/pull/543)
* Updated the backend contribution guide by [@wu-clan](https://github.com/wu-clan) in [#544](https://github.com/fastapi-practices/fastapi_best_architecture/pull/544)
* Optimize the return of relationship interfaces by [@wu-clan](https://github.com/wu-clan) in [#545](https://github.com/fastapi-practices/fastapi_best_architecture/pull/545)
* Optimize the dynamic import of data models by [@wu-clan](https://github.com/wu-clan) in [#546](https://github.com/fastapi-practices/fastapi_best_architecture/pull/546)
* Update git and docker ignore files by [@wu-clan](https://github.com/wu-clan) in [#547](https://github.com/fastapi-practices/fastapi_best_architecture/pull/547)
* Optimize dependencies to reduce package size by [@wu-clan](https://github.com/wu-clan) in [#548](https://github.com/fastapi-practices/fastapi_best_architecture/pull/548)
* Fix async install plugin dependencies for windows by [@wu-clan](https://github.com/wu-clan) in [#549](https://github.com/fastapi-practices/fastapi_best_architecture/pull/549)
* Fix return schema of the config api by [@wu-clan](https://github.com/wu-clan) in [#551](https://github.com/fastapi-practices/fastapi_best_architecture/pull/551)
* Optimize schemas with model relationships by [@wu-clan](https://github.com/wu-clan) in [#552](https://github.com/fastapi-practices/fastapi_best_architecture/pull/552)
* Fix filters for opera log query list by [@ThankCat](https://github.com/ThankCat) in [#554](https://github.com/fastapi-practices/fastapi_best_architecture/pull/554)
* Fix the celery env in docker compose by [@wu-clan](https://github.com/wu-clan) in [#555](https://github.com/fastapi-practices/fastapi_best_architecture/pull/555)
* Update volumes of redis in docker compose by [@wu-clan](https://github.com/wu-clan) in [#556](https://github.com/fastapi-practices/fastapi_best_architecture/pull/556)
* Fix the query for the sub department by [@PoetryL](https://github.com/PoetryL) in [#557](https://github.com/fastapi-practices/fastapi_best_architecture/pull/557)
* Optimize codes and comments with cursor by [@wu-clan](https://github.com/wu-clan) in [#550](https://github.com/fastapi-practices/fastapi_best_architecture/pull/550)
## New Contributors
* [@ThankCat](https://github.com/ThankCat) made their first contribution in [#554](https://github.com/fastapi-practices/fastapi_best_architecture/pull/554)
* [@PoetryL](https://github.com/PoetryL) made their first contribution in [#557](https://github.com/fastapi-practices/fastapi_best_architecture/pull/557)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
[Changes][v1.0.4]
<a id="v1.0.3"></a> <a id="v1.0.3"></a>
# [v1.0.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.3) - 2025-03-11 # [v1.0.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.3) - 2025-03-11
@@ -375,6 +532,14 @@
[Changes][v1.0.0] [Changes][v1.0.0]
[v1.4.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
[v1.3.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
[v1.1.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2
[v1.1.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
[v1.1.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.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
[v1.0.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2 [v1.0.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2
[v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1 [v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
+1 -2
View File
@@ -2,8 +2,7 @@
ARG SERVER_TYPE=fastapi_server ARG SERVER_TYPE=fastapi_server
# === Python environment from uv === # === Python environment from uv ===
FROM python:3.10-slim AS builder FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Used for build Python packages # Used for build Python packages
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
+3 -43
View File
@@ -4,11 +4,9 @@
# FastAPI Best Architecture # FastAPI Best Architecture
English | [简体中文](./README.zh-CN.md)
Enterprise-level backend architecture solution Enterprise-level backend architecture solution
**🔥Continuously updated and maintained🔥** English | [简体中文](./README.zh-CN.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
@@ -21,12 +19,10 @@ Enterprise-level backend architecture solution
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344) ![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div> </div>
> [!NOTE]
> This repository as a template library open to any person or enterprise can be used for free!
## Pseudo 3-tier architecture ## Pseudo 3-tier architecture
The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating
@@ -45,43 +41,7 @@ pattern, use templates to transform it to your heart's content!
| data access | dao / mapper | crud | | data access | dao / mapper | crud |
| model | model / entity | model | | model | model / entity | model |
## Features ## Help
- [x] Global FastAPI PEP 593 Annotated parameter style
- [x] Comprehensive async/await + asgiref asynchronous design
- [x] Adheres to RESTful API specifications
- [x] Uses SQLAlchemy 2.0 with new syntax
- [x] Uses Pydantic v2 version
- [x] Implements role-menu RBAC access control
- [x] Integrates Casbin RBAC access control
- [x] Supports Celery asynchronous tasks
- [x] Custom-developed JWT authentication middleware
- [x] Supports global custom time zones
- [x] Supports Docker / Docker-compose deployment
- [x] Integrates Pytest unit testing
## Built-in Functions
- [x] User Management: Assign roles and permissions
- [x] Department Management: Configure organizational structure (company, department, team, etc.)
- [x] Menu Management: Set up menus and button-level permissions
- [x] Role Management: Configure roles, assign menus and permissions
- [x] Dictionary Management: Maintain common parameters and configurations
- [x] Parameter Management: Dynamically configure commonly used system parameters
- [x] Notification Announcements: Publish and maintain system notification and announcement information
- [x] Token Management: Detect online status, support forced logout
- [x] Multi-device Login: Support one-click switching between multi-device login modes
- [x] OAuth 2.0: Built-in custom-developed OAuth 2.0 authorization login
- [x] Plugin System: Hot-swappable plugin design to reduce coupling
- [x] Scheduled Tasks: Support scheduled, asynchronous tasks, and function calls
- [x] Code Generation: Automatically generate code with preview, write, and download support
- [x] Operation Logs: Record and query normal and abnormal operations
- [x] Login Logs: Record and query normal and abnormal logins
- [x] Cache Monitoring: Query system cache information and command statistics
- [x] Service Monitoring: View server hardware information and status
- [x] API Documentation: Automatically generate online interactive API documentation
## Development and deployment
For more details, please check For more details, please check
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
+3 -43
View File
@@ -4,11 +4,9 @@
# FastAPI Best Architecture # FastAPI Best Architecture
简体中文 | [English](./README.md)
企业级后端架构解决方案 企业级后端架构解决方案
**🔥持续更新维护中🔥** 简体中文 | [English](./README.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
@@ -21,12 +19,10 @@
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344) ![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div> </div>
> [!NOTE]
> 此仓库作为模板库公开,任何人或企业均可免费使用!
## 伪三层架构 ## 伪三层架构
mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷 mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷
@@ -43,43 +39,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
| 数据访问 | dao / mapper | crud | | 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model | | 模型 | model / entity | model |
## 特性 ## 帮助
- [x] 全局 FastAPI PEP 593 Annotated 参数风格
- [x] 全面 async/await + asgiref 异步设计
- [x] 遵循 RESTful API 规范
- [x] 使用 SQLAlchemy 2.0 全新语法
- [x] 使用 Pydantic v2 版本
- [x] 实现角色菜单 RBAC 访问控制
- [x] 集成 Casbin RBAC 访问控制
- [x] 支持 Celery 异步任务
- [x] 自研 JWT 认证中间件
- [x] 支持全局自定义时间时区
- [x] 支持 Docker / Docker-compose 部署
- [x] 集成 Pytest 单元测试
## 内置功能
- [x] 用户管理:分配角色和权限
- [x] 部门管理:配置组织架构(公司、部门、小组等)
- [x] 菜单管理:设置菜单及按钮级权限
- [x] 角色管理:配置角色、分配菜单和权限
- [x] 字典管理:维护常用参数和配置
- [x] 参数管理:系统常用参数动态配置
- [x] 通知公告:发布和维护系统通知公告信息
- [x] 令牌管理:检测在线状态,支持强制下线
- [x] 多端登录:支持一键切换多端登录模式
- [x] OAuth 2.0:内置自研 OAuth 2.0 授权登录
- [x] 插件系统:热插拔插件设计,降低耦合
- [x] 定时任务:支持定时,异步任务及函数调用
- [x] 代码生成:自动生成代码,支持预览、写入和下载
- [x] 操作日志:记录和查询正常和异常操作
- [x] 登录日志:记录和查询正常和异常登录
- [x] 缓存监控:查询系统缓存信息和命令统计
- [x] 服务监控:查看服务器硬件信息和状态
- [x] 接口文档:自动生成在线交互式 API 文档
## 开发部署
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) 更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
+6 -6
View File
@@ -15,18 +15,18 @@ REDIS_DATABASE=0
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log # Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b' OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# Admin # App Admin
# OAuth2 # OAuth2
OAUTH2_GITHUB_CLIENT_ID='test' OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test' OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test' OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test' OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# Task # App Task
# Celery # Celery
CELERY_BROKER_REDIS_DATABASE=1 CELERY_BROKER_REDIS_DATABASE=1
CELERY_BACKEND_REDIS_DATABASE=2 CELERY_BACKEND_REDIS_DATABASE=2
# Rabbitmq # Rabbitmq
RABBITMQ_HOST='127.0.0.1' CELERY_RABBITMQ_HOST='127.0.0.1'
RABBITMQ_PORT=5672 CELERY_RABBITMQ_PORT=5672
RABBITMQ_USERNAME='guest' CELERY_RABBITMQ_USERNAME='guest'
RABBITMQ_PASSWORD='guest' CELERY_RABBITMQ_PASSWORD='guest'
+1 -1
View File
@@ -1,6 +1,6 @@
__pycache__/ __pycache__/
.env .env
log/
alembic/versions/ alembic/versions/
static/media/ static/media/
*.log
celerybeat-schedule.* celerybeat-schedule.*
+1 -1
View File
@@ -20,7 +20,7 @@ from backend.plugin.tools import get_plugin_models
# import your new model here # import your new model here
from backend.app.admin.model import * # noqa: F401 from backend.app.admin.model import * # noqa: F401
from backend.app.generator.model import * # noqa: F401 from backend.plugin.code_generator.model import * # noqa: F401
# import plugin model # import plugin model
for cls in get_plugin_models(): for cls in get_plugin_models():
-2
View File
@@ -5,14 +5,12 @@ from fastapi import APIRouter
from backend.app.admin.api.v1.auth import router as auth_router from backend.app.admin.api.v1.auth import router as auth_router
from backend.app.admin.api.v1.log import router as log_router from backend.app.admin.api.v1.log import router as log_router
from backend.app.admin.api.v1.monitor import router as monitor_router from backend.app.admin.api.v1.monitor import router as monitor_router
from backend.app.admin.api.v1.oauth2 import router as oauth2_router
from backend.app.admin.api.v1.sys import router as sys_router from backend.app.admin.api.v1.sys import router as sys_router
from backend.core.conf import settings from backend.core.conf import settings
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
v1.include_router(auth_router) v1.include_router(auth_router)
v1.include_router(oauth2_router)
v1.include_router(sys_router) v1.include_router(sys_router)
v1.include_router(log_router) v1.include_router(log_router)
v1.include_router(monitor_router) v1.include_router(monitor_router)
+1 -2
View File
@@ -11,7 +11,6 @@ from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwagge
from backend.app.admin.schema.user import AuthLoginParam from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.auth_service import auth_service from backend.app.admin.service.auth_service import auth_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
router = APIRouter() router = APIRouter()
@@ -41,7 +40,7 @@ async def create_new_token(request: Request) -> ResponseSchemaModel[GetNewToken]
return response_base.success(data=data) return response_base.success(data=data)
@router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth]) @router.post('/logout', summary='用户登出')
async def user_logout(request: Request, response: Response) -> ResponseModel: async def user_logout(request: Request, response: Response) -> ResponseModel:
await auth_service.logout(request=request, response=response) await auth_service.logout(request=request, response=response)
return response_base.success() return response_base.success()
+3 -3
View File
@@ -5,9 +5,9 @@ from fastapi import APIRouter, Depends, Request
from fastapi_limiter.depends import RateLimiter from fastapi_limiter.depends import RateLimiter
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.admin.conf import admin_settings
from backend.app.admin.schema.captcha import GetCaptchaDetail from backend.app.admin.schema.captcha import GetCaptchaDetail
from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@@ -26,9 +26,9 @@ async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]
img, code = await run_in_threadpool(img_captcha, img_byte=img_type) img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
ip = request.state.ip ip = request.state.ip
await redis_client.set( await redis_client.set(
f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}', f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}',
code, code,
ex=admin_settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
) )
data = GetCaptchaDetail(image_type=img_type, image=img) data = GetCaptchaDetail(image_type=img_type, image=img)
return response_base.success(data=data) return response_base.success(data=data)
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.monitor.online import router as token_router
from backend.app.admin.api.v1.monitor.redis import router as redis_router from backend.app.admin.api.v1.monitor.redis import router as redis_router
from backend.app.admin.api.v1.monitor.server import router as server_router from backend.app.admin.api.v1.monitor.server import router as server_router
@@ -9,3 +10,4 @@ router = APIRouter(prefix='/monitors')
router.include_router(redis_router, prefix='/redis', tags=['redis监控']) router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
router.include_router(server_router, prefix='/server', tags=['服务器监控']) router.include_router(server_router, prefix='/server', tags=['服务器监控'])
router.include_router(token_router, prefix='/online', tags=['在线用户'])
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.token import GetTokenDetail, KickOutToken from backend.app.admin.schema.token import GetTokenDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
@@ -18,8 +18,8 @@ from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth]) @router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth])
async def get_tokens( async def get_online(
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
) -> ResponseSchemaModel[list[GetTokenDetail]]: ) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*') token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
@@ -61,7 +61,8 @@ async def get_tokens(
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}') extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}')
if extra_info: if extra_info:
extra_info = json.loads(extra_info) extra_info = json.loads(extra_info)
if extra_info.get('login_type') != 'swagger': # 排除 swagger 登录生成的 token
if extra_info.get('swagger') is None:
if username is not None: if username is not None:
if username == extra_info.get('username'): if username == extra_info.get('username'):
append_token_detail() append_token_detail()
@@ -81,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'])
+4 -10
View File
@@ -1,29 +1,23 @@
#!/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.config import router as config_router
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.dict_data import router as dict_data_router
from backend.app.admin.api.v1.sys.dict_type import router as dict_type_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
router = APIRouter(prefix='/sys') router = APIRouter(prefix='/sys')
router.include_router(config_router, prefix='/configs', tags=['系统参数配置'])
router.include_router(dept_router, prefix='/depts', tags=['系统部门']) router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
router.include_router(dict_data_router, prefix='/dict-datas', tags=['系统字典数据'])
router.include_router(dict_type_router, prefix='/dict-types', 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=['系统插件'])
+17 -14
View File
@@ -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()
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
GetDataScopeDetail,
GetDataScopeWithRelationDetail,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.service.data_scope_service import data_scope_service
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
router = APIRouter()
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
async def get_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeDetail]:
data = await data_scope_service.get(pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
async def get_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
data = await data_scope_service.get_rules(pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有数据范围',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_pagination_data_scopes(
db: CurrentSession,
name: Annotated[str | None, Query(description='范围名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
data_scope_select = await data_scope_service.get_select(name=name, status=status)
page_data = await paging_data(db, data_scope_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建数据范围',
dependencies=[
Depends(RequestPermission('data:scope:add')),
DependsRBAC,
],
)
async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel:
await data_scope_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新数据范围',
dependencies=[
Depends(RequestPermission('data:scope:edit')),
DependsRBAC,
],
)
async def update_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam
) -> ResponseModel:
count = await data_scope_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/rules',
summary='更新数据范围规则',
dependencies=[
Depends(RequestPermission('data:scope:rule:edit')),
DependsRBAC,
],
)
async def update_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam
):
count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除数据范围',
dependencies=[
Depends(RequestPermission('data:scope:del')),
DependsRBAC,
],
)
async def delete_data_scope(pk: Annotated[list[int], Query(description='数据范围 ID 列表')]) -> ResponseModel:
count = await data_scope_service.delete(pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
+4 -3
View File
@@ -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()
+3 -3
View File
@@ -14,7 +14,7 @@ from backend.common.security.rbac import DependsRBAC
router = APIRouter() router = APIRouter()
@router.get('/sidebar', summary='获取用户侧边栏', 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]]]:
menu = await menu_service.get_user_menu_tree(request=request) menu = await menu_service.get_user_menu_tree(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()
+68 -68
View File
@@ -1,98 +1,98 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import io from typing import Annotated, Any
import os.path
import zipfile
from typing import Annotated from fastapi import APIRouter, Depends, File, Path, UploadFile
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.params import Query from fastapi.params import Query
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from backend.common.exception import errors from backend.app.admin.service.plugin_service import plugin_service
from backend.common.response.response_schema import ResponseModel, response_base from backend.common.response.response_code import CustomResponseCode
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.core.path_conf import PLUGIN_DIR
from backend.plugin.tools import install_requirements_async
router = APIRouter() router = APIRouter()
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth])
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
plugins = await plugin_service.get_all()
return response_base.success(data=plugins)
@router.get('/changed', summary='插件状态是否变更', dependencies=[DependsJwtAuth])
async def plugin_changed() -> ResponseSchemaModel[bool]:
plugins = await plugin_service.changed()
return response_base.success(data=bool(plugins))
@router.post( @router.post(
'/install', '/install/zip',
summary='安装插件', summary='安装 zip 插件',
description='使用插件 zip 压缩包进行安装', description='使用插件 zip 压缩包进行安装',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:install')), Depends(RequestPermission('sys:plugin:install')),
DependsRBAC, DependsRBAC,
], ],
) )
async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: async def install_zip_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
contents = await file.read() await plugin_service.install_zip(file=file)
file_bytes = io.BytesIO(contents) return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
if not zipfile.is_zipfile(file_bytes):
raise errors.ForbiddenError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_dir_in_zip = f'{file.filename[:-4]}/backend/plugin/'
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)]
if not members_in_plugin_dir:
raise errors.ForbiddenError(msg='插件压缩包内容非法')
plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '')
if (
len(members_in_plugin_dir) <= 3
or f'{plugin_dir_in_zip}{plugin_name}/plugin.toml' not in members_in_plugin_dir
or f'{plugin_dir_in_zip}{plugin_name}/README.md' not in members_in_plugin_dir
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir_in_zip):
new_filename = member.filename.replace(plugin_dir_in_zip, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(PLUGIN_DIR, members)
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
await install_requirements_async()
return response_base.success()
@router.post( @router.post(
'/zip', '/install/git',
summary='安装 git 插件',
description='使用插件 git 仓库地址进行安装,不限制平台;如果需要凭证,需在 git 仓库地址中添加凭证信息',
dependencies=[
Depends(RequestPermission('sys:plugin:install')),
DependsRBAC,
],
)
async def install_git_plugin(repo_url: Annotated[str, Query(description='插件 git 仓库地址')]) -> ResponseModel:
await plugin_service.install_git(repo_url=repo_url)
return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
@router.delete(
'/uninstall',
summary='卸载插件',
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
dependencies=[
Depends(RequestPermission('sys:plugin:uninstall')),
DependsRBAC,
],
)
async def uninstall_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel:
await plugin_service.uninstall(plugin=plugin)
return response_base.success(res=CustomResponseCode.PLUGIN_UNINSTALL_SUCCESS)
@router.post(
'/status',
summary='更新插件状态',
dependencies=[
Depends(RequestPermission('sys:plugin:status')),
DependsRBAC,
],
)
async def update_plugin_status(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel:
await plugin_service.update_status(plugin=plugin)
return response_base.success()
@router.get(
'/zip/{plugin}',
summary='打包插件', summary='打包插件',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:zip')), Depends(RequestPermission('sys:plugin:zip')),
DependsRBAC, DependsRBAC,
], ],
) )
async def build_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> StreamingResponse: async def build_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
plugin_dir = os.path.join(PLUGIN_DIR, plugin) bio = await plugin_service.build(plugin=plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, arcname)
bio.seek(0)
return StreamingResponse( return StreamingResponse(
bio, bio,
media_type='application/x-zip-compressed', media_type='application/x-zip-compressed',
+17 -19
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, 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,7 +33,7 @@ async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
async def get_user_all_roles( async def get_user_all_roles(
pk: Annotated[int, Path(description='用户 ID')], pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[list[GetRoleDetail]]: ) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_by_user(pk=pk) data = await role_service.get_users(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -43,13 +41,13 @@ async def get_user_all_roles(
async def get_role_all_menus( async def get_role_all_menus(
pk: Annotated[int, Path(description='角色 ID')], pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any]]]:
menu = await menu_service.get_role_menu_tree(pk=pk) menu = await role_service.get_menu_tree(pk=pk)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}/rules', summary='获取角色所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
async def get_role_all_rules(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]: async def get_role_all_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]:
rule = await data_rule_service.get_role_rules(pk=pk) rule = await role_service.get_scopes(pk=pk)
return response_base.success(data=rule) return response_base.success(data=rule)
@@ -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
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, File, UploadFile
-40
View File
@@ -1,40 +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_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback'
OAUTH2_LINUX_DO_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/linux-do/callback'
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 分钟
# 参数配置
CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy']
@lru_cache
def get_admin_settings() -> AdminSettings:
"""获取 admin 参数配置"""
return AdminSettings()
admin_settings = get_admin_settings()
+3 -3
View File
@@ -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:
""" """
@@ -24,14 +24,14 @@ class CRUDDataRule(CRUDPlus[DataRule]):
""" """
return await self.select_model(db, pk) return await self.select_model(db, pk)
async def get_list(self, name: str | None = None) -> Select: async def get_list(self, name: str | None) -> Select:
""" """
获取规则列表 获取规则列表
:param name: 规则名称 :param name: 规则名称
:return: :return:
""" """
stmt = select(self.model).options(noload(self.model.roles)).order_by(desc(self.model.created_time)) stmt = select(self.model).options(noload(self.model.scope)).order_by(desc(self.model.created_time))
filters = [] filters = []
if name is not None: if name is not None:
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, DataScope
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam
class CRUDDataScope(CRUDPlus[DataScope]):
"""数据范围数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> DataScope | None:
"""
获取数据范围详情
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
return await self.select_model(db, pk)
async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None:
"""
通过名称获取数据范围
:param db: 数据库会话
:param name: 范围名称
:return:
"""
return await self.select_model_by_column(db, name=name)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope:
"""
获取数据范围关联数据
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
stmt = select(self.model).options(selectinload(self.model.rules)).where(self.model.id == pk)
data_scope = await db.execute(stmt)
return data_scope.scalars().first()
async def get_list(self, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表
:param name: 范围名称
:param status: 范围状态
:return:
"""
stmt = (
select(self.model)
.options(noload(self.model.rules), noload(self.model.roles))
.order_by(desc(self.model.created_time))
)
filters = []
if name is not None:
filters.append(self.model.name.like(f'%{name}%'))
if status is not None:
filters.append(self.model.status == status)
if filters:
stmt = stmt.where(and_(*filters))
return stmt
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
"""
创建数据范围
:param db: 数据库会话
:param obj: 创建数据范围参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
"""
更新数据范围
:param db: 数据库会话
:param pk: 范围 ID
:param obj: 更新数据范围参数
:return:
"""
return await self.update_model(db, pk, obj)
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:param rule_ids: 数据规则 ID 列表
:return:
"""
current_data_scope = await self.get_with_relation(db, pk)
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules))
rules = await db.execute(stmt)
current_data_scope.rules = rules.scalars().all()
return len(current_data_scope.rules)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除数据范围
:param db: 数据库会话
:param pk: 范围 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
+9 -5
View File
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from fastapi import Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -9,6 +10,7 @@ from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.security.permission import filter_data_permission
class CRUDDept(CRUDPlus[Dept]): class CRUDDept(CRUDPlus[Dept]):
@@ -36,15 +38,17 @@ class CRUDDept(CRUDPlus[Dept]):
async def get_all( async def get_all(
self, self,
request: Request,
db: AsyncSession, db: AsyncSession,
name: str | None = None, name: str | None,
leader: str | None = None, leader: str | None,
phone: str | None = None, phone: str | None,
status: int | None = None, status: int | None,
) -> Sequence[Dept]: ) -> Sequence[Dept]:
""" """
获取所有部门 获取所有部门
:param request: FastAPI 请求对象
:param db: 数据库会话 :param db: 数据库会话
:param name: 部门名称 :param name: 部门名称
:param leader: 负责人 :param leader: 负责人
@@ -61,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:
""" """
+1 -1
View File
@@ -11,7 +11,7 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam
class CRUDLoginLog(CRUDPlus[LoginLog]): class CRUDLoginLog(CRUDPlus[LoginLog]):
"""登录日志数据库操作类""" """登录日志数据库操作类"""
async def get_list(self, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取登录日志列表 获取登录日志列表
+3 -3
View File
@@ -32,9 +32,9 @@ 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 = None, status: int | None = None) -> Sequence[Menu]: async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
""" """
获取菜单列表 获取菜单列表
@@ -60,7 +60,7 @@ class CRUDMenu(CRUDPlus[Menu]):
:return: :return:
""" """
stmt = select(self.model).order_by(asc(self.model.sort)) stmt = select(self.model).order_by(asc(self.model.sort))
filters = [self.model.menu_type.in_([0, 1])] filters = [self.model.type.in_([0, 1])]
if not superuser: if not superuser:
filters.append(self.model.id.in_(menu_ids)) filters.append(self.model.id.in_(menu_ids))
stmt = stmt.where(and_(*filters)) stmt = stmt.where(and_(*filters))
+1 -1
View File
@@ -11,7 +11,7 @@ from backend.app.admin.schema.opera_log import CreateOperaLogParam
class CRUDOperaLogDao(CRUDPlus[OperaLog]): class CRUDOperaLogDao(CRUDPlus[OperaLog]):
"""操作日志数据库操作类""" """操作日志数据库操作类"""
async def get_list(self, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取操作日志列表 获取操作日志列表
+13 -13
View File
@@ -7,12 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, Menu, Role, User from backend.app.admin.model import DataScope, Menu, Role, User
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleRuleParam, UpdateRoleScopeParam,
) )
@@ -39,7 +39,7 @@ class CRUDRole(CRUDPlus[Role]):
""" """
stmt = ( stmt = (
select(self.model) select(self.model)
.options(selectinload(self.model.menus), selectinload(self.model.rules)) .options(selectinload(self.model.menus), selectinload(self.model.scopes))
.where(self.model.id == role_id) .where(self.model.id == role_id)
) )
role = await db.execute(stmt) role = await db.execute(stmt)
@@ -54,7 +54,7 @@ class CRUDRole(CRUDPlus[Role]):
""" """
return await self.select_models(db) return await self.select_models(db)
async def get_by_user(self, db: AsyncSession, user_id: int) -> Sequence[Role]: async def get_users(self, db: AsyncSession, user_id: int) -> Sequence[Role]:
""" """
获取用户角色列表 获取用户角色列表
@@ -66,7 +66,7 @@ class CRUDRole(CRUDPlus[Role]):
roles = await db.execute(stmt) roles = await db.execute(stmt)
return roles.scalars().all() return roles.scalars().all()
async def get_list(self, name: str | None = None, status: int | None = None) -> Select: async def get_list(self, name: str | None, status: int | None) -> Select:
""" """
获取角色列表 获取角色列表
@@ -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:
""" """
+4 -7
View File
@@ -142,7 +142,7 @@ class CRUDUser(CRUDPlus[User]):
:param avatar: 头像地址 :param avatar: 头像地址
:return: :return:
""" """
return await self.update_model(db, input_user, {'avatar': avatar.url}) 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:
""" """
@@ -175,9 +175,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, pk, {'password': new_pwd}) return await self.update_model(db, pk, {'password': new_pwd})
async def get_list( async def get_list(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
self, dept: int | None = None, username: str | None = None, phone: str | None = None, status: int | None = None
) -> Select:
""" """
获取用户列表 获取用户列表
@@ -191,8 +189,7 @@ class CRUDUser(CRUDPlus[User]):
select(self.model) select(self.model)
.options( .options(
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)), selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
noload(self.model.socials), selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.rules)),
) )
.order_by(desc(self.model.join_time)) .order_by(desc(self.model.join_time))
) )
@@ -313,7 +310,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
stmt = select(self.model).options( stmt = select(self.model).options(
selectinload(self.model.dept), selectinload(self.model.dept),
selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.rules)), selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes)),
) )
filters = [] filters = []
+1 -4
View File
@@ -1,13 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from backend.app.admin.model.config import Config
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.dict_data import DictData
from backend.app.admin.model.dict_type import DictType
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
+11 -9
View File
@@ -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='运算符(0and、1or') operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column( expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in' comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in'
) )
value: Mapped[str] = mapped_column(String(255), comment='规则值') value: Mapped[str] = mapped_column(String(255), comment='规则值')
# 角色规则对多 # 数据范围规则对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='rules') scope_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_data_scope.id', ondelete='SET NULL'), default=None, comment='数据范围关联 ID'
)
scope: Mapped[DataScope] = relationship(init=False, back_populates='rules')
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_scope
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Role
class DataScope(Base):
"""数据范围表"""
__tablename__ = 'sys_data_scope'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
# 数据范围规则一对多
rules: Mapped[list[DataRule]] = relationship(init=False, back_populates='scope')
# 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
+7 -7
View File
@@ -20,16 +20,16 @@ sys_role_menu = Table(
Column('menu_id', Integer, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'), Column('menu_id', Integer, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'),
) )
sys_role_data_rule = Table( sys_role_data_scope = Table(
'sys_role_data_rule', 'sys_role_data_scope',
MappedBase.metadata, MappedBase.metadata,
Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'), Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'),
Column( Column(
'data_rule_id', 'data_scope_id',
Integer, Integer,
ForeignKey('sys_data_rule.id', ondelete='CASCADE'), ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True, primary_key=True,
comment='数据权限规则ID', comment='数据范围 ID',
), ),
) )
+5 -2
View File
@@ -24,15 +24,18 @@ 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 | 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='菜单图标')
path: Mapped[str | None] = mapped_column(String(200), default=None, comment='路由地址') type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
menu_type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮)')
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正常)')
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)') display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)') cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='外链地址'
)
remark: Mapped[str | None] = mapped_column( remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注' LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注'
) )
+4 -4
View File
@@ -9,11 +9,11 @@ from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_rule, sys_role_menu, sys_user_role from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Menu, User from backend.app.admin.model import DataScope, Menu, User
class Role(Base): class Role(Base):
@@ -34,5 +34,5 @@ class Role(Base):
# 角色菜单多对多 # 角色菜单多对多
menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles') menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles')
# 角色数据权限规则多对多 # 角色数据范围多对多
rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='roles') scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
+1 -4
View File
@@ -15,7 +15,7 @@ from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role, UserSocial from backend.app.admin.model import Dept, Role
class User(Base): class User(Base):
@@ -55,8 +55,5 @@ class User(Base):
) )
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users') dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户社交信息一对多
socials: Mapped[list[UserSocial]] = relationship(init=False, back_populates='user')
# 用户角色多对多 # 用户角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users') roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
+7 -4
View File
@@ -14,7 +14,7 @@ class DataRuleSchemaBase(SchemaBase):
name: str = Field(description='规则名称') name: str = Field(description='规则名称')
model: str = Field(description='模型名称') model: str = Field(description='模型名称')
column: str = Field(description='字段名称') column: str = Field(description='字段名称')
operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.OR, description='操作符(AND/OR') operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.AND, description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型') expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型')
value: str = Field(description='规则值') value: str = Field(description='规则值')
@@ -36,6 +36,9 @@ class GetDataRuleDetail(DataRuleSchemaBase):
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间') updated_time: datetime | None = Field(None, description='更新时间')
def __hash__(self) -> int:
"""计算哈希值""" class GetDataRuleColumnDetail(SchemaBase):
return hash(self.name) """数据规则可用模型字段详情"""
key: str = Field(description='字段名')
comment: str = Field(description='字段评论')
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_rule import GetDataRuleDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DataScopeBase(SchemaBase):
"""数据范围基础模型"""
name: str = Field(description='名称')
status: StatusType = Field(StatusType.enable, description='状态')
class CreateDataScopeParam(DataScopeBase):
"""创建数据范围参数"""
class UpdateDataScopeParam(DataScopeBase):
"""更新数据范围参数"""
class UpdateDataScopeRuleParam(SchemaBase):
"""更新数据范围规则参数"""
rules: list[int] = Field(description='数据规则 ID 列表')
class GetDataScopeDetail(DataScopeBase):
"""数据范围详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='数据范围 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情"""
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
+3 -2
View File
@@ -13,16 +13,17 @@ class MenuSchemaBase(SchemaBase):
title: str = Field(description='菜单标题') title: str = Field(description='菜单标题')
name: str = Field(description='菜单名称') name: 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='图标')
path: str | None = Field(None, description='路由路径') type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
menu_type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)')
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='状态')
display: StatusType = Field(StatusType.enable, description='是否显示') display: StatusType = Field(StatusType.enable, description='是否显示')
cache: StatusType = Field(StatusType.enable, description='是否缓存') cache: StatusType = Field(StatusType.enable, description='是否缓存')
link: str | None = Field(None, description='外链地址')
remark: str | None = Field(None, description='备注') remark: str | None = Field(None, description='备注')
+5 -5
View File
@@ -4,7 +4,7 @@ from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_rule import GetDataRuleDetail from backend.app.admin.schema.data_scope import GetDataScopeDetail
from backend.app.admin.schema.menu import GetMenuDetail from backend.app.admin.schema.menu import GetMenuDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
@@ -32,10 +32,10 @@ class UpdateRoleMenuParam(SchemaBase):
menus: list[int] = Field(description='菜单 ID 列表') menus: list[int] = Field(description='菜单 ID 列表')
class UpdateRoleRuleParam(SchemaBase): class UpdateRoleScopeParam(SchemaBase):
"""更新角色规则参数""" """更新角色数据范围参数"""
rules: list[int] = Field(description='数据规则 ID 列表') scopes: list[int] = Field(description='数据范围 ID 列表')
class GetRoleDetail(RoleSchemaBase): class GetRoleDetail(RoleSchemaBase):
@@ -52,4 +52,4 @@ class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情""" """角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表') menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则详情列表') scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
-6
View File
@@ -35,12 +35,6 @@ class GetLoginToken(AccessTokenBase):
user: GetUserInfoDetail = Field(description='用户信息') user: GetUserInfoDetail = Field(description='用户信息')
class KickOutToken(SchemaBase):
"""踢出令牌"""
session_uuid: str = Field(description='会话 UUID')
class GetTokenDetail(SchemaBase): class GetTokenDetail(SchemaBase):
"""令牌详情""" """令牌详情"""
+23 -12
View File
@@ -5,7 +5,6 @@ from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTask, BackgroundTasks from starlette.background import BackgroundTask, BackgroundTasks
from backend.app.admin.conf import admin_settings
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken
@@ -33,7 +32,7 @@ class AuthService:
"""认证服务类""" """认证服务类"""
@staticmethod @staticmethod
async def user_verify(db: AsyncSession, username: str, password: str) -> User: async def user_verify(db: AsyncSession, username: str, password: str | None) -> User:
""" """
验证用户名和密码 验证用户名和密码
@@ -45,10 +44,16 @@ class AuthService:
user = await user_dao.get_by_username(db, username) user = await user_dao.get_by_username(db, username)
if not user: if not user:
raise errors.NotFoundError(msg='用户名或密码有误') raise errors.NotFoundError(msg='用户名或密码有误')
elif not password_verify(password, user.password):
if user.password is None:
raise errors.AuthorizationError(msg='用户名或密码有误') raise errors.AuthorizationError(msg='用户名或密码有误')
elif not user.status: else:
if not password_verify(password, user.password):
raise errors.AuthorizationError(msg='用户名或密码有误')
if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
return user return user
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]: async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]:
@@ -65,7 +70,7 @@ class AuthService:
str(user.id), str(user.id),
user.is_multi_login, user.is_multi_login,
# extra info # extra info
login_type='swagger', swagger=True,
) )
return a_token.access_token, user return a_token.access_token, user
@@ -85,12 +90,12 @@ class AuthService:
user = None user = None
try: try:
user = await self.user_verify(db, obj.username, obj.password) user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
if not captcha_code: if not captcha_code:
raise errors.AuthorizationError(msg='验证码失效,请重新获取') raise errors.AuthorizationError(msg='验证码失效,请重新获取')
if captcha_code.lower() != obj.captcha.lower(): if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
await db.refresh(user) await db.refresh(user)
a_token = await create_access_token( a_token = await create_access_token(
@@ -206,11 +211,17 @@ class AuthService:
:param response: FastAPI 响应对象 :param response: FastAPI 响应对象
:return: :return:
""" """
token = get_token(request) try:
token_payload = jwt_decode(token) token = get_token(request)
user_id = token_payload.id token_payload = jwt_decode(token)
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) user_id = token_payload.id
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
except errors.TokenError:
return
finally:
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
# 清理缓存
if request.user.is_multi_login: if request.user.is_multi_login:
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}')
if refresh_token: if refresh_token:
+17 -31
View File
@@ -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,44 +30,32 @@ 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
@staticmethod @staticmethod
async def get_select(*, name: str | None = None) -> Select: async def get_select(*, name: str | None) -> Select:
""" """
获取数据规则列表查询条件 获取数据规则列表查询条件
@@ -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()
+7 -7
View File
@@ -33,11 +33,12 @@ class DeptService:
@staticmethod @staticmethod
async def get_dept_tree( async def get_dept_tree(
*, name: str | None = None, leader: str | None = None, phone: str | None = None, status: int | None = 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
@@ -16,7 +16,7 @@ class LoginLogService:
"""登录日志服务类""" """登录日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取登录日志列表查询条件 获取登录日志列表查询条件
+12 -24
View File
@@ -5,14 +5,13 @@ 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
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 from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
class MenuService: class MenuService:
@@ -33,7 +32,7 @@ class MenuService:
return menu return menu
@staticmethod @staticmethod
async def get_menu_tree(*, title: str | None = None, status: int | None = None) -> list[dict[str, Any]]: async def get_menu_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]:
""" """
获取菜单树形结构 获取菜单树形结构
@@ -46,23 +45,6 @@ class MenuService:
menu_tree = get_tree_data(menu_select) menu_tree = get_tree_data(menu_select)
return menu_tree return menu_tree
@staticmethod
async def get_role_menu_tree(*, pk: int) -> list[dict[str, Any]]:
"""
获取角色的菜单树形结构
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menu_ids = [menu.id for menu in role.menus]
menu_select = await menu_dao.get_role_menus(db, False, menu_ids)
menu_tree = get_tree_data(menu_select)
return menu_tree
@staticmethod @staticmethod
async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]: async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]:
""" """
@@ -79,7 +61,7 @@ class MenuService:
for role in roles: for role in roles:
menu_ids.extend([menu.id for menu in role.menus]) menu_ids.extend([menu.id for menu in role.menus])
menu_select = await menu_dao.get_role_menus(db, request.user.is_superuser, menu_ids) menu_select = await menu_dao.get_role_menus(db, request.user.is_superuser, menu_ids)
menu_tree = get_tree_data(menu_select) menu_tree = get_vben5_tree_data(menu_select)
return menu_tree return menu_tree
@staticmethod @staticmethod
@@ -123,14 +105,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 +122,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
@@ -11,7 +11,7 @@ class OperaLogService:
"""操作日志服务类""" """操作日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取操作日志列表查询条件 获取操作日志列表查询条件
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import json
import os
import shutil
import zipfile
from typing import Any
from dulwich import porcelain
from fastapi import UploadFile
from backend.common.enums import StatusType
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client
from backend.plugin.tools import install_requirements_async, uninstall_requirements_async
from backend.utils.re_verify import is_git_url
from backend.utils.timezone import timezone
class PluginService:
"""插件服务类"""
@staticmethod
async def get_all() -> list[dict[str, Any]]:
"""获取所有插件"""
keys = []
result = []
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'):
keys.append(key)
for info in await redis_client.mget(*keys):
result.append(json.loads(info))
return result
@staticmethod
async def changed() -> str | None:
"""插件状态是否变更"""
return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
@staticmethod
async def install_zip(*, file: UploadFile) -> None:
"""
通过 zip 压缩包安装插件
:param file: 插件 zip 压缩包
:return:
"""
contents = await file.read()
file_bytes = io.BytesIO(contents)
if not zipfile.is_zipfile(file_bytes):
raise errors.ForbiddenError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_dir = file.filename[:-4]
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir)]
if not members_in_plugin_dir:
raise errors.ForbiddenError(msg='插件压缩包内容非法')
plugin_name = (
members_in_plugin_dir[0]
.replace('/', '')
.replace('-master', '')
.replace('-main', '')
.replace('-dev', '')
)
if (
len(members_in_plugin_dir) <= 3
or f'{plugin_dir}/plugin.toml' not in members_in_plugin_dir
or f'{plugin_dir}/README.md' not in members_in_plugin_dir
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir):
new_filename = member.filename.replace(plugin_dir, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(os.path.join(PLUGIN_DIR, plugin_name), members)
await install_requirements_async(plugin_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def install_git(*, repo_url: str):
"""
通过 git 安装插件
:param repo_url: git 存储库的 URL
:return:
"""
match = is_git_url(repo_url)
if not match:
raise errors.ForbiddenError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
if repo_name in plugins:
raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
except Exception as e:
log.error(f'插件安装失败: {e}')
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
else:
await install_requirements_async(repo_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def uninstall(*, plugin: str):
"""
卸载插件
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
shutil.move(plugin_dir, bacup_dir)
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
await redis_client.hdel(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def update_status(*, plugin: str):
"""
更新插件状态
:param plugin: 插件名称
:return:
"""
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
if not plugin_info:
raise errors.ForbiddenError(msg='插件不存在')
plugin_info = json.loads(plugin_info)
# 更新持久缓存状态
new_status = (
str(StatusType.enable.value)
if plugin_info['plugin']['enable'] == str(StatusType.disable.value)
else str(StatusType.disable.value)
)
plugin_info['plugin']['enable'] = new_status
await redis_client.set(
f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False)
)
await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, new_status)
@staticmethod
async def build(*, plugin: str) -> io.BytesIO:
"""
打包插件为 zip 压缩包
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, arcname)
bio.seek(0)
return bio
plugin_service: PluginService = PluginService()
+61 -26
View File
@@ -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,11 +54,11 @@ 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
async def get_select(*, name: str | None = None, status: int | None = None) -> Select: async def get_select(*, name: str | None, status: int | None) -> Select:
""" """
获取角色列表查询条件 获取角色列表查询条件
@@ -68,6 +68,38 @@ class RoleService:
""" """
return await role_dao.get_list(name=name, status=status) return await role_dao.get_list(name=name, status=status)
@staticmethod
async def get_menu_tree(*, pk: int) -> list[dict[str, Any]]:
"""
获取角色的菜单树形结构
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menu_ids = [menu.id for menu in role.menus]
menu_select = await menu_dao.get_role_menus(db, False, menu_ids)
menu_tree = get_tree_data(menu_select)
return menu_tree
@staticmethod
async def get_scopes(*, pk: int) -> list[int]:
"""
获取角色数据范围列表
:param pk:
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
scope_ids = [scope.id for scope in role.scopes]
return scope_ids
@staticmethod @staticmethod
async def create(*, obj: CreateRoleParam) -> None: async def create(*, obj: CreateRoleParam) -> None:
""" """
@@ -100,20 +132,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 +154,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
+31 -41
View File
@@ -135,25 +135,25 @@ 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) count = await user_dao.update_userinfo(db, user.id, obj)
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
@@ -168,7 +168,7 @@ class UserService:
""" """
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 not request.user.is_superuser and request.user.username != username:
raise errors.AuthorizationError raise errors.ForbiddenError(msg='你只能修改自己的信息')
input_user = await user_dao.get_with_relation(db, username=username) input_user = await user_dao.get_with_relation(db, username=username)
if not input_user: if not input_user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
@@ -190,17 +190,17 @@ 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.AuthorizationError raise errors.AuthorizationError(msg='你只能修改自己的信息')
input_user = await user_dao.get_by_username(db, username) user = await user_dao.get_by_username(db, username)
if not input_user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.update_avatar(db, input_user.id, avatar) count = await user_dao.update_avatar(db, user.id, avatar)
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 get_select(*, dept: int, username: str = None, phone: str = None, status: int = None) -> Select: async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select:
""" """
获取用户列表查询条件 获取用户列表查询条件
@@ -230,7 +230,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 +251,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 +272,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 +289,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
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.generator.api.v1.gen import router as gen_router
from backend.app.generator.api.v1.gen_business import router as gen_business_router
from backend.app.generator.api.v1.gen_model import router as gen_model_router
from backend.core.conf import settings
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成'])
v1.include_router(gen_router)
v1.include_router(gen_business_router, prefix='/businesses')
v1.include_router(gen_model_router, prefix='/models')
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
class GeneratorSettings(BaseSettings):
"""代码生成配置"""
# 模版
TEMPLATE_BACKEND_DIR_NAME: str = 'py'
# 代码下载
DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
@lru_cache
def get_generator_settings() -> GeneratorSettings:
"""获取代码生成配置"""
return GeneratorSettings()
generator_settings = get_generator_settings()
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.app.generator.model.gen_business import GenBusiness
from backend.app.generator.model.gen_model import GenModel
-2
View File
@@ -3,11 +3,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.router import v1 as admin_v1 from backend.app.admin.api.router import v1 as admin_v1
from backend.app.generator.api.router import v1 as generator_v1
from backend.app.task.api.router import v1 as task_v1 from backend.app.task.api.router import v1 as task_v1
router = APIRouter() router = APIRouter()
router.include_router(admin_v1) router.include_router(admin_v1)
router.include_router(generator_v1)
router.include_router(task_v1) router.include_router(task_v1)
+2 -2
View File
@@ -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=['任务'])
+9 -10
View File
@@ -5,7 +5,6 @@ from typing import Any
import celery import celery
import celery_aio_pool import celery_aio_pool
from backend.app.task.conf import task_settings
from backend.core.conf import settings from backend.core.conf import settings
__all__ = ['celery_app'] __all__ = ['celery_app']
@@ -13,14 +12,14 @@ __all__ = ['celery_app']
def get_broker_url() -> str: def get_broker_url() -> str:
"""获取消息代理 URL""" """获取消息代理 URL"""
if task_settings.CELERY_BROKER == 'redis': if settings.CELERY_BROKER == 'redis':
return ( return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
) )
return ( return (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@' f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}' f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
) )
@@ -28,16 +27,16 @@ def get_result_backend() -> str:
"""获取结果后端 URL""" """获取结果后端 URL"""
return ( return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BACKEND_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BACKEND_REDIS_DATABASE}'
) )
def get_result_backend_transport_options() -> dict[str, Any]: def get_result_backend_transport_options() -> dict[str, Any]:
"""获取结果后端传输选项""" """获取结果后端传输选项"""
return { return {
'global_keyprefix': task_settings.CELERY_BACKEND_REDIS_PREFIX, 'global_keyprefix': settings.CELERY_BACKEND_REDIS_PREFIX,
'retry_policy': { 'retry_policy': {
'timeout': task_settings.CELERY_BACKEND_REDIS_TIMEOUT, 'timeout': settings.CELERY_BACKEND_REDIS_TIMEOUT,
}, },
} }
@@ -55,7 +54,7 @@ def init_celery() -> celery.Celery:
'fba_celery', 'fba_celery',
enable_utc=False, enable_utc=False,
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
beat_schedule=task_settings.CELERY_SCHEDULE, beat_schedule=settings.CELERY_SCHEDULE,
broker_url=get_broker_url(), broker_url=get_broker_url(),
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
result_backend=get_result_backend(), result_backend=get_result_backend(),
@@ -65,7 +64,7 @@ def init_celery() -> celery.Celery:
) )
# 自动发现任务 # 自动发现任务
app.autodiscover_tasks(task_settings.CELERY_TASK_PACKAGES) app.autodiscover_tasks(settings.CELERY_TASK_PACKAGES)
return app return app
+7 -4
View File
@@ -1,19 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import asyncio
from typing import Any 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:
""" """
@@ -34,7 +36,7 @@ class TaskBase(Task):
""" """
await task_notification(msg=f'任务 {task_id} 执行成功') await task_notification(msg=f'任务 {task_id} 执行成功')
async def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None: def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None:
""" """
任务失败后执行钩子 任务失败后执行钩子
@@ -43,4 +45,5 @@ class TaskBase(Task):
:param einfo: 异常信息 :param einfo: 异常信息
:return: :return:
""" """
await task_notification(msg=f'任务 {task_id} 执行失败') loop = asyncio.get_event_loop()
loop.create_task(task_notification(msg=f'任务 {task_id} 执行失败'))
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from typing import Any, Literal
from celery.schedules import crontab
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BASE_PATH
class TaskSettings(BaseSettings):
"""Celery 任务配置"""
model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# .env 环境
ENVIRONMENT: Literal['dev', 'pro']
# .env Redis 配置
CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int
# .env RabbitMQ 配置
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
RABBITMQ_HOST: str
RABBITMQ_PORT: int
RABBITMQ_USERNAME: str
RABBITMQ_PASSWORD: str
# Celery 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5
CELERY_TASK_PACKAGES: list[str] = [
'app.task.celery_task',
'app.task.celery_task.db_log',
]
CELERY_TASK_MAX_RETRIES: int = 5
# Celery 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': {
'task': 'task_demo_async',
'schedule': 10,
},
'exec-every-sunday': {
'task': 'delete_db_opera_log',
'schedule': crontab('0', '0', day_of_week='6'),
},
'exec-every-15-of-month': {
'task': 'delete_db_login_log',
'schedule': crontab('0', '0', day_of_month='15'),
},
}
@model_validator(mode='before')
@classmethod
def validate_celery_broker(cls, values: Any) -> Any:
"""生产环境强制使用 RabbitMQ 作为消息代理"""
if values['ENVIRONMENT'] == 'pro':
values['CELERY_BROKER'] = 'rabbitmq'
return values
@lru_cache
def get_task_settings() -> TaskSettings:
"""获取 Celery 任务配置"""
return TaskSettings()
task_settings = get_task_settings()
+3 -3
View File
@@ -19,11 +19,11 @@ class TaskResult(SchemaBase):
"""任务执行结果""" """任务执行结果"""
result: str = Field(description='任务执行结果') result: str = Field(description='任务执行结果')
traceback: str = Field(description='错误堆栈信息') traceback: str | None = Field(None, description='错误堆栈信息')
status: str = Field(description='任务状态') status: str = Field(description='任务状态')
name: str = Field(description='任务名称') name: str | None = Field(None, description='任务名称')
args: list[Any] | None = Field(None, description='任务函数位置参数') args: list[Any] | None = Field(None, description='任务函数位置参数')
kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数') kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
worker: str = Field(description='执行任务的 worker') worker: str | None = Field(None, description='执行任务的 worker')
retries: int | None = Field(None, description='重试次数') retries: int | None = Field(None, description='重试次数')
queue: str | None = Field(None, description='任务队列') queue: str | None = Field(None, description='任务队列')
+4 -152
View File
@@ -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 # !=
@@ -119,153 +121,3 @@ class FileType(StrEnum):
image = 'image' image = 'image'
video = 'video' video = 'video'
class GenModelMySQLColumnType(StrEnum):
"""代码生成模型列类型(MySQL"""
# Python 类型映射
BIGINT = 'int'
BigInteger = 'int' # BIGINT
BINARY = 'bytes'
BLOB = 'bytes'
BOOLEAN = 'bool' # BOOL
Boolean = 'bool' # BOOL
CHAR = 'str'
CLOB = 'str'
DATE = 'date'
Date = 'date' # DATE
DATETIME = 'datetime'
DateTime = 'datetime' # DATETIME
DECIMAL = 'Decimal'
DOUBLE = 'float'
Double = 'float' # DOUBLE
DOUBLE_PRECISION = 'float'
Enum = 'Enum' # Enum()
FLOAT = 'float'
Float = 'float' # FLOAT
INT = 'int' # INTEGER
INTEGER = 'int'
Integer = 'int' # INTEGER
Interval = 'timedelta' # DATETIME
JSON = 'dict'
LargeBinary = 'bytes' # BLOB
NCHAR = 'str'
NUMERIC = 'Decimal'
Numeric = 'Decimal' # NUMERIC
NVARCHAR = 'str' # String
PickleType = 'bytes' # BLOB
REAL = 'float'
SMALLINT = 'int'
SmallInteger = 'int' # SMALLINT
String = 'str' # String
TEXT = 'str'
Text = 'str' # TEXT
TIME = 'time'
Time = 'time' # TIME
TIMESTAMP = 'datetime'
Unicode = 'str' # String
UnicodeText = 'str' # TEXT
UUID = 'str | UUID'
Uuid = 'str' # CHAR(32)
VARBINARY = 'bytes'
VARCHAR = 'str' # String
# sa.dialects.mysql 导入
BIT = 'bool'
ENUM = 'Enum'
LONGBLOB = 'bytes'
LONGTEXT = 'str'
MEDIUMBLOB = 'bytes'
MEDIUMINT = 'int'
MEDIUMTEXT = 'str'
SET = 'list[str]'
TINYBLOB = 'bytes'
TINYINT = 'int'
TINYTEXT = 'str'
YEAR = 'int'
class GenModelPostgreSQLColumnType(StrEnum):
"""代码生成模型列类型(PostgreSQL)"""
# Python 类型映射
BIGINT = 'int'
BigInteger = 'int' # BIGINT
BINARY = 'bytes'
BLOB = 'bytes'
BOOLEAN = 'bool'
Boolean = 'bool' # BOOLEAN
CHAR = 'str'
CLOB = 'str'
DATE = 'date'
Date = 'date' # DATE
DATETIME = 'datetime'
DateTime = 'datetime' # TIMESTAMP WITHOUT TIME ZONE
DECIMAL = 'Decimal'
DOUBLE = 'float'
Double = 'float' # DOUBLE PRECISION
DOUBLE_PRECISION = 'float' # DOUBLE PRECISION
Enum = 'Enum' # Enum(name='enum')
FLOAT = 'float'
Float = 'float' # FLOAT
INT = 'int' # INTEGER
INTEGER = 'int'
Integer = 'int' # INTEGER
Interval = 'timedelta' # INTERVAL
JSON = 'dict'
LargeBinary = 'bytes' # BYTEA
NCHAR = 'str'
NUMERIC = 'Decimal'
Numeric = 'Decimal' # NUMERIC
NVARCHAR = 'str' # String
PickleType = 'bytes' # BYTEA
REAL = 'float'
SMALLINT = 'int'
SmallInteger = 'int' # SMALLINT
String = 'str' # String
TEXT = 'str'
Text = 'str' # TEXT
TIME = 'time' # TIME WITHOUT TIME ZONE
Time = 'time' # TIME WITHOUT TIME ZONE
TIMESTAMP = 'datetime' # TIMESTAMP WITHOUT TIME ZONE
Unicode = 'str' # String
UnicodeText = 'str' # TEXT
UUID = 'str | UUID'
Uuid = 'str'
VARBINARY = 'bytes'
VARCHAR = 'str' # String
# sa.dialects.postgresql 导入
ARRAY = 'list'
BIT = 'bool'
BYTEA = 'bytes'
CIDR = 'str'
CITEXT = 'str'
DATEMULTIRANGE = 'list[date]'
DATERANGE = 'tuple[date, date]'
DOMAIN = 'str'
ENUM = 'Enum'
HSTORE = 'dict'
INET = 'str'
INT4MULTIRANGE = 'list[int]'
INT4RANGE = 'tuple[int, int]'
INT8MULTIRANGE = 'list[int]'
INT8RANGE = 'tuple[int, int]'
INTERVAL = 'timedelta'
JSONB = 'dict'
JSONPATH = 'str'
MACADDR = 'str'
MACADDR8 = 'str'
MONEY = 'Decimal'
NUMMULTIRANGE = 'list[Decimal]'
NUMRANGE = 'tuple[Decimal, Decimal]'
OID = 'int'
REGCLASS = 'str'
REGCONFIG = 'str'
TSMULTIRANGE = 'list[datetime]'
TSQUERY = 'str'
TSRANGE = 'tuple[datetime, datetime]'
TSTZMULTIRANGE = 'list[datetime]'
TSTZRANGE = 'tuple[datetime, datetime]'
TSVECTOR = 'str'
+4 -4
View File
@@ -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
@@ -24,7 +24,7 @@ class _CustomPageParams(BaseModel, AbstractParams):
"""自定义分页参数""" """自定义分页参数"""
page: int = Query(1, ge=1, description='页码') page: int = Query(1, ge=1, description='页码')
size: int = Query(20, gt=0, le=100, description='每页数量') size: int = Query(20, gt=0, le=200, description='每页数量')
def to_raw_params(self) -> RawParams: def to_raw_params(self) -> RawParams:
return RawParams( return RawParams(
@@ -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
+4
View File
@@ -39,6 +39,10 @@ class CustomResponseCode(CustomCodeBase):
HTTP_503 = (503, '服务器暂时无法处理请求') HTTP_503 = (503, '服务器暂时无法处理请求')
HTTP_504 = (504, '网关超时') HTTP_504 = (504, '网关超时')
# Plugin
PLUGIN_INSTALL_SUCCESS = (200, '插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务')
PLUGIN_UNINSTALL_SUCCESS = (200, '插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
class CustomErrorCode(CustomCodeBase): class CustomErrorCode(CustomCodeBase):
"""自定义错误状态码""" """自定义错误状态码"""
+4 -2
View File
@@ -40,7 +40,7 @@ class ResponseModel(BaseModel):
class ResponseSchemaModel(ResponseModel, Generic[SchemaT]): class ResponseSchemaModel(ResponseModel, Generic[SchemaT]):
""" """
包含返回数据 schema 的通用型统一返回模型仅适用于非分页接口 包含返回数据 schema 的通用型统一返回模型
示例:: 示例::
@@ -68,7 +68,9 @@ class ResponseBase:
@staticmethod @staticmethod
def __response( def __response(
*, res: CustomResponseCode | CustomResponse = None, data: Any | None = None *,
res: CustomResponseCode | CustomResponse,
data: Any | None,
) -> ResponseModel | ResponseSchemaModel: ) -> ResponseModel | ResponseSchemaModel:
""" """
请求返回通用方法 请求返回通用方法
+96 -97
View File
@@ -7,106 +7,105 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
from backend.core.conf import settings from backend.core.conf import settings
# 自定义验证错误信息 # 自定义验证错误信息,参考:
# 不包含验证预期内容(也就是输入内容),受支持的预期内容字段参考以下链接
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 # https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
# 替换预期内容字段方式,参考以下链接
# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232 # https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232
CUSTOM_VALIDATION_ERROR_MESSAGES = { CUSTOM_VALIDATION_ERROR_MESSAGES = {
'arguments_type': '参数类型输入错误', 'no_such_attribute': "对象没有属性 '{attribute}'",
'assertion_error': '断言执行错误', 'json_invalid': '无效的 JSON: {error}',
'bool_parsing': '布尔值输入解析错误', 'json_type': 'JSON 输入应为字符串、字节或字节数组',
'bool_type': '布尔值类型输入错误', 'recursion_loop': '递归错误 - 检测到循环引用',
'bytes_too_long': '字节长度输入过长', 'model_type': '输入应为有效的字典或 {class_name} 的实例',
'bytes_too_short': '字节长度输入过短', 'model_attributes_type': '输入应为有效的字典或可提取字段的对象',
'bytes_type': '字节类型输入错误', 'dataclass_exact_type': '输入应为 {class_name} 的实例',
'callable_type': '可调用对象类型输入错误', 'dataclass_type': '输入应为字典或 {class_name} 的实例',
'dataclass_exact_type': '数据类实例类型输入错误', 'missing': '字段为必填项',
'dataclass_type': '数据类类型输入错误', 'frozen_field': '字段已冻结',
'date_from_datetime_inexact': '日期分量输入非零', 'frozen_instance': '实例已冻结',
'date_from_datetime_parsing': '日期输入解析错误', 'extra_forbidden': '不允许额外的输入',
'date_future': '日期输入非将来时', 'invalid_key': '键应为字符串',
'date_parsing': '日期输入验证错误', 'get_attribute_error': '提取属性时出错: {error}',
'date_past': '日期输入非过去时', 'none_required': '输入应为 None',
'date_type': '日期类型输入错误', 'enum': '输入应为 {expected}',
'datetime_future': '日期时间输入非将来时间', 'greater_than': '输入应大于 {gt}',
'datetime_object_invalid': '日期时间输入对象无效', 'greater_than_equal': '输入应大于或等于 {ge}',
'datetime_parsing': '日期时间输入解析错误', 'less_than': '输入应小于 {lt}',
'datetime_past': '日期时间输入非过去时间', 'less_than_equal': '输入应小于或等于 {le}',
'datetime_type': '日期时间类型输入错误', 'finite_number': '输入应为有限数字',
'decimal_max_digits': '小数位数输入过多', 'too_short': '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}',
'decimal_max_places': '小数位数输入错误', 'too_long': '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}',
'decimal_parsing': '小数输入解析错误', 'string_type': '输入应为有效的字符串',
'decimal_type': '小数类型输入错误', 'string_sub_type': '输入应为字符串,而不是 str 子类的实例',
'decimal_whole_digits': '小数位数输入错误', 'string_unicode': '输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串',
'dict_type': '字典类型输入错误', 'string_pattern_mismatch': "字符串应匹配模式 '{pattern}'",
'enum': '枚举成员输入错误,允许 {expected}', 'string_too_short': '字符串应至少有 {min_length} 个字符',
'extra_forbidden': '禁止额外字段输入', 'string_too_long': '字符串最多应有 {max_length} 个字符',
'finite_number': '有限值输入错误', 'dict_type': '输入应为有效的字典',
'float_parsing': '浮点数输入解析错误', 'mapping_type': '输入应为有效的映射,错误: {error}',
'float_type': '浮点数类型输入错误', 'iterable_type': '输入应为可迭代对象',
'frozen_field': '冻结字段输入错误', 'iteration_error': '迭代对象时出错,错误: {error}',
'frozen_instance': '冻结实例禁止修改', 'list_type': '输入应为有效的列表',
'frozen_set_type': '冻结类型禁止输入', 'tuple_type': '输入应为有效的元组',
'get_attribute_error': '获取属性错误', 'set_type': '输入应为有效的集合',
'greater_than': '输入值过大', 'bool_type': '输入应为有效的布尔',
'greater_than_equal': '输入值过大或相等', 'bool_parsing': '输入应为有效的布尔值,无法解释输入',
'int_from_float': '整数类型输入错误', 'int_type': '输入应为有效的整数',
'int_parsing': '整数输入解析错误', 'int_parsing': '输入应为有效的整数,无法将字符串解析为整数',
'int_parsing_size': '整数输入解析长度错误', 'int_parsing_size': '无法将输入字符串解析为整数,超出最大大小',
'int_type': '整数类型输入错误', 'int_from_float': '输入应为有效的整数,得到一个带有小数部分的数字',
'invalid_key': '输入无效键值', 'multiple_of': '输入应为 {multiple_of} 的倍数',
'is_instance_of': '类型实例输入错误', 'float_type': '输入应为有效的数字',
'is_subclass_of': '类型子类输入错误', 'float_parsing': '输入应为有效的数字,无法将字符串解析为数字',
'iterable_type': '可迭代类型输入错误', 'bytes_type': '输入应为有效的字节',
'iteration_error': '迭代值输入错误', 'bytes_too_short': '数据应至少有 {min_length} 个字节',
'json_invalid': 'JSON 字符串输入错误', 'bytes_too_long': '数据最多应有 {max_length} 个字节',
'json_type': 'JSON 类型输入错误', 'value_error': '值错误,{error}',
'less_than': '输入值过小', 'assertion_error': '断言失败,{error}',
'less_than_equal': '输入值过小或相等', 'literal_error': '输入应为 {expected}',
'list_type': '列表类型输入错误', 'date_type': '输入应为有效的日期',
'literal_error': '字面值输入错误', 'date_parsing': '输入应为 YYYY-MM-DD 格式的有效日期,{error}',
'mapping_type': '映射类型输入错误', 'date_from_datetime_parsing': '输入应为有效的日期或日期时间,{error}',
'missing': '缺少必填字段', 'date_from_datetime_inexact': '提供给日期的日期时间应具有零时间 - 例如为精确日期',
'missing_argument': '缺少参数', 'date_past': '日期应为过去的时间',
'missing_keyword_only_argument': '缺少关键字参数', 'date_future': '日期应为未来的时间',
'missing_positional_only_argument': '缺少位置参数', 'time_type': '输入应为有效的时间',
'model_attributes_type': '模型属性类型输入错误', 'time_parsing': '输入应为有效的时间格式,{error}',
'model_type': '模型实例输入错误', 'datetime_type': '输入应为有效的日期时间',
'multiple_argument_values': '参数值输入过多', 'datetime_parsing': '输入应为有效的日期时间,{error}',
'multiple_of': '输入值非倍数', 'datetime_object_invalid': '无效的日期时间对象,得到 {error}',
'no_such_attribute': '分配无效属性值', 'datetime_past': '输入应为过去的时间',
'none_required': '输入值必须为 None', 'datetime_future': '输入应为未来的时间',
'recursion_loop': '输入循环赋值', 'timezone_naive': '输入不应包含时区信息',
'set_type': '集合类型输入错误', 'timezone_aware': '输入应包含时区信息',
'string_pattern_mismatch': '字符串约束模式输入不匹配', 'timezone_offset': '需要时区偏移为 {tz_expected},实际得到 {tz_actual}',
'string_sub_type': '字符串子类型(非严格实例)输入错误', 'time_delta_type': '输入应为有效的时间差',
'string_too_long': '字符串输入过长', 'time_delta_parsing': '输入应为有效的时间差,{error}',
'string_too_short': '字符串输入过短', 'frozen_set_type': '输入应为有效的冻结集合',
'string_type': '字符串类型输入错误', 'is_instance_of': '输入应为 {class} 的实例',
'string_unicode': '字符串输入非 Unicode', 'is_subclass_of': '输入应为 {class} 的子类',
'time_delta_parsing': '时间差输入解析错误', 'callable_type': '输入应为可调用对象',
'time_delta_type': '时间差类型输入错误', 'union_tag_invalid': "使用 {discriminator} 找到的输入标签 '{tag}' 与任何预期标签不匹配: {expected_tags}",
'time_parsing': '时间输入解析错误', 'union_tag_not_found': '无法使用区分器 {discriminator} 提取标签',
'time_type': '时间类型输入错误', 'arguments_type': '参数必须是元组、列表或字典',
'timezone_aware': '缺少时区输入信息', 'missing_argument': '缺少必需参数',
'timezone_naive': '禁止时区输入信息', 'unexpected_keyword_argument': '意外的关键字参数',
'too_long': '输入过长', 'missing_keyword_only_argument': '缺少必需的关键字专用参数',
'too_short': '输入过短', 'unexpected_positional_argument': '意外的位置参数',
'tuple_type': '元组类型输入错误', 'missing_positional_only_argument': '缺少必需的位置专用参数',
'unexpected_keyword_argument': '输入意外关键字参数', 'multiple_argument_values': '为参数提供了多个值',
'unexpected_positional_argument': '输入意外位置参数', 'url_type': 'URL 输入应为字符串或 URL',
'union_tag_invalid': '联合类型字面值输入错误', 'url_parsing': '输入应为有效的 URL{error}',
'union_tag_not_found': '联合类型参数输入未找到', 'url_syntax_violation': '输入违反了严格的 URL 语法规则,{error}',
'url_parsing': 'URL 输入解析错误', 'url_too_long': 'URL 最多应有 {max_length} 个字符',
'url_scheme': 'URL 输入方案错误', 'url_scheme': 'URL 方案应为 {expected_schemes}',
'url_syntax_violation': 'URL 输入语法错误', 'uuid_type': 'UUID 输入应为字符串、字节或 UUID 对象',
'url_too_long': 'URL 输入过长', 'uuid_parsing': '输入应为有效的 UUID{error}',
'url_type': 'URL 类型输入错误', 'uuid_version': '预期 UUID 版本为 {expected_version}',
'uuid_parsing': 'UUID 输入解析错误', 'decimal_type': '十进制输入应为整数、浮点数、字符串或 Decimal 对象',
'uuid_type': 'UUID 类型输入错误', 'decimal_parsing': '输入应为有效的十进制数',
'uuid_version': 'UUID 版本类型输入错误', 'decimal_max_digits': '十进制输入总共应不超过 {max_digits} 位数字',
'value_error': '值输入错误', 'decimal_max_places': '十进制输入应不超过 {decimal_places} 位小数',
'decimal_whole_digits': '十进制输入在小数点前应不超过 {whole_digits} 位数字',
} }
CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')] CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
+54 -35
View File
@@ -4,7 +4,9 @@ from typing import TYPE_CHECKING
from fastapi import Request from fastapi import Request
from sqlalchemy import ColumnElement, and_, or_ from sqlalchemy import ColumnElement, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.exception.errors import ServerError from backend.common.exception.errors import ServerError
@@ -12,7 +14,7 @@ from backend.core.conf import settings
from backend.utils.import_parse import dynamic_import_data_model from backend.utils.import_parse import dynamic_import_data_model
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.schema.data_rule import GetDataRuleDetail from backend.app.admin.model import DataRule
class RequestPermission: class RequestPermission:
@@ -47,33 +49,48 @@ class RequestPermission:
request.state.permission = self.value request.state.permission = self.value
def filter_data_permission(request: Request) -> ColumnElement[bool]: async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnElement[bool]:
""" """
过滤数据权限控制用户可见数据范围 过滤数据权限控制用户可见数据范围
使用场景 使用场景
- 用户登录前台后控制其能看到哪些数据 - 控制用户能看到哪些数据
- 根据用户角色和规则过滤数据访问权限
:param db: 数据库会话
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
# 获取用户角色和规则 # 获取用户角色和数据范围
data_rules = [] data_scopes = []
for role in request.user.roles: for role in request.user.roles:
data_rules.extend(role.rules) for scope in role.scopes:
user_data_rules: list[GetDataRuleDetail] = list(dict.fromkeys(data_rules)) if scope.status:
data_scopes.append(scope)
# 超级管理员和无规则用户不做过滤 # 超级管理员和无规则用户不做过滤
if request.user.is_superuser or not user_data_rules: if request.user.is_superuser or not data_scopes:
return or_(1 == 1) return or_(1 == 1)
# 获取数据范围规则
data_rule_list: list[DataRule] = []
for data_scope in data_scopes:
data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope.id)
data_rule_list.extend(data_scope_with_relation.rules)
# 去重
seen_data_rule_ids = set()
new_data_rule_list = []
for rule in data_rule_list:
if rule.id not in seen_data_rule_ids:
seen_data_rule_ids.add(rule.id)
new_data_rule_list.append(rule)
where_and_list = [] where_and_list = []
where_or_list = [] where_or_list = []
for rule in user_data_rules: for data_rule in new_data_rule_list:
# 验证规则模型 # 验证规则模型
rule_model = rule.model rule_model = data_rule.model
if rule_model not in settings.DATA_PERMISSION_MODELS: if rule_model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则模型不存在') raise errors.NotFoundError(msg='数据规则模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model]) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
@@ -82,39 +99,41 @@ def filter_data_permission(request: Request) -> ColumnElement[bool]:
model_columns = [ model_columns = [
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
] ]
column = rule.column column = data_rule.column
if column not in model_columns: if column not in model_columns:
raise errors.NotFoundError(msg='数据规则模型列不存在') raise errors.NotFoundError(msg='数据规则模型列不存在')
# 构建过滤条件 # 构建过滤条件
column_obj = getattr(model_ins, column) column_obj = getattr(model_ins, column)
rule_expression = rule.expression rule_expression = data_rule.expression
condition = None condition = None
if rule_expression == RoleDataRuleExpressionType.eq: match rule_expression:
condition = column_obj == rule.value case RoleDataRuleExpressionType.eq:
elif rule_expression == RoleDataRuleExpressionType.ne: condition = column_obj == data_rule.value
condition = column_obj != rule.value case RoleDataRuleExpressionType.ne:
elif rule_expression == RoleDataRuleExpressionType.gt: condition = column_obj != data_rule.value
condition = column_obj > rule.value case RoleDataRuleExpressionType.gt:
elif rule_expression == RoleDataRuleExpressionType.ge: condition = column_obj > data_rule.value
condition = column_obj >= rule.value case RoleDataRuleExpressionType.ge:
elif rule_expression == RoleDataRuleExpressionType.lt: condition = column_obj >= data_rule.value
condition = column_obj < rule.value case RoleDataRuleExpressionType.lt:
elif rule_expression == RoleDataRuleExpressionType.le: condition = column_obj < data_rule.value
condition = column_obj <= rule.value case RoleDataRuleExpressionType.le:
elif rule_expression == RoleDataRuleExpressionType.in_: condition = column_obj <= data_rule.value
values = rule.value.split(',') if isinstance(rule.value, str) else rule.value case RoleDataRuleExpressionType.in_:
condition = column_obj.in_(values) values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
elif rule.expression == RoleDataRuleExpressionType.not_in: condition = column_obj.in_(values)
values = rule.value.split(',') if isinstance(rule.value, str) else rule.value case RoleDataRuleExpressionType.not_in:
condition = ~column_obj.in_(values) values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
condition = column_obj.not_in(values)
# 根据运算符添加到对应列表 # 根据运算符添加到对应列表
if condition is not None: if condition is not None:
if rule.operator == RoleDataRuleOperatorType.AND: match data_rule.operator:
where_and_list.append(condition) case RoleDataRuleOperatorType.AND:
elif rule.operator == RoleDataRuleOperatorType.OR: where_and_list.append(condition)
where_or_list.append(condition) case RoleDataRuleOperatorType.OR:
where_or_list.append(condition)
# 组合所有条件 # 组合所有条件
where_list = [] where_list = []
+10 -1
View File
@@ -3,10 +3,12 @@
from fastapi import Depends, Request from fastapi import Depends, Request
from backend.common.enums import MethodType, StatusType from backend.common.enums import MethodType, StatusType
from backend.common.exception import errors
from backend.common.exception.errors import AuthorizationError, TokenError from backend.common.exception.errors import AuthorizationError, TokenError
from backend.common.log import log
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.core.conf import settings from backend.core.conf import settings
from backend.plugin.casbin.utils.rbac import casbin_verify from backend.utils.import_parse import import_module_cached
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
@@ -67,6 +69,13 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
if path_auth_perm not in allow_perms: if path_auth_perm not in allow_perms:
raise AuthorizationError raise AuthorizationError
else: else:
try:
casbin_rbac = import_module_cached('backend.plugin.casbin_rbac.rbac')
casbin_verify = getattr(casbin_rbac, 'casbin_verify')
except (ImportError, AttributeError) as e:
log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}')
raise errors.ServerError(msg='权限校验失败,请联系系统管理员')
await casbin_verify(request) await casbin_verify(request)
+4 -5
View File
@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import socketio import socketio
from backend.app.task.conf import task_settings
from backend.common.log import log from backend.common.log import log
from backend.common.security.jwt import jwt_authentication from backend.common.security.jwt import jwt_authentication
from backend.core.conf import settings from backend.core.conf import settings
@@ -13,13 +12,13 @@ sio = socketio.AsyncServer(
# 集成 Celery 实现消息订阅 # 集成 Celery 实现消息订阅
client_manager=socketio.AsyncRedisManager( client_manager=socketio.AsyncRedisManager(
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
) )
if task_settings.CELERY_BROKER == 'redis' if settings.CELERY_BROKER == 'redis'
else socketio.AsyncAioPikaManager( else socketio.AsyncAioPikaManager(
( (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@' f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}' f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
) )
), ),
async_mode='asgi', async_mode='asgi',
+69 -4
View File
@@ -3,6 +3,7 @@
from functools import lru_cache from functools import lru_cache
from typing import Any, Literal from typing import Any, Literal
from celery.schedules import crontab
from pydantic import model_validator from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -77,7 +78,7 @@ class Settings(BaseSettings):
JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天 JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
# RBAC # RBAC
RBAC_ROLE_MENU_MODE: bool = False RBAC_ROLE_MENU_MODE: bool = True
RBAC_ROLE_MENU_EXCLUDE: list[str] = [ RBAC_ROLE_MENU_EXCLUDE: list[str] = [
'sys:monitor:redis', 'sys:monitor:redis',
'sys:monitor:server', 'sys:monitor:server',
@@ -89,11 +90,12 @@ class Settings(BaseSettings):
# 数据权限配置 # 数据权限配置
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义 DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
'Api': 'backend.plugin.casbin.model.Api', '部门': 'backend.app.admin.model.Dept',
} }
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列 DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
'id', 'id',
'sort', 'sort',
'del_flag',
'created_time', 'created_time',
'updated_time', 'updated_time',
] ]
@@ -179,17 +181,80 @@ class Settings(BaseSettings):
'confirm_password', 'confirm_password',
] ]
# 插件配置 # Plugin 配置
PLUGIN_PIP_CHINA: bool = True PLUGIN_PIP_CHINA: bool = True
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/' PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
# App Admin
# .env OAuth2
OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# OAuth2
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
# App Task
# .env Redis
CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int
# .env RabbitMQ
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
CELERY_RABBITMQ_HOST: str
CELERY_RABBITMQ_PORT: int
CELERY_RABBITMQ_USERNAME: str
CELERY_RABBITMQ_PASSWORD: str
# 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5
CELERY_TASK_PACKAGES: list[str] = [
'app.task.celery_task',
'app.task.celery_task.db_log',
]
CELERY_TASK_MAX_RETRIES: int = 5
# 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': {
'task': 'task_demo_async',
'schedule': 10,
},
'exec-every-sunday': {
'task': 'delete_db_opera_log',
'schedule': crontab('0', '0', day_of_week='6'),
},
'exec-every-15-of-month': {
'task': 'delete_db_login_log',
'schedule': crontab('0', '0', day_of_month='15'),
},
}
# Plugin Code Generator
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
# Plugin Config
CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy']
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def check_env(cls, values: Any) -> Any: def check_env(cls, values: Any) -> Any:
"""生产环境下禁用 OpenAPI 文档和静态文件服务""" """检查环境变量"""
if values.get('ENVIRONMENT') == 'pro': if values.get('ENVIRONMENT') == 'pro':
# FastAPI
values['FASTAPI_OPENAPI_URL'] = None values['FASTAPI_OPENAPI_URL'] = None
values['FASTAPI_STATIC_FILES'] = False values['FASTAPI_STATIC_FILES'] = False
# Task
values['CELERY_BROKER'] = 'rabbitmq'
return values return values
-3
View File
@@ -17,9 +17,6 @@ STATIC_DIR = BASE_PATH / 'static'
# 上传文件目录 # 上传文件目录
UPLOAD_DIR = STATIC_DIR / 'upload' UPLOAD_DIR = STATIC_DIR / 'upload'
# jinja2 模版文件路径
JINJA2_TEMPLATE_DIR = BASE_PATH / 'templates'
# 插件目录 # 插件目录
PLUGIN_DIR = BASE_PATH / 'plugin' PLUGIN_DIR = BASE_PATH / 'plugin'
+3 -9
View File
@@ -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
+1 -1
View File
@@ -71,7 +71,7 @@ class JwtAuthMiddleware(AuthenticationBackend):
except TokenError as exc: except TokenError as exc:
raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers) raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers)
except Exception as e: except Exception as e:
log.error(f'JWT 授权异常:{e}') log.exception(f'JWT 授权异常:{e}')
raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error')) raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性 # 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
from backend.plugin.casbin.schema.api import CreateApiParam, GetApiDetail, UpdateApiParam
from backend.plugin.casbin.service.api_service import api_service
router = APIRouter()
@router.get('/all', summary='获取所有接口', dependencies=[DependsJwtAuth])
async def get_all_apis() -> ResponseSchemaModel[list[GetApiDetail]]:
data = await api_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取接口详情', dependencies=[DependsJwtAuth])
async def get_api(pk: Annotated[int, Path(description='API ID')]) -> ResponseSchemaModel[GetApiDetail]:
api = await api_service.get(pk=pk)
return response_base.success(data=api)
@router.get(
'',
summary='分页获取所有接口',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_pagination_apis(
request: Request,
db: CurrentSession,
name: Annotated[str | None, Query(description='API 名称')] = None,
method: Annotated[str | None, Query(description='请求方法')] = None,
path: Annotated[str | None, Query(description='API 路径')] = None,
) -> ResponseSchemaModel[PageData[GetApiDetail]]:
api_select = await api_service.get_select(request=request, name=name, method=method, path=path)
page_data = await paging_data(db, api_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建接口',
dependencies=[
Depends(RequestPermission('sys:api:add')),
DependsRBAC,
],
)
async def create_api(obj: CreateApiParam) -> ResponseModel:
await api_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新接口',
dependencies=[
Depends(RequestPermission('sys:api:edit')),
DependsRBAC,
],
)
async def update_api(pk: Annotated[int, Path(description='API ID')], obj: UpdateApiParam) -> ResponseModel:
count = await api_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除接口',
dependencies=[
Depends(RequestPermission('sys:api:del')),
DependsRBAC,
],
)
async def delete_api(pk: Annotated[list[int], Query(description='API ID 列表')]) -> ResponseModel:
count = await api_service.delete(pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
from backend.plugin.casbin.schema.casbin_rule import (
CreateGroupParam,
CreatePolicyParam,
DeleteAllPoliciesParam,
DeleteGroupParam,
DeletePolicyParam,
GetPolicyDetail,
UpdatePoliciesParam,
UpdatePolicyParam,
)
from backend.plugin.casbin.service.casbin_service import casbin_service
router = APIRouter()
@router.get(
'',
summary='分页获取所有权限策略',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_pagination_casbin(
db: CurrentSession,
ptype: Annotated[str | None, Query(description='策略类型:p / g')] = None,
sub: Annotated[str | None, Query(description='用户 UUID / 角色 ID')] = None,
) -> ResponseSchemaModel[PageData[GetPolicyDetail]]:
casbin_select = await casbin_service.get_casbin_list(ptype=ptype, sub=sub)
page_data = await paging_data(db, casbin_select)
return response_base.success(data=page_data)
@router.get('/policies', summary='获取所有 P 权限策略', dependencies=[DependsJwtAuth])
async def get_all_policies(
role: Annotated[int | None, Query(description='角色 ID')] = None,
) -> ResponseSchemaModel[list[list[str]]]:
policies = await casbin_service.get_policy_list(role=role)
return response_base.success(data=policies)
@router.post(
'/policy',
summary='添加 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:add')),
DependsRBAC,
],
)
async def create_policy(p: CreatePolicyParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_policy(p=p)
return response_base.success(data=data)
@router.post(
'/policies',
summary='添加多组 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:group:add')),
DependsRBAC,
],
)
async def create_policies(ps: list[CreatePolicyParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_policies(ps=ps)
return response_base.success(data=data)
@router.put(
'/policy',
summary='更新 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:edit')),
DependsRBAC,
],
)
async def update_policy(obj: UpdatePolicyParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.update_policy(obj=obj)
return response_base.success(data=data)
@router.put(
'/policies',
summary='更新多组 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:group:edit')),
DependsRBAC,
],
)
async def update_policies(obj: UpdatePoliciesParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.update_policies(obj=obj)
return response_base.success(data=data)
@router.delete(
'/policy',
summary='删除 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:del')),
DependsRBAC,
],
)
async def delete_policy(p: DeletePolicyParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_policy(p=p)
return response_base.success(data=data)
@router.delete(
'/policies',
summary='删除多组 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:group:del')),
DependsRBAC,
],
)
async def delete_policies(ps: list[DeletePolicyParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_policies(ps=ps)
return response_base.success(data=data)
@router.delete(
'/policies/all',
summary='删除所有 P 权限策略',
dependencies=[
Depends(RequestPermission('casbin:p:empty')),
DependsRBAC,
],
)
async def delete_all_policies(sub: DeleteAllPoliciesParam) -> ResponseModel:
count = await casbin_service.delete_all_policies(sub=sub)
if count > 0:
return response_base.success()
return response_base.fail()
@router.get('/groups', summary='获取所有 G 权限策略', dependencies=[DependsJwtAuth])
async def get_all_groups() -> ResponseSchemaModel[list[list[str]]]:
data = await casbin_service.get_group_list()
return response_base.success(data=data)
@router.post(
'/group',
summary='添加 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:add')),
DependsRBAC,
],
)
async def create_group(g: CreateGroupParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_group(g=g)
return response_base.success(data=data)
@router.post(
'/groups',
summary='添加多组 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:group:add')),
DependsRBAC,
],
)
async def create_groups(gs: list[CreateGroupParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.create_groups(gs=gs)
return response_base.success(data=data)
@router.delete(
'/group',
summary='删除 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:del')),
DependsRBAC,
],
)
async def delete_group(g: DeleteGroupParam) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_group(g=g)
return response_base.success(data=data)
@router.delete(
'/groups',
summary='删除多组 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:group:del')),
DependsRBAC,
],
)
async def delete_groups(gs: list[DeleteGroupParam]) -> ResponseSchemaModel[bool]:
data = await casbin_service.delete_groups(gs=gs)
return response_base.success(data=data)
@router.delete(
'/groups/all',
summary='删除所有 G 权限策略',
dependencies=[
Depends(RequestPermission('casbin:g:empty')),
DependsRBAC,
],
)
async def delete_all_groups(uuid: Annotated[UUID, Query()]) -> ResponseModel:
count = await casbin_service.delete_all_groups(uuid=uuid)
if count > 0:
return response_base.success()
return response_base.fail()
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
from backend.core.conf import settings
class CasbinSettings(BaseSettings):
"""Casbin 配置"""
# RBAC
RBAC_CASBIN_EXCLUDE: set[tuple[str, str]] = {
('POST', f'{settings.FASTAPI_API_V1_PATH}/auth/logout'),
('POST', f'{settings.FASTAPI_API_V1_PATH}/auth/token/new'),
}
@lru_cache
def get_casbin_settings() -> CasbinSettings:
"""获取 Casbin 配置"""
return CasbinSettings()
casbin_settings = get_casbin_settings()
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from fastapi import Request
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.common.security.permission import filter_data_permission
from backend.plugin.casbin.model import Api
from backend.plugin.casbin.schema.api import CreateApiParam, UpdateApiParam
class CRUDApi(CRUDPlus[Api]):
"""API 数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> Api | None:
"""
获取 API
:param db: 数据库会话
:param pk: API ID
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, request: Request, name: str = None, method: str = None, path: str = None) -> Select:
"""
获取 API 列表
:param request: FastAPI 请求对象
:param name: API 名称
:param method: 请求方法
:param path: API 路径
:return:
"""
filters = {}
if name is not None:
filters.update(name__like=f'%{name}%')
if method is not None:
filters.update(method=method)
if path is not None:
filters.update(path__like=f'%{path}%')
stmt = await self.select_order('created_time', 'desc', **filters)
return stmt.where(filter_data_permission(request))
async def get_all(self, db: AsyncSession) -> Sequence[Api]:
"""
获取所有 API
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def get_by_name(self, db: AsyncSession, name: str) -> Api | None:
"""
通过名称获取 API
:param db: 数据库会话
:param name: API 名称
:return:
"""
return await self.select_model_by_column(db, name=name)
async def create(self, db: AsyncSession, obj: CreateApiParam) -> None:
"""
创建 API
:param db: 数据库会话
:param obj: 创建 API 参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateApiParam) -> int:
"""
更新 API
:param db: 数据库会话
:param pk: API ID
:param obj: 更新 API 参数
:return:
"""
return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除 API
:param db: 数据库会话
:param pk: API ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
api_dao: CRUDApi = CRUDApi(Api)
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from uuid import UUID
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.plugin.casbin.model import CasbinRule
from backend.plugin.casbin.schema.casbin_rule import DeleteAllPoliciesParam
class CRUDCasbin(CRUDPlus[CasbinRule]):
"""Casbin 规则数据库操作类"""
async def get_list(self, ptype: str, sub: str) -> Select:
"""
获取策略列表
:param ptype: 策略类型
:param sub: 用户 UUID / 角色 ID
:return:
"""
return await self.select_order('id', 'desc', ptype=ptype, v0__like=f'%{sub}%')
async def delete_policies_by_sub(self, db: AsyncSession, sub: DeleteAllPoliciesParam) -> int:
"""
删除角色所有 P 策略
:param db: 数据库会话
:param sub: 删除所有 P 策略参数
:return:
"""
filters = [sub.role]
if sub.uuid:
filters.append(sub.uuid)
return await self.delete_model_by_column(db, allow_multiple=True, v0__mor={'eq': filters})
async def delete_groups_by_uuid(self, db: AsyncSession, uuid: UUID) -> int:
"""
删除用户所有 G 策略
:param db: 数据库会话
:param uuid: 用户 UUID
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, v0=str(uuid))
casbin_dao: CRUDCasbin = CRUDCasbin(CasbinRule)
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.plugin.casbin.model.api import Api
from backend.plugin.casbin.model.casbin_rule import CasbinRule
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
class Api(Base):
"""API 表"""
__tablename__ = 'sys_api'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='API 名称')
method: Mapped[str] = mapped_column(String(16), comment='请求方法')
path: Mapped[str] = mapped_column(String(500), comment='API 路径')
remark: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='备注')
@@ -1,34 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import MappedBase, id_key
class CasbinRule(MappedBase):
"""Casbin 规则表"""
__tablename__ = 'sys_casbin_rule'
id: Mapped[id_key]
ptype: Mapped[str] = mapped_column(String(255), comment='策略类型: p / g')
v0: Mapped[str] = mapped_column(String(255), comment='用户 UUID / 角色 ID')
v1: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='API 路径 / 角色名称')
v2: Mapped[str | None] = mapped_column(String(255), comment='请求方法')
v3: Mapped[str | None] = mapped_column(String(255), comment='预留字段')
v4: Mapped[str | None] = mapped_column(String(255), comment='预留字段')
v5: Mapped[str | None] = mapped_column(String(255), comment='预留字段')
def __str__(self) -> str:
arr = [self.ptype]
for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5):
if v is None:
break
arr.append(v)
return ', '.join(arr)
def __repr__(self) -> str:
return f'<CasbinRule {self.id}: "{str(self)}">'
-10
View File
@@ -1,10 +0,0 @@
[app]
include = 'admin'
[api.api]
prefix = '/apis'
tags = '系统API'
[api.casbin]
prefix = '/casbin'
tags = '系统Casbin权限'
-2
View File
@@ -1,2 +0,0 @@
casbin>=1.38.0
casbin_async_sqlalchemy_adapter>=1.7.0
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import MethodType
from backend.common.schema import SchemaBase
class ApiSchemaBase(SchemaBase):
"""API 基础模型"""
name: str = Field(description='API 名称')
method: MethodType = Field(MethodType.GET, description='请求方法')
path: str = Field(description='API 路径')
remark: str | None = Field(None, description='备注')
class CreateApiParam(ApiSchemaBase):
"""创建 API 参数"""
class UpdateApiParam(ApiSchemaBase):
"""更新 API 参数"""
class GetApiDetail(ApiSchemaBase):
"""API 详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='API ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import ConfigDict, Field
from backend.common.enums import MethodType
from backend.common.schema import SchemaBase
class CreatePolicyParam(SchemaBase):
"""创建 P 策略参数"""
sub: str = Field(description='用户 UUID / 角色 ID')
path: str = Field(description='API 路径')
method: MethodType = Field(MethodType.GET, description='请求方法')
class UpdatePolicyParam(SchemaBase):
"""更新 P 策略参数"""
old: CreatePolicyParam = Field(description='原策略')
new: CreatePolicyParam = Field(description='新策略')
class UpdatePoliciesParam(SchemaBase):
"""批量更新策略参数"""
old: list[CreatePolicyParam] = Field(description='原策略列表')
new: list[CreatePolicyParam] = Field(description='新策略列表')
class DeletePolicyParam(CreatePolicyParam):
"""删除策略参数"""
class DeleteAllPoliciesParam(SchemaBase):
"""删除所有策略参数"""
uuid: str | None = Field(None, description='用户 UUID')
role: str = Field(description='角色')
class CreateGroupParam(SchemaBase):
"""创建 G 策略参数"""
uuid: str = Field(description='用户 UUID')
role: str = Field(description='角色')
class DeleteGroupParam(CreateGroupParam):
"""删除 G 策略参数"""
class GetPolicyDetail(SchemaBase):
"""策略详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='规则 ID')
ptype: str = Field(description='规则类型, p / g')
v0: str = Field(description='用户 UUID / 角色 ID')
v1: str = Field(description='API 路径 / 角色')
v2: str | None = Field(None, description='请求方法')
v3: str | None = Field(None, description='预留字段')
v4: str | None = Field(None, description='预留字段')
v5: str | None = Field(None, description='预留字段')
@@ -1,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 = 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()
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import casbin
import casbin_async_sqlalchemy_adapter
from fastapi import Request
from backend.common.exception.errors import AuthorizationError
from backend.database.db import async_engine
from backend.plugin.casbin.conf import casbin_settings
from backend.plugin.casbin.model import CasbinRule
async def casbin_enforcer() -> casbin.AsyncEnforcer:
"""获取 casbin 执行器"""
# 模型定义:https://casbin.org/zh/docs/category/model
_CASBIN_RBAC_MODEL_CONF_TEXT = """
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && (keyMatch(r.obj, p.obj) || keyMatch3(r.obj, p.obj)) && (r.act == p.act || p.act == "*")
"""
adapter = casbin_async_sqlalchemy_adapter.Adapter(async_engine, db_class=CasbinRule)
model = casbin.AsyncEnforcer.new_model(text=_CASBIN_RBAC_MODEL_CONF_TEXT)
enforcer = casbin.AsyncEnforcer(model, adapter)
await enforcer.load_policy()
return enforcer
async def casbin_verify(request: Request) -> None:
"""
Casbin 权限校验
:param request: FastAPI 请求对象
:return:
"""
method = request.method
path = request.url.path
# casbin 鉴权白名单
if (method, path) in casbin_settings.RBAC_CASBIN_EXCLUDE:
return
# casbin 权限校验
user_uuid = request.user.uuid
enforcer = await casbin_enforcer()
if not enforcer.enforce(user_uuid, path, method):
raise AuthorizationError
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.core.conf import settings
from backend.plugin.code_generator.api.v1.business import router as business_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
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成'])
v1.include_router(gen_router)
v1.include_router(business_router, prefix='/businesses')
v1.include_router(model_router, prefix='/models')
@@ -4,18 +4,18 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path from fastapi import APIRouter, Depends, Path
from backend.app.generator.schema.gen_business import (
CreateGenBusinessParam,
GetGenBusinessDetail,
UpdateGenBusinessParam,
)
from backend.app.generator.schema.gen_model import GetGenModelDetail
from backend.app.generator.service.gen_business_service import gen_business_service
from backend.app.generator.service.gen_model_service import gen_model_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.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.schema.business import (
CreateGenBusinessParam,
GetGenBusinessDetail,
UpdateGenBusinessParam,
)
from backend.plugin.code_generator.schema.column import GetGenModelDetail
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.column_service import gen_model_service
router = APIRouter() router = APIRouter()
@@ -4,12 +4,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path from fastapi import APIRouter, Depends, Path
from backend.app.generator.schema.gen_model import CreateGenModelParam, GetGenModelDetail, UpdateGenModelParam
from backend.app.generator.service.gen_model_service import gen_model_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.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.schema.column import CreateGenModelParam, GetGenModelDetail, UpdateGenModelParam
from backend.plugin.code_generator.service.column_service import gen_model_service
router = APIRouter() router = APIRouter()
@@ -5,13 +5,13 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from backend.app.generator.conf import generator_settings
from backend.app.generator.schema.gen import ImportParam
from backend.app.generator.service.gen_service import gen_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.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.conf import settings
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
router = APIRouter() router = APIRouter()
@@ -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'},
) )
@@ -5,8 +5,8 @@ from typing import Sequence
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.generator.model import GenBusiness from backend.plugin.code_generator.model import GenBusiness
from backend.app.generator.schema.gen_business import CreateGenBusinessParam, UpdateGenBusinessParam from backend.plugin.code_generator.schema.business import CreateGenBusinessParam, UpdateGenBusinessParam
class CRUDGenBusiness(CRUDPlus[GenBusiness]): class CRUDGenBusiness(CRUDPlus[GenBusiness]):
@@ -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]:
""" """

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