Compare commits

...
73 Commits
Author SHA1 Message Date
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
Wu Clan e492cec7d7 Optimize codes and comments with cursor (#550) 2025-03-28 10:08:32 +08:00
PoetryL 92fe1e7554 Fix the query for the sub department (#557) 2025-03-27 23:30:12 +08:00
Wu Clan 7b080600f6 Update volumes of redis in docker compose (#556) 2025-03-26 18:58:06 +08:00
Wu Clan 71f9667bc0 Fix the celery env in docker compose (#555) 2025-03-26 18:34:41 +08:00
Thank CAT 2bf548f8b9 Fix filters for opera log query list (#554) 2025-03-25 09:19:28 +08:00
Wu Clan 15fd4545aa Optimize schemas with model relationships (#552) 2025-03-20 18:46:13 +08:00
Wu Clan 7f6c4ad6da Fix return schema of the config api (#551) 2025-03-20 16:27:44 +08:00
Wu Clan 2be12b4ea4 Fix async install plugin dependencies for windows (#549) 2025-03-17 23:39:10 +08:00
Wu Clan baebdd9870 Optimize dependencies to reduce package size (#548) 2025-03-17 18:41:58 +08:00
Wu Clan d1648bb709 Update git and docker ignore files (#547)
* Update git and docker ignore files

* add __pycache__ dir

* add .python-version
2025-03-17 13:20:21 +08:00
Wu Clan a4a394a18d Optimize the dynamic import of data models (#546) 2025-03-16 15:13:09 +08:00
Wu Clan 951fdc902a Optimize the return of relationship interfaces (#545)
* Optimize the return of relationship interfaces

* Fix gen business detail schema
2025-03-13 18:30:05 +08:00
Wu Clan 18ffa06d70 Updated the backend contribution guide (#544) 2025-03-12 18:15:10 +08:00
Wu Clan d28f152d78 Update the changelog for v1.0.3 (#543) 2025-03-12 14:23:38 +08:00
Wu Clan 1e3e9ea84a Update docker scripts and nginx conf (#542) 2025-03-11 23:26:53 +08:00
Huyuwei 5b42e59d65 Add Aliyun mirror to PyPI index in pyproject.toml (#541)
* Add Aliyun mirror to PyPI index in pyproject.toml

* Update uv.lock

* fix end of files

* Remove UV_INDEX environment variable from Dockerfile
2025-03-11 16:48:30 +08:00
Huyuwei 1882467554 Fix Dockerfile mounts for dependency installation (#540) 2025-03-10 15:34:11 +08:00
Huyuwei f1cd48e9d0 Fix typos in Dockerfile comments (#539) 2025-03-10 15:33:42 +08:00
Wu Clan d1fd84617b Refactor toml and dependencies file dir (#538)
* Refactor toml and dependencies file dir

* fix ci
2025-03-09 23:38:20 +08:00
Wu Clan 03fcf82b26 Update docker scripts in backend README (#537) 2025-03-04 18:34:07 +08:00
Wu Clan f75e1bf3df Update the changelog for v1.0.2 (#536) 2025-03-01 16:01:41 +08:00
255 changed files with 9320 additions and 7708 deletions
+9 -6
View File
@@ -1,6 +1,9 @@
**/__pycache__ __pycache__/
**/venv .git/
**/.venv .github/
**/.python-version .idea/
**/.git .vscode/
**/.github venv/
.venv/
.ruff_cache/
.pytest_cache/
+4 -6
View File
@@ -12,25 +12,23 @@ 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 }}
- name: Install dependencies - name: Install dependencies
run: | run: |
cd backend
uv sync --only-group lint uv sync --only-group lint
- name: Run lint - name: Run lint
run: | run: |
cd backend
source .venv/bin/activate source .venv/bin/activate
chmod 755 scripts/lint.sh chmod 755 backend/scripts/lint.sh
./scripts/lint.sh ./backend/scripts/lint.sh
+7
View File
@@ -1,2 +1,9 @@
.idea/ .idea/
.vscode/ .vscode/
.cursor/
.DS_Store
venv/
.venv/
.python-version
.ruff_cache/
.pytest_cache/
+4 -11
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,18 +20,11 @@ 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
args:
- '--directory'
- 'backend'
files: backend/uv\.lock$
- id: uv-export - id: uv-export
args: args:
- '--directory'
- 'backend'
- '-o' - '-o'
- 'requirements.txt' - 'requirements.txt'
- '--no-hashes' - '--no-hashes'
files: backend/uv\.lock$
+179 -1
View File
@@ -1,3 +1,172 @@
<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>
# [v1.0.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.3) - 2025-03-11
## What's Changed
* Update the changelog for v1.0.2 by [@wu-clan](https://github.com/wu-clan) in [#536](https://github.com/fastapi-practices/fastapi_best_architecture/pull/536)
* Update docker scripts in backend README by [@wu-clan](https://github.com/wu-clan) in [#537](https://github.com/fastapi-practices/fastapi_best_architecture/pull/537)
* Refactor toml and dependencies file dir by [@wu-clan](https://github.com/wu-clan) in [#538](https://github.com/fastapi-practices/fastapi_best_architecture/pull/538)
* Fix typos in Dockerfile comments by [@huyuwei1996](https://github.com/huyuwei1996) in [#539](https://github.com/fastapi-practices/fastapi_best_architecture/pull/539)
* Fix Dockerfile mounts for dependency installation by [@huyuwei1996](https://github.com/huyuwei1996) in [#540](https://github.com/fastapi-practices/fastapi_best_architecture/pull/540)
* Add Aliyun mirror to PyPI index in pyproject.toml by [@huyuwei1996](https://github.com/huyuwei1996) in [#541](https://github.com/fastapi-practices/fastapi_best_architecture/pull/541)
* Update docker scripts and nginx conf by [@wu-clan](https://github.com/wu-clan) in [#542](https://github.com/fastapi-practices/fastapi_best_architecture/pull/542)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
[Changes][v1.0.3]
<a id="v1.0.2"></a>
# [v1.0.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.2) - 2025-03-01
## What's Changed
* Update the changelog for v1.0.1 by [@wu-clan](https://github.com/wu-clan) in [#532](https://github.com/fastapi-practices/fastapi_best_architecture/pull/532)
* Fix celery async task worker pool by [@wu-clan](https://github.com/wu-clan) in [#533](https://github.com/fastapi-practices/fastapi_best_architecture/pull/533)
* Add log module root and output levels by [@wu-clan](https://github.com/wu-clan) in [#534](https://github.com/fastapi-practices/fastapi_best_architecture/pull/534)
* Add plugin related interfaces by [@wu-clan](https://github.com/wu-clan) in [#535](https://github.com/fastapi-practices/fastapi_best_architecture/pull/535)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2
[Changes][v1.0.2]
<a id="v1.0.1"></a> <a id="v1.0.1"></a>
# [v1.0.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.1) - 2025-02-26 # [v1.0.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.1) - 2025-02-26
@@ -342,7 +511,16 @@
[Changes][v1.0.0] [Changes][v1.0.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.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
[v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0 [v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.8.1 --> <!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.0 -->
+6 -8
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 \
@@ -15,13 +14,12 @@ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debi
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_NO_CACHE=1 \ UV_NO_CACHE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/usr/local \ UV_PROJECT_ENVIRONMENT=/usr/local
UV_INDEX=https://mirrors.aliyun.com/pypi/simple
# Install dependencies with cache # Install dependencies with cache
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=backend/uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=backend/pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-default-groups --group server uv sync --frozen --no-default-groups --group server
# === Runtime base server image === # === Runtime base server image ===
@@ -43,7 +41,7 @@ WORKDIR /fba
ENV PYTHONPATH=/fba ENV PYTHONPATH=/fba
RUN python3 backend/scripts/init_plugin.py RUN python3 backend/scripts/init_plugin.py
# === FastPAI server iamge === # === FastAPI server image ===
FROM base_server AS fastapi_server FROM base_server AS fastapi_server
WORKDIR /fba WORKDIR /fba
@@ -57,7 +55,7 @@ EXPOSE 8001
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port","8000"] CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port","8000"]
# === Celery server iamge === # === Celery server image ===
FROM base_server AS celery FROM base_server AS celery
WORKDIR /fba/backend/ WORKDIR /fba/backend/
+33 -33
View File
@@ -6,9 +6,7 @@
English | [简体中文](./README.zh-CN.md) English | [简体中文](./README.zh-CN.md)
A backend and frontend separation solution based on the FastAPI framework, following Enterprise-level backend architecture solution
the [pseudo 3-tier architecture](#pseudo-3-tier-architecture) design, supporting **Python 3.10** and above
versions
**🔥Continuously updated and maintained🔥** **🔥Continuously updated and maintained🔥**
@@ -49,37 +47,39 @@ pattern, use templates to transform it to your heart's content!
## Features ## Features
- [x] Design with FastAPI PEP 593 Annotated Parameters - [x] Global FastAPI PEP 593 Annotated parameter style
- [x] Global asynchronous design with async/await + asgiref - [x] Comprehensive async/await + asgiref asynchronous design
- [x] Follows Restful API specification - [x] Adheres to RESTful API specifications
- [x] Global SQLAlchemy 2.0 syntax - [x] Uses SQLAlchemy 2.0 with new syntax
- [x] Pydantic v1 and v2 (different branches) - [x] Uses Pydantic v2 version
- [x] Casbin RBAC access control model - [x] Implements role-menu RBAC access control
- [x] Role menu RBAC access control model - [x] Integrates Casbin RBAC access control
- [x] Celery asynchronous tasks - [x] Supports Celery asynchronous tasks
- [x] JWT middleware whitelist authentication - [x] Custom-developed JWT authentication middleware
- [x] Global customizable time zone time - [x] Supports global custom time zones
- [x] Docker / Docker-compose deployment - [x] Supports Docker / Docker-compose deployment
- [x] Pytest Unit Testing - [x] Integrates Pytest unit testing
## Built-in features ## Built-in Functions
- [x] User management: System User Role Management, Permission Allocation - [x] User Management: Assign roles and permissions
- [x] Department management: Configure system organization (company, department, team...) - [x] Department Management: Configure organizational structure (company, department, team, etc.)
- [x] Menu management: Configure system menu, user menu, button permission tags - [x] Menu Management: Set up menus and button-level permissions
- [x] Role management: role menu permission allocation, role route permission allocation - [x] Role Management: Configure roles, assign menus and permissions
- [x] Dictionary management: Maintain commonly used fixed data or parameters within the system - [x] Dictionary Management: Maintain common parameters and configurations
- [x] Token management: System user online status detection, supports kicking users offline - [x] Parameter Management: Dynamically configure commonly used system parameters
- [x] Login authentication: backend-based graphical captcha background authentication login - [x] Notification Announcements: Publish and maintain system notification and announcement information
- [x] Multipoint login: One-click modification of multipoint login through user information - [x] Token Management: Detect online status, support forced logout
- [x] OAuth 2.0: Built-in self-developed OAuth 2.0 login integration - [x] Multi-device Login: Support one-click switching between multi-device login modes
- [x] Code generation: automatic backend code generation, supports preview, writing, and download - [x] OAuth 2.0: Built-in custom-developed OAuth 2.0 authorization login
- [x] Scheduled task: Automated task, asynchronous task, supports function calls - [x] Plugin System: Hot-swappable plugin design to reduce coupling
- [x] Plugin system: Say goodbye to high coupling integration through hot-pluggable plugin mode - [x] Scheduled Tasks: Support scheduled, asynchronous tasks, and function calls
- [x] Operation log: Record and query of system normal and abnormal operations - [x] Code Generation: Automatically generate code with preview, write, and download support
- [x] Login log: Record and query of normal and abnormal user login - [x] Operation Logs: Record and query normal and abnormal operations
- [x] Service monitoring: Server hardware device information and status - [x] Login Logs: Record and query normal and abnormal logins
- [x] API documentation: Automatically generate online interactive API documentation - [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 ## Development and deployment
@@ -103,7 +103,7 @@ the [official documentation](https://fastapi-practices.github.io/fastapi_best_ar
## Interactivity ## Interactivity
[TG / Discord](https://wu-clan.github.io/homepage/) [Discord](https://wu-clan.github.io/homepage/)
## Sponsor us ## Sponsor us
+31 -29
View File
@@ -6,7 +6,7 @@
简体中文 | [English](./README.md) 简体中文 | [English](./README.md)
基于 FastAPI 框架的前后端分离中后台解决方案,遵循[伪三层架构](#伪三层架构)设计, 支持 **python3.10** 及以上版本 企业级后端架构解决方案
**🔥持续更新维护中🔥** **🔥持续更新维护中🔥**
@@ -43,38 +43,40 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
| 数据访问 | dao / mapper | crud | | 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model | | 模型 | model / entity | model |
## 特 ## 特
- [x] 全局 FastAPI PEP 593 Annotated 参数风格 - [x] 全局 FastAPI PEP 593 Annotated 参数风格
- [x] async/await + asgiref 的全局异步设计 - [x] 全面 async/await + asgiref 异步设计
- [x] 遵循 Restful API 规范 - [x] 遵循 RESTful API 规范
- [x] 全局 SQLAlchemy 2.0 语法 - [x] 使用 SQLAlchemy 2.0 全新语法
- [x] Pydantic v1 和 v2 (不同分支) - [x] 使用 Pydantic v2 版本
- [x] Casbin RBAC 访问控制模型 - [x] 实现角色菜单 RBAC 访问控制
- [x] 角色菜单 RBAC 访问控制模型 - [x] 集成 Casbin RBAC 访问控制
- [x] Celery 异步任务 - [x] 支持 Celery 异步任务
- [x] JWT 中间件白名单认证 - [x] 自研 JWT 认证中间件
- [x] 全局自定义时区时 - [x] 支持全局自定义时间时区
- [x] Docker / Docker-compose 部署 - [x] 支持 Docker / Docker-compose 部署
- [x] Pytest 单元测试 - [x] 集成 Pytest 单元测试
## 内置功能 ## 内置功能
- [x] 用户管理:系统用户角色管理,权限分配 - [x] 用户管理:分配角色和权限
- [x] 部门管理:配置系统组织构(公司、部门、小组... - [x] 部门管理:配置组织构(公司、部门、小组
- [x] 菜单管理:配置系统菜单,用户菜单,按钮权限标识 - [x] 菜单管理:设置菜单及按钮权限
- [x] 角色管理:角色菜单权限分配,角色路由权限分配 - [x] 角色管理:配置角色、分配菜单权限
- [x] 字典管理:维护系统内部常用固定数据或参数 - [x] 字典管理:维护常用参数和配置
- [x] 令牌管理:系统用户在线状态检测,支持踢人下线 - [x] 参数管理:系统常用参数动态配置
- [x] 登录认证:基于后端的图形验证码后台认证登录 - [x] 通知公告:发布和维护系统通知公告信息
- [x] 多点登录:通过用户信息一键修改多点登录支持 - [x] 令牌管理:检测在线状态,支持强制下线
- [x] OAuth20:内置自研 OAuth 2.0 登录集成 - [x] 多端登录:支持一键切换多端登录模式
- [x] 代码生成:后端代码自动生成,支持预览,写入及下载 - [x] OAuth 2.0:内置自研 OAuth 2.0 授权登录
- [x] 定时任务:自动化任务,异步任务,支持函数调用 - [x] 插件系统:热插拔插件设计,降低耦合
- [x] 插件系统:通过热插拔插件模式告别高耦合集成 - [x] 定时任务:支持定时,异步任务及函数调用
- [x] 操作日志:系统正常和异常操作的日志记录与查询 - [x] 代码生成:自动生成代码,支持预览、写入和下载
- [x] 登录日志:用户正常和异常登录的日志记录与查询 - [x] 操作日志:记录和查询正常和异常操作
- [x] 服务监控:服务器硬件设备信息与状态 - [x] 登录日志:记录和查询正常和异常登录
- [x] 缓存监控:查询系统缓存信息和命令统计
- [x] 服务监控:查看服务器硬件信息和状态
- [x] 接口文档:自动生成在线交互式 API 文档 - [x] 接口文档:自动生成在线交互式 API 文档
## 开发部署 ## 开发部署
@@ -98,7 +100,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
## 互动 ## 互动
[TG / Discord](https://wu-clan.github.io/homepage/) [Discord](https://wu-clan.github.io/homepage/)
## 赞助我们 ## 赞助我们
-4
View File
@@ -1,4 +0,0 @@
__pycache__/
venv/
.venv/
.python-version
+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 -7
View File
@@ -1,12 +1,6 @@
__pycache__/ __pycache__/
.env .env
venv/
.venv/
.mypy_cache/
log/
alembic/versions/ alembic/versions/
static/media/ static/media/
.ruff_cache/ *.log
.pytest_cache/
.python-version
celerybeat-schedule.* celerybeat-schedule.*
+27 -23
View File
@@ -1,64 +1,68 @@
# FBA Project - Backend # FastAPI Best Architecture - Backend
## Docker ## Docker
> [!IMPORTANT]
> Due to Docker context limitations, you can't build an image in this directory
1. Make sure you're at the root of the project 1. Make sure you're at the root of the project
2. Run the following Docker command to build container: 2. Run the following Docker command to build container:
```shell ```shell
docker build -f backend/backend.dockerfile -t fba_backend_independent . docker build -f Dockerfile -t fba_backend_independent .
``` ```
3. Start container 3. Start container
Native boot needs to change `127.0.0.1` in `.env` to `host.docker.internal`
```shell ```shell
docker run -d fba_backend_independent -p 8000:8000 --name fba_app docker run -d -p 8000:8000 --name fba_server fba_backend_independent
``` ```
## Contributing ## Contributing
1. Prerequisites 1. Prerequisites
You'll need the following prerequisites: - Python >= 3.10
- Any python version between Python >= 3.10
- Git - Git
- [uv](https://docs.astral.sh/uv/getting-started/installation/) - [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Fork this repository to your GitHub account - Fork this repository to your GitHub account
2. Installation and setup 2. Installation and setup
Clone your fork and cd into the repo directory Clone your forked repository:
```shell ```shell
git clone https://github.com/<your username>/fastapi_best_architecture.git git clone https://github.com/<your account>/fastapi_best_architecture.git
cd fastapi_best_architecture/backend
uv venv
uv pip install -r requirements.txt
``` ```
3. Checkout a new branch and make your changes Go to the root directory of the project, open the terminal, and run the following command:
```sh
uv sync --frozen
```
3. Checkout
Checkout a new branch and make your changes
```shell ```shell
# Checkout a new branch and make your changes
git checkout -b your-new-feature-branch git checkout -b your-new-feature-branch
``` ```
4. Run linting 4. Format and Lint
Auto-formatting and lint via `pre-commit`
```shell ```shell
# Run automated code formatting and linting
pre-commit run --all-files pre-commit run --all-files
``` ```
5. Commit and push your changes 5. Commit and push
Commit your changes, push your branch to GitHub, and create a pull request. Commit your changes and push your branch to the GitHub.
6. PR
Create a PR via GitHub
## Scripts ## Scripts
+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 -1
View File
@@ -36,7 +36,7 @@ async def user_login(
@router.post('/token/new', summary='创建新 token') @router.post('/token/new', summary='创建新 token')
async def create_new_token(request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]: async def create_new_token(request: Request) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.new_token(request=request) data = await auth_service.new_token(request=request)
return response_base.success(data=data) return response_base.success(data=data)
+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)
+6 -6
View File
@@ -18,7 +18,7 @@ router = APIRouter()
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取登录日志', summary='分页获取登录日志',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -26,9 +26,9 @@ router = APIRouter()
) )
async def get_pagination_login_logs( async def get_pagination_login_logs(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query()] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query()] = None, ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]: ) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
log_select = await login_log_service.get_select(username=username, status=status, ip=ip) log_select = await login_log_service.get_select(username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select) page_data = await paging_data(db, log_select)
@@ -37,13 +37,13 @@ async def get_pagination_login_logs(
@router.delete( @router.delete(
'', '',
summary='批量删除登录日志', summary='批量删除登录日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:login:del')), Depends(RequestPermission('log:login:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_login_log(pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_login_log(pk: Annotated[list[int], Query(description='登录日志 ID 列表')]) -> ResponseModel:
count = await login_log_service.delete(pk=pk) count = await login_log_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+6 -6
View File
@@ -18,7 +18,7 @@ router = APIRouter()
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取操作日志', summary='分页获取操作日志',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -26,9 +26,9 @@ router = APIRouter()
) )
async def get_pagination_opera_logs( async def get_pagination_opera_logs(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query()] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query()] = None, ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]: ) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
log_select = await opera_log_service.get_select(username=username, status=status, ip=ip) log_select = await opera_log_service.get_select(username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select) page_data = await paging_data(db, log_select)
@@ -37,13 +37,13 @@ async def get_pagination_opera_logs(
@router.delete( @router.delete(
'', '',
summary='批量删除操作日志', summary='批量删除操作日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:opera:del')), Depends(RequestPermission('log:opera:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_opera_log(pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_opera_log(pk: Annotated[list[int], Query(description='操作日志 ID 列表')]) -> ResponseModel:
count = await opera_log_service.delete(pk=pk) count = await opera_log_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+4 -1
View File
@@ -19,5 +19,8 @@ router = APIRouter()
], ],
) )
async def get_redis_info() -> ResponseModel: async def get_redis_info() -> ResponseModel:
data = {'info': await redis_info.get_info(), 'stats': await redis_info.get_stats()} data = {
'info': await redis_info.get_info(),
'stats': await redis_info.get_stats(),
}
return response_base.success(data=data) return response_base.success(data=data)
@@ -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 -8
View File
@@ -1,12 +1,10 @@
#!/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
@@ -16,14 +14,12 @@ 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(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
router.include_router(token_router, prefix='/tokens', tags=['系统令牌']) router.include_router(token_router, prefix='/tokens', 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=['系统插件'])
+30 -22
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
@@ -12,47 +17,48 @@ 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.database.db import CurrentSession from backend.database.db import CurrentSession
from backend.utils.serializers import select_as_dict, select_list_serialize
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(model: Annotated[str, Path()]) -> ResponseSchemaModel[list[str]]: async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')],
) -> 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)
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
async def get_all_data_rule() -> ResponseSchemaModel[list[GetDataRuleDetail]]: async def get_all_data_rules() -> ResponseSchemaModel[list[GetDataRuleDetail]]:
data_rules = await data_rule_service.get_all() data = await data_rule_service.get_all()
data = select_list_serialize(data_rules)
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(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetDataRuleDetail]: async def get_data_rule(
data_rule = await data_rule_service.get(pk=pk) pk: Annotated[int, Path(description='数据规则 ID')],
data = GetDataRuleDetail(**select_as_dict(data_rule)) ) -> ResponseSchemaModel[GetDataRuleDetail]:
data = await data_rule_service.get(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取所有数据权限规则', summary='分页获取所有数据规则',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_data_rule( async def get_pagination_data_rules(
db: CurrentSession, name: Annotated[str | None, Query()] = None db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]: ) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
data_rule_select = await data_rule_service.get_select(name=name) data_rule_select = await data_rule_service.get_select(name=name)
page_data = await paging_data(db, data_rule_select) page_data = await paging_data(db, data_rule_select)
@@ -61,7 +67,7 @@ async def get_pagination_data_rule(
@router.post( @router.post(
'', '',
summary='创建数据权限规则', summary='创建数据规则',
dependencies=[ dependencies=[
Depends(RequestPermission('data:rule:add')), Depends(RequestPermission('data:rule:add')),
DependsRBAC, DependsRBAC,
@@ -74,13 +80,15 @@ 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,
], ],
) )
async def update_data_rule(pk: Annotated[int, Path(...)], obj: UpdateDataRuleParam) -> ResponseModel: async def update_data_rule(
pk: Annotated[int, Path(description='数据规则 ID')], obj: UpdateDataRuleParam
) -> ResponseModel:
count = await data_rule_service.update(pk=pk, obj=obj) count = await data_rule_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -89,14 +97,14 @@ async def update_data_rule(pk: Annotated[int, Path(...)], obj: UpdateDataRulePar
@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(request: Request, pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_data_rule(pk: Annotated[list[int], Query(description='数据规则 ID 列表')]) -> ResponseModel:
count = await data_rule_service.delete(request=request, pk=pk) count = await data_rule_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()
+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()
+12 -13
View File
@@ -10,26 +10,25 @@ from backend.common.response.response_schema import ResponseModel, ResponseSchem
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.utils.serializers import select_as_dict
router = APIRouter() router = APIRouter()
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
async def get_dept(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetDeptDetail]: async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseSchemaModel[GetDeptDetail]:
dept = await dept_service.get(pk=pk) data = await dept_service.get(pk=pk)
data = GetDeptDetail(**select_as_dict(dept))
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有部门展示树', dependencies=[DependsJwtAuth]) @router.get('', summary='获取所有部门展示树', dependencies=[DependsJwtAuth])
async def get_all_depts_tree( async def get_all_depts(
name: Annotated[str | None, Query()] = None, request: Request,
leader: Annotated[str | None, Query()] = None, name: Annotated[str | None, Query(description='部门名称')] = None,
phone: Annotated[str | None, Query()] = None, leader: Annotated[str | None, Query(description='部门负责人')] = None,
status: Annotated[int | None, Query()] = None, phone: Annotated[str | 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)
@@ -54,7 +53,7 @@ async def create_dept(obj: CreateDeptParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_dept(pk: Annotated[int, Path(...)], obj: UpdateDeptParam) -> ResponseModel: async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam) -> ResponseModel:
count = await dept_service.update(pk=pk, obj=obj) count = await dept_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -69,8 +68,8 @@ async def update_dept(pk: Annotated[int, Path(...)], obj: UpdateDeptParam) -> Re
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_dept(request: Request, pk: Annotated[int, Path(...)]) -> 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()
+9 -10
View File
@@ -10,27 +10,26 @@ from backend.common.response.response_schema import ResponseModel, ResponseSchem
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.utils.serializers import select_as_dict
router = APIRouter() router = APIRouter()
@router.get('/sidebar', summary='获取用户菜单展示树', dependencies=[DependsJwtAuth]) @router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben5', dependencies=[DependsJwtAuth])
async def get_user_sidebar_tree(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)
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
async def get_menu(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetMenuDetail]: async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseSchemaModel[GetMenuDetail]:
menu = await menu_service.get(pk=pk) data = await menu_service.get(pk=pk)
data = GetMenuDetail(**select_as_dict(menu))
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有菜单展示树', dependencies=[DependsJwtAuth]) @router.get('', summary='获取所有菜单展示树', dependencies=[DependsJwtAuth])
async def get_all_menus( async def get_all_menus(
title: Annotated[str | None, Query()] = None, status: Annotated[int | None, Query()] = None title: Annotated[str | None, Query(description='菜单标题')] = None,
status: Annotated[int | None, Query(description='状体')] = None,
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any]]]:
menu = await menu_service.get_menu_tree(title=title, status=status) menu = await menu_service.get_menu_tree(title=title, status=status)
return response_base.success(data=menu) return response_base.success(data=menu)
@@ -57,7 +56,7 @@ async def create_menu(obj: CreateMenuParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_menu(pk: Annotated[int, Path(...)], obj: UpdateMenuParam) -> ResponseModel: async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam) -> ResponseModel:
count = await menu_service.update(pk=pk, obj=obj) count = await menu_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -72,8 +71,8 @@ async def update_menu(pk: Annotated[int, Path(...)], obj: UpdateMenuParam) -> Re
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_menu(request: Request, pk: Annotated[int, Path(...)]) -> 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 -65
View File
@@ -1,95 +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='此插件已安装')
os.makedirs(full_plugin_path)
# 解压安装
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir_in_zip):
member.filename = member.filename.replace(plugin_dir_in_zip, '')
if not member.filename:
continue
members.append(member)
zf.extractall(PLUGIN_DIR, members)
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
await install_requirements_async(False)
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_zip(plugin: Annotated[str, Query()]): 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',
+34 -33
View File
@@ -2,17 +2,16 @@
# -*- 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,
GetRoleDetail, GetRoleDetail,
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
@@ -20,47 +19,49 @@ 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.database.db import CurrentSession from backend.database.db import CurrentSession
from backend.utils.serializers import select_as_dict, select_list_serialize
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]: async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
roles = await role_service.get_all() data = await role_service.get_all()
data = select_list_serialize(roles)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/all', summary='获取用户所有角色', dependencies=[DependsJwtAuth]) @router.get('/{pk}/all', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
async def get_user_all_roles(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[list[GetRoleDetail]]: async def get_user_all_roles(
roles = await role_service.get_by_user(pk=pk) pk: Annotated[int, Path(description='用户 ID')],
data = select_list_serialize(roles) ) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_users(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth]) @router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth])
async def get_role_all_menus(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[list[dict[str, Any]]]: async def get_role_all_menus(
menu = await menu_service.get_role_menu_tree(pk=pk) pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any]]]:
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(...)]) -> 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)
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
async def get_role(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetRoleDetail]: async def get_role(
role = await role_service.get(pk=pk) pk: Annotated[int, Path(description='角色 ID')],
data = GetRoleDetail(**select_as_dict(role)) ) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
data = await role_service.get(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取所有角色', summary='分页获取所有角色',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -68,8 +69,8 @@ async def get_role(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetRole
) )
async def get_pagination_roles( async def get_pagination_roles(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query()] = None, name: Annotated[str | None, Query(description='角色名称')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetRoleDetail]]: ) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
role_select = await role_service.get_select(name=name, status=status) role_select = await role_service.get_select(name=name, status=status)
page_data = await paging_data(db, role_select) page_data = await paging_data(db, role_select)
@@ -97,7 +98,7 @@ async def create_role(obj: CreateRoleParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_role(pk: Annotated[int, Path(...)], obj: UpdateRoleParam) -> ResponseModel: async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam) -> ResponseModel:
count = await role_service.update(pk=pk, obj=obj) count = await role_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -113,26 +114,26 @@ async def update_role(pk: Annotated[int, Path(...)], obj: UpdateRoleParam) -> Re
], ],
) )
async def update_role_menus( async def update_role_menus(
request: Request, pk: Annotated[int, Path(...)], 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(...)], 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()
@@ -140,14 +141,14 @@ async def update_role_rules(
@router.delete( @router.delete(
'', '',
summary='批量删除角色', summary='批量删除角色',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:del')), Depends(RequestPermission('sys:role:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_role(request: Request, pk: Annotated[list[int], Query(...)]) -> 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()
+30 -25
View File
@@ -9,7 +9,7 @@ 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, KickOutToken
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, superuser_verify from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
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.core.conf import settings
@@ -19,10 +19,28 @@ router = APIRouter()
@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth]) @router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth])
async def get_tokens(username: Annotated[str | None, Query()] = None) -> ResponseSchemaModel[list[GetTokenDetail]]: async def get_tokens(
username: Annotated[str | None, Query(description='用户名')] = None,
) -> 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}:*')
token_online = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX) online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
data = [] data: list[GetTokenDetail] = []
def append_token_detail() -> None:
data.append(
token_detail.model_copy(
update={
'username': extra_info.get('username', '未知'),
'nickname': extra_info.get('nickname', '未知'),
'ip': extra_info.get('ip', '未知'),
'os': extra_info.get('os', '未知'),
'browser': extra_info.get('browser', '未知'),
'device': extra_info.get('device', '未知'),
'last_login_time': extra_info.get('last_login_time', '未知'),
}
)
)
for key in token_keys: for key in token_keys:
token = await redis_client.get(key) token = await redis_client.get(key)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
@@ -36,31 +54,16 @@ async def get_tokens(username: Annotated[str | None, Query()] = None) -> Respons
os='未知', os='未知',
browser='未知', browser='未知',
device='未知', device='未知',
status=StatusType.disable if session_uuid not in token_online else StatusType.enable, status=StatusType.enable if session_uuid in online_clients else StatusType.disable,
last_login_time='未知', last_login_time='未知',
expire_time=token_payload.expire_time, expire_time=token_payload.expire_time,
) )
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:
def append_token_detail():
data.append(
token_detail.model_copy(
update={
'username': extra_info.get('username'),
'nickname': extra_info.get('nickname'),
'ip': extra_info.get('ip'),
'os': extra_info.get('os'),
'browser': extra_info.get('browser'),
'device': extra_info.get('device'),
'last_login_time': extra_info.get('last_login_time'),
}
)
)
extra_info = json.loads(extra_info) extra_info = json.loads(extra_info)
if extra_info.get('login_type') != 'swagger': # 排除 swagger 登录生成的 token
if username: if extra_info.get('swagger') is None:
if username is not None:
if username == extra_info.get('username'): if username == extra_info.get('username'):
append_token_detail() append_token_detail()
else: else:
@@ -78,7 +81,9 @@ async def get_tokens(username: Annotated[str | None, Query()] = None) -> Respons
DependsRBAC, DependsRBAC,
], ],
) )
async def kick_out(request: Request, pk: Annotated[int, Path(...)], session_uuid: KickOutToken) -> ResponseModel: async def kick_out(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: KickOutToken
) -> ResponseModel:
superuser_verify(request) superuser_verify(request)
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{pk}:{session_uuid}') await revoke_token(str(pk), obj.session_uuid)
return response_base.success() return response_base.success()
-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
+30 -27
View File
@@ -7,8 +7,8 @@ from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
AvatarParam, AvatarParam,
GetCurrentUserInfoDetail, GetCurrentUserInfoWithRelationDetail,
GetUserInfoDetail, GetUserInfoWithRelationDetail,
RegisterUserParam, RegisterUserParam,
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
@@ -21,7 +21,6 @@ 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.database.db import CurrentSession from backend.database.db import CurrentSession
from backend.utils.serializers import select_as_dict
router = APIRouter() router = APIRouter()
@@ -33,10 +32,9 @@ async def register_user(obj: RegisterUserParam) -> ResponseModel:
@router.post('/add', summary='添加用户', dependencies=[DependsRBAC]) @router.post('/add', summary='添加用户', dependencies=[DependsRBAC])
async def add_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoDetail]: async def add_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.add(request=request, obj=obj) await user_service.add(request=request, obj=obj)
current_user = await user_service.get_userinfo(username=obj.username) data = await user_service.get_userinfo(username=obj.username)
data = GetUserInfoDetail(**select_as_dict(current_user))
return response_base.success(data=data) return response_base.success(data=data)
@@ -48,21 +46,24 @@ async def password_reset(request: Request, obj: ResetPasswordParam) -> ResponseM
return response_base.fail() return response_base.fail()
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth], response_model_exclude={'password'}) @router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoDetail]: async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
data = GetCurrentUserInfoDetail(**request.user.model_dump()) data = request.user.model_dump()
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth]) @router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth])
async def get_user(username: Annotated[str, Path(...)]) -> ResponseSchemaModel[GetUserInfoDetail]: async def get_user(
current_user = await user_service.get_userinfo(username=username) username: Annotated[str, Path(description='用户名')],
data = GetUserInfoDetail(**select_as_dict(current_user)) ) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
data = await user_service.get_userinfo(username=username)
return response_base.success(data=data) return response_base.success(data=data)
@router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) @router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth])
async def update_user(request: Request, username: Annotated[str, Path(...)], obj: UpdateUserParam) -> ResponseModel: async def update_user(
request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserParam
) -> ResponseModel:
count = await user_service.update(request=request, username=username, obj=obj) count = await user_service.update(request=request, username=username, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -78,14 +79,16 @@ async def update_user(request: Request, username: Annotated[str, Path(...)], obj
], ],
) )
async def update_user_role( async def update_user_role(
request: Request, username: Annotated[str, Path(...)], obj: UpdateUserRoleParam request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserRoleParam
) -> ResponseModel: ) -> ResponseModel:
await user_service.update_roles(request=request, username=username, obj=obj) await user_service.update_roles(request=request, username=username, obj=obj)
return response_base.success() return response_base.success()
@router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth]) @router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth])
async def update_avatar(request: Request, username: Annotated[str, Path(...)], avatar: AvatarParam) -> ResponseModel: async def update_avatar(
request: Request, username: Annotated[str, Path(description='用户名')], avatar: AvatarParam
) -> ResponseModel:
count = await user_service.update_avatar(request=request, username=username, avatar=avatar) count = await user_service.update_avatar(request=request, username=username, avatar=avatar)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -94,7 +97,7 @@ async def update_avatar(request: Request, username: Annotated[str, Path(...)], a
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取所有用户', summary='分页获取所有用户',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -102,18 +105,18 @@ async def update_avatar(request: Request, username: Annotated[str, Path(...)], a
) )
async def get_pagination_users( async def get_pagination_users(
db: CurrentSession, db: CurrentSession,
dept: Annotated[int | None, Query()] = None, dept: Annotated[int | None, Query(description='部门 ID')] = None,
username: Annotated[str | None, Query()] = None, username: Annotated[str | None, Query(description='用户名')] = None,
phone: Annotated[str | None, Query()] = None, phone: Annotated[str | None, Query(description='手机号')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]: ) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status) user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status)
page_data = await paging_data(db, user_select) page_data = await paging_data(db, user_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@router.put('/{pk}/super', summary='修改用户超级权限', dependencies=[DependsRBAC]) @router.put('/{pk}/super', summary='修改用户超级权限', dependencies=[DependsRBAC])
async def super_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def super_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_permission(request=request, pk=pk) count = await user_service.update_permission(request=request, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -121,7 +124,7 @@ async def super_set(request: Request, pk: Annotated[int, Path(...)]) -> Response
@router.put('/{pk}/staff', summary='修改用户后台登录权限', dependencies=[DependsRBAC]) @router.put('/{pk}/staff', summary='修改用户后台登录权限', dependencies=[DependsRBAC])
async def staff_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def staff_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_staff(request=request, pk=pk) count = await user_service.update_staff(request=request, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -129,15 +132,15 @@ async def staff_set(request: Request, pk: Annotated[int, Path(...)]) -> Response
@router.put('/{pk}/status', summary='修改用户状态', dependencies=[DependsRBAC]) @router.put('/{pk}/status', summary='修改用户状态', dependencies=[DependsRBAC])
async def status_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def status_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_status(request=request, pk=pk) count = await user_service.update_status(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()
@router.put('/{pk}/multi', summary='修改用户多登录状态', dependencies=[DependsRBAC]) @router.put('/{pk}/multi', summary='修改用户多登录状态', dependencies=[DependsRBAC])
async def multi_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def multi_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_multi_login(request=request, pk=pk) count = await user_service.update_multi_login(request=request, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -153,7 +156,7 @@ async def multi_set(request: Request, pk: Annotated[int, Path(...)]) -> Response
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_user(username: Annotated[str, Path(...)]) -> ResponseModel: async def delete_user(username: Annotated[str, Path(description='用户名')]) -> ResponseModel:
count = await user_service.delete(username=username) count = await user_service.delete(username=username)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
-43
View File
@@ -1,43 +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 BasePath
class AdminSettings(BaseSettings):
"""Admin Settings"""
model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore')
# OAuth2https://github.com/fastapi-practices/fastapi_oauth20
# GitHub
OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback'
# Linux Do
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
OAUTH2_LINUX_DO_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/linux-do/callback'
# Front-end redirect address
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# Captcha
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 过期时间,单位:秒
# Config
CONFIG_BUILT_IN_TYPES: list = ['website', 'protocol', 'policy']
@lru_cache
def get_admin_settings() -> AdminSettings:
"""获取 admin 配置"""
return AdminSettings()
admin_settings = get_admin_settings()
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.conf import admin_settings
from backend.app.admin.model import Config
from backend.app.admin.schema.config import CreateConfigParam, UpdateConfigParam
class CRUDConfig(CRUDPlus[Config]):
async def get(self, db: AsyncSession, pk: int) -> Config | None:
"""
获取系统参数配置
:param db:
:param pk:
:return:
"""
return await self.select_model_by_column(db, id=pk, type__not_in=admin_settings.CONFIG_BUILT_IN_TYPES)
async def get_by_type(self, db: AsyncSession, type: str) -> Sequence[Config]:
"""
通过 type 获取内置系统配置
:param db:
:param type:
:return:
"""
return await self.select_models(db, type=type)
async def get_by_key_and_type(self, db: AsyncSession, key: str, type: str) -> Config | None:
"""
通过 name 和 type 获取内置系统配置
:param db:
:param key:
:param type:
:return:
"""
return await self.select_model_by_column(db, key=key, type=type)
async def get_by_key(self, db: AsyncSession, key: str, built_in: bool = False) -> Config | None:
"""
通过 key 获取系统配置参数
:param db:
:param key:
:param built_in:
:return:
"""
filters = {'key': key}
if not built_in:
filters.update({'type__not_in': admin_settings.CONFIG_BUILT_IN_TYPES})
return await self.select_model_by_column(db, **filters)
async def get_list(self, name: str = None, type: str = None) -> Select:
"""
获取系统参数配置列表
:param name:
:param type:
:return:
"""
filters = {'type__not_in': admin_settings.CONFIG_BUILT_IN_TYPES}
if name is not None:
filters.update(name__like=f'%{name}%')
if type is not None:
filters.update(type__like=f'%{type}%')
return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj_in: CreateConfigParam) -> None:
"""
创建 Config
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateConfigParam) -> int:
"""
更新 Config
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除 Config
:param db:
:param pk:
:return:
"""
return await self.delete_model_by_column(
db, allow_multiple=True, id__in=pk, type__not_in=admin_settings.CONFIG_BUILT_IN_TYPES
)
config_dao: CRUDConfig = CRUDConfig(Config)
+37 -31
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import Select, desc, select from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload from sqlalchemy.orm import noload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
@@ -12,76 +12,82 @@ 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:
""" """
获取数据权限规则 获取规则详情
:param db: :param db: 数据库会话
:param pk: :param pk: 规则 ID
:return: :return:
""" """
return await self.select_model(db, pk) return await self.select_model(db, pk)
async def get_list(self, name: str = None) -> Select: async def get_list(self, name: str | None) -> Select:
""" """
获取数据权限规则列表 获取规则列表
: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))
where_list = []
filters = []
if name is not None: if name is not None:
where_list.append(self.model.name.like(f'%{name}%')) filters.append(self.model.name.like(f'%{name}%'))
if where_list:
stmt = stmt.where(*where_list) if filters:
stmt = stmt.where(and_(*filters))
return stmt return stmt
async def get_by_name(self, db: AsyncSession, name: str): async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
""" """
通过 name 获取数据权限规则 通过名称获取规则
:param db: :param db: 数据库会话
:param name: :param name: 规则名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name) return await self.select_model_by_column(db, name=name)
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]: async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
""" """
获取所有数据权限规则 获取所有规则
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.select_models(db) return await self.select_models(db)
async def create(self, db: AsyncSession, obj_in: CreateDataRuleParam) -> None: async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
""" """
创建数据权限规则 创建规则
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建规则参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateDataRuleParam) -> int: async def update(self, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
""" """
更新数据权限规则 更新规则
:param db: :param db: 数据库会话
:param pk: :param pk: 规则 ID
:param obj_in: :param obj: 更新规则参数
:return: :return:
""" """
return await self.update_model(db, pk, obj_in) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pk: list[int]) -> int:
""" """
删除数据权限规则 删除规则
:param db: :param db: 数据库会话
:param pk: :param pk: 规则 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
+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)
+47 -38
View File
@@ -2,47 +2,58 @@
# -*- 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
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept, User 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]):
"""部门数据库操作类"""
async def get(self, db: AsyncSession, dept_id: int) -> Dept | None: async def get(self, db: AsyncSession, dept_id: int) -> Dept | None:
""" """
获取部门 获取部门详情
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
return await self.select_model_by_column(db, id=dept_id, del_flag=0) return await self.select_model_by_column(db, id=dept_id, del_flag=0)
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None: async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
""" """
通过 name 获取 API 通过名称获取部门
:param db: :param db: 数据库会话
:param name: :param name: 部门名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name, del_flag=0) return await self.select_model_by_column(db, name=name, del_flag=0)
async def get_all( async def get_all(
self, db: AsyncSession, name: str = None, leader: str = None, phone: str = None, status: int = None self,
request: Request,
db: AsyncSession,
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> Sequence[Dept]: ) -> Sequence[Dept]:
""" """
获取所有部门 获取所有部门
:param db: :param request: FastAPI 请求对象
:param name: :param db: 数据库会话
:param leader: :param name: 部门名称
:param phone: :param leader: 负责人
:param status: :param phone: 联系电话
:param status: 部门状态
:return: :return:
""" """
filters = {'del_flag__eq': 0} filters = {'del_flag__eq': 0}
@@ -54,64 +65,62 @@ 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_in: CreateDeptParam) -> None: async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
创建部门 创建部门
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建部门参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db: AsyncSession, dept_id: int, obj_in: UpdateDeptParam) -> int: async def update(self, db: AsyncSession, dept_id: int, obj: UpdateDeptParam) -> int:
""" """
更新部门 更新部门
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:param obj_in: :param obj: 更新部门参数
:return: :return:
""" """
return await self.update_model(db, dept_id, obj_in) return await self.update_model(db, dept_id, obj)
async def delete(self, db: AsyncSession, dept_id: int) -> int: async def delete(self, db: AsyncSession, dept_id: int) -> int:
""" """
删除部门 删除部门
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag') return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag')
async def get_with_relation(self, db: AsyncSession, dept_id: int) -> list[User]: async def get_with_relation(self, db: AsyncSession, dept_id: int) -> Dept | None:
""" """
获取关联 获取部门及关联数据
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.users)).where(self.model.id == dept_id) stmt = select(self.model).options(selectinload(self.model.users)).where(self.model.id == dept_id)
result = await db.execute(stmt) result = await db.execute(stmt)
user_relation = result.scalars().first() return result.scalars().first()
return user_relation.users
async def get_children(self, db: AsyncSession, dept_id: int) -> list[Dept]: async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
""" """
获取子部门 获取子部门列表
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == dept_id) stmt = select(self.model).where(self.model.parent_id == dept_id, self.model.del_flag == 0)
result = await db.execute(stmt) result = await db.execute(stmt)
dept = result.scalars().first() return result.scalars().all()
return dept.children
dept_dao: CRUDDept = CRUDDept(Dept) dept_dao: CRUDDept = CRUDDept(Dept)
-98
View File
@@ -1,98 +0,0 @@
#!/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 DictData
from backend.app.admin.schema.dict_data import CreateDictDataParam, UpdateDictDataParam
class CRUDDictData(CRUDPlus[DictData]):
async def get(self, db: AsyncSession, pk: int) -> DictData | None:
"""
获取字典数据
:param db:
:param pk:
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, label: str = None, value: str = None, status: int = None) -> Select:
"""
获取所有字典数据
:param label:
:param value:
:param status:
:return:
"""
stmt = select(self.model).options(noload(self.model.type)).order_by(desc(self.model.sort))
where_list = []
if label is not None:
where_list.append(self.model.label.like(f'%{label}%'))
if value is not None:
where_list.append(self.model.value.like(f'%{value}%'))
if status is not None:
where_list.append(self.model.status == status)
if where_list:
stmt = stmt.where(and_(*where_list))
return stmt
async def get_by_label(self, db: AsyncSession, label: str) -> DictData | None:
"""
通过 label 获取字典数据
:param db:
:param label:
:return:
"""
return await self.select_model_by_column(db, label=label)
async def create(self, db: AsyncSession, obj_in: CreateDictDataParam) -> None:
"""
创建数据字典
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateDictDataParam) -> int:
"""
更新数据字典
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除字典数据
:param db:
:param pk:
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DictData | None:
"""
获取字典数据和类型
:param db:
:param pk:
:return:
"""
stmt = select(self.model).options(selectinload(self.model.type)).where(self.model.id == pk)
dict_data = await db.execute(stmt)
return dict_data.scalars().first()
dict_data_dao: CRUDDictData = CRUDDictData(DictData)
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DictType
from backend.app.admin.schema.dict_type import CreateDictTypeParam, UpdateDictTypeParam
class CRUDDictType(CRUDPlus[DictType]):
async def get(self, db: AsyncSession, pk: int) -> DictType | None:
"""
获取字典类型
:param db:
:param pk:
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, *, name: str = None, code: str = None, status: int = None) -> Select:
"""
获取所有字典类型
:param name:
:param code:
:param status:
:return:
"""
filters = {}
if name is not None:
filters.update(name__like=f'%{name}%')
if code is not None:
filters.update(code__like=f'%{code}%')
if status is not None:
filters.update(status=status)
return await self.select_order('created_time', 'desc', **filters)
async def get_by_code(self, db: AsyncSession, code: str) -> DictType | None:
"""
通过 code 获取字典类型
:param db:
:param code:
:return:
"""
return await self.select_model_by_column(db, code=code)
async def create(self, db: AsyncSession, obj_in: CreateDictTypeParam) -> None:
"""
创建字典类型
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateDictTypeParam) -> int:
"""
更新字典类型
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除字典类型
:param db:
:param pk:
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
dict_type_dao: CRUDDictType = CRUDDictType(DictType)
+14 -12
View File
@@ -9,13 +9,15 @@ 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:
""" """
获取登录日志列表 获取登录日志列表
:param username: :param username: 用户名
:param status: :param status: 登录状态
:param ip: :param ip: IP 地址
:return: :return:
""" """
filters = {} filters = {}
@@ -27,31 +29,31 @@ class CRUDLoginLog(CRUDPlus[LoginLog]):
filters.update(ip__like=f'%{ip}%') filters.update(ip__like=f'%{ip}%')
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj_in: CreateLoginLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
""" """
创建登录日志 创建登录日志
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建登录日志参数
:return: :return:
""" """
await self.create_model(db, obj_in, commit=True) await self.create_model(db, obj, commit=True)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pk: list[int]) -> int:
""" """
删除登录日志 删除登录日志
:param db: :param db: 数据库会话
:param pk: :param pk: 登录日志 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
async def delete_all(self, db: AsyncSession) -> int: async def delete_all(self, db: AsyncSession) -> int:
""" """
删除所有登录日志 删除所有日志
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True) return await self.delete_model_by_column(db, allow_multiple=True)
+42 -39
View File
@@ -3,6 +3,7 @@
from typing import Sequence from typing import Sequence
from sqlalchemy import and_, asc, select from sqlalchemy import and_, asc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
@@ -11,33 +12,35 @@ from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
class CRUDMenu(CRUDPlus[Menu]): class CRUDMenu(CRUDPlus[Menu]):
async def get(self, db, menu_id: int) -> Menu | None: """菜单数据库操作类"""
"""
获取菜单
:param db: async def get(self, db: AsyncSession, menu_id: int) -> Menu | None:
:param menu_id: """
获取菜单详情
:param db: 数据库会话
:param menu_id: 菜单 ID
:return: :return:
""" """
return await self.select_model(db, menu_id) return await self.select_model(db, menu_id)
async def get_by_title(self, db, title: str) -> Menu | None: async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
""" """
通过 title 获取菜单 通过标题获取菜单
:param db: :param db: 数据库会话
: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, 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]:
""" """
获取所有菜单 获取菜单列表
:param db: :param db: 数据库会话
:param title: :param title: 菜单标题
:param status: :param status: 菜单状态
:return: :return:
""" """
filters = {} filters = {}
@@ -47,60 +50,60 @@ class CRUDMenu(CRUDPlus[Menu]):
filters.update(status=status) filters.update(status=status)
return await self.select_models_order(db, 'sort', **filters) return await self.select_models_order(db, 'sort', **filters)
async def get_role_menus(self, db, superuser: bool, menu_ids: list[int]) -> Sequence[Menu]: async def get_role_menus(self, db: AsyncSession, superuser: bool, menu_ids: list[int]) -> Sequence[Menu]:
""" """
获取角色菜单 获取角色菜单列表
:param db: :param db: 数据库会话
:param superuser: :param superuser: 是否超级管理员
:param menu_ids: :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
stmt = select(self.model).order_by(asc(self.model.sort)) stmt = select(self.model).order_by(asc(self.model.sort))
where_list = [self.model.menu_type.in_([0, 1])] filters = [self.model.type.in_([0, 1])]
if not superuser: if not superuser:
where_list.append(self.model.id.in_(menu_ids)) filters.append(self.model.id.in_(menu_ids))
stmt = stmt.where(and_(*where_list)) stmt = stmt.where(and_(*filters))
menu = await db.execute(stmt) menu = await db.execute(stmt)
return menu.scalars().all() return menu.scalars().all()
async def create(self, db, obj_in: CreateMenuParam) -> None: async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
""" """
创建菜单 创建菜单
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建菜单参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db, menu_id: int, obj_in: UpdateMenuParam) -> int: async def update(self, db: AsyncSession, menu_id: int, obj: UpdateMenuParam) -> int:
""" """
更新菜单 更新菜单
:param db: :param db: 数据库会话
:param menu_id: :param menu_id: 菜单 ID
:param obj_in: :param obj: 更新菜单参数
:return: :return:
""" """
return await self.update_model(db, menu_id, obj_in) return await self.update_model(db, menu_id, obj)
async def delete(self, db, menu_id: int) -> int: async def delete(self, db: AsyncSession, menu_id: int) -> int:
""" """
删除菜单 删除菜单
:param db: :param db: 数据库会话
:param menu_id: :param menu_id: 菜单 ID
:return: :return:
""" """
return await self.delete_model(db, menu_id) return await self.delete_model(db, menu_id)
async def get_children(self, db, menu_id: int) -> list[Menu]: async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]:
""" """
获取子菜单 获取子菜单列表
:param db: :param db: 数据库会话
:param menu_id: :param menu_id: 菜单 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == menu_id) stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == menu_id)
+16 -14
View File
@@ -9,49 +9,51 @@ 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:
""" """
获取操作日志列表 获取操作日志列表
:param username: :param username: 用户名
:param status: :param status: 操作状态
:param ip: :param ip: IP 地址
:return: :return:
""" """
filters = {} filters = {}
if username is not None: if username is not None:
filters.update(username=f'%{username}%') filters.update(username__like=f'%{username}%')
if status is not None: if status is not None:
filters.update(status=status) filters.update(status=status)
if ip is not None: if ip is not None:
filters.update(ip=f'%{ip}%') filters.update(ip__like=f'%{ip}%')
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj_in: CreateOperaLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
""" """
创建操作日志 创建操作日志
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建操作日志参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pk: list[int]) -> int:
""" """
删除操作日志 删除操作日志
:param db: :param db: 数据库会话
:param pk: :param pk: 操作日志 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
async def delete_all(self, db: AsyncSession) -> int: async def delete_all(self, db: AsyncSession) -> int:
""" """
删除所有操作日志 删除所有日志
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True) return await self.delete_model_by_column(db, allow_multiple=True)
+64 -60
View File
@@ -2,158 +2,162 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import Select, desc, select from sqlalchemy import Select, and_, desc, select
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,
) )
class CRUDRole(CRUDPlus[Role]): class CRUDRole(CRUDPlus[Role]):
async def get(self, db, role_id: int) -> Role | None: """角色数据库操作类"""
"""
获取角色
:param db: async def get(self, db: AsyncSession, role_id: int) -> Role | None:
:param role_id: """
获取角色详情
:param db: 数据库会话
:param role_id: 角色 ID
:return: :return:
""" """
return await self.select_model(db, role_id) return await self.select_model(db, role_id)
async def get_with_relation(self, db, role_id: int) -> Role | None: async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None:
""" """
获取角色和菜单 获取角色及关联数据
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:return: :return:
""" """
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)
return role.scalars().first() return role.scalars().first()
async def get_all(self, db) -> Sequence[Role]: async def get_all(self, db: AsyncSession) -> Sequence[Role]:
""" """
获取所有角色 获取所有角色
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.select_models(db) return await self.select_models(db)
async def get_by_user(self, db, user_id: int) -> Sequence[Role]: async def get_users(self, db: AsyncSession, user_id: int) -> Sequence[Role]:
""" """
获取用户所有角色 获取用户角色列表
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
stmt = select(self.model).join(self.model.users).where(User.id == user_id) stmt = select(self.model).join(self.model.users).where(User.id == user_id)
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, status: int = None) -> Select: async def get_list(self, name: str | None, status: int | None) -> Select:
""" """
获取角色列表 获取角色列表
:param name: :param name: 角色名称
:param status: :param status: 角色状态
:return: :return:
""" """
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))
) )
where_list = []
if name: filters = []
where_list.append(self.model.name.like(f'%{name}%')) if name is not None:
filters.append(self.model.name.like(f'%{name}%'))
if status is not None: if status is not None:
where_list.append(self.model.status == status) filters.append(self.model.status == status)
if where_list:
stmt = stmt.where(*where_list) if filters:
stmt = stmt.where(and_(*filters))
return stmt return stmt
async def get_by_name(self, db, name: str) -> Role | None: async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
""" """
通过 name 获取角色 通过名称获取角色
:param db: :param db: 数据库会话
:param name: :param name: 角色名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name) return await self.select_model_by_column(db, name=name)
async def create(self, db, obj_in: CreateRoleParam) -> None: async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
""" """
创建角色 创建角色
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建角色参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db, role_id: int, obj_in: UpdateRoleParam) -> int: async def update(self, db: AsyncSession, role_id: int, obj: UpdateRoleParam) -> int:
""" """
更新角色 更新角色
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:param obj_in: :param obj: 更新角色参数
:return: :return:
""" """
return await self.update_model(db, role_id, obj_in) return await self.update_model(db, role_id, obj)
async def update_menus(self, db, role_id: int, menu_ids: UpdateRoleMenuParam) -> int: async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
""" """
更新角色菜单 更新角色菜单
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:param menu_ids: :param menu_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(Menu).where(Menu.id.in_(menu_ids.menus)) stmt = select(Menu).where(Menu.id.in_(menu_ids.menus))
menus = await db.execute(stmt) menus = await db.execute(stmt)
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, 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: :param role_id: 角色 ID
:param rule_ids: :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(DataScope).where(DataScope.id.in_(scope_ids.scopes))
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) scopes = await db.execute(stmt)
rules = await db.execute(stmt) current_role.scopes = scopes.scalars().all()
current_role.rules = rules.scalars().all() return len(current_role.scopes)
return len(current_role.rules)
async def delete(self, db, role_id: list[int]) -> int: async def delete(self, db: AsyncSession, role_id: list[int]) -> int:
""" """
删除角色 删除角色
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_id) return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_id)
+102 -93
View File
@@ -21,42 +21,44 @@ from backend.utils.timezone import timezone
class CRUDUser(CRUDPlus[User]): class CRUDUser(CRUDPlus[User]):
"""用户数据库操作类"""
async def get(self, db: AsyncSession, user_id: int) -> User | None: async def get(self, db: AsyncSession, user_id: int) -> User | None:
""" """
获取用户 获取用户详情
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
return await self.select_model(db, user_id) return await self.select_model(db, user_id)
async def get_by_username(self, db: AsyncSession, username: str) -> User | None: async def get_by_username(self, db: AsyncSession, username: str) -> User | None:
""" """
通过 username 获取用户 通过用户名获取用户
:param db: :param db: 数据库会话
:param username: :param username: 用户名
:return: :return:
""" """
return await self.select_model_by_column(db, username=username) return await self.select_model_by_column(db, username=username)
async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None: async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
""" """
通过 nickname 获取用户 通过昵称获取用户
:param db: :param db: 数据库会话
:param nickname: :param nickname: 用户昵称
:return: :return:
""" """
return await self.select_model_by_column(db, nickname=nickname) return await self.select_model_by_column(db, nickname=nickname)
async def update_login_time(self, db: AsyncSession, username: str) -> int: async def update_login_time(self, db: AsyncSession, username: str) -> int:
""" """
更新用户登录时间 更新用户最后登录时间
:param db: :param db: 数据库会话
:param username: :param username: 用户名
:return: :return:
""" """
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username) return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
@@ -65,9 +67,9 @@ class CRUDUser(CRUDPlus[User]):
""" """
创建用户 创建用户
:param db: :param db: 数据库会话
:param obj: :param obj: 注册用户参数
:param social: 社交用户,适配 oauth 2.0 :param social: 是否社交用户
:return: :return:
""" """
if not social: if not social:
@@ -83,10 +85,10 @@ class CRUDUser(CRUDPlus[User]):
async def add(self, db: AsyncSession, obj: AddUserParam) -> None: async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
""" """
后台添加用户 添加用户
:param db: :param db: 数据库会话
:param obj: :param obj: 添加用户参数
:return: :return:
""" """
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
@@ -94,19 +96,21 @@ class CRUDUser(CRUDPlus[User]):
dict_obj = obj.model_dump(exclude={'roles'}) dict_obj = obj.model_dump(exclude={'roles'})
dict_obj.update({'salt': salt}) dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj) new_user = self.model(**dict_obj)
role_list = [] role_list = []
for role_id in obj.roles: for role_id in obj.roles:
role_list.append(await db.get(Role, role_id)) role_list.append(await db.get(Role, role_id))
new_user.roles.extend(role_list) new_user.roles.extend(role_list)
db.add(new_user) db.add(new_user)
async def update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int: async def update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int:
""" """
更新用户信息 更新用户信息
:param db: :param db: 数据库会话
:param input_user: :param input_user: 用户 ID
:param obj: :param obj: 更新用户参数
:return: :return:
""" """
return await self.update_model(db, input_user, obj) return await self.update_model(db, input_user, obj)
@@ -116,15 +120,14 @@ class CRUDUser(CRUDPlus[User]):
""" """
更新用户角色 更新用户角色
:param db: :param db: 数据库会话
:param input_user: :param input_user: 用户对象
:param obj: :param obj: 更新角色参数
:return: :return:
""" """
# 删除用户所有角色
for i in list(input_user.roles): for i in list(input_user.roles):
input_user.roles.remove(i) input_user.roles.remove(i)
# 添加用户角色
role_list = [] role_list = []
for role_id in obj.roles: for role_id in obj.roles:
role_list.append(await db.get(Role, role_id)) role_list.append(await db.get(Role, role_id))
@@ -134,29 +137,29 @@ class CRUDUser(CRUDPlus[User]):
""" """
更新用户头像 更新用户头像
:param db: :param db: 数据库会话
:param input_user: :param input_user: 用户 ID
: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:
""" """
删除用户 删除用户
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
return await self.delete_model(db, user_id) return await self.delete_model(db, user_id)
async def check_email(self, db: AsyncSession, email: str) -> User | None: async def check_email(self, db: AsyncSession, email: str) -> User | None:
""" """
检查邮箱是否存在 检查邮箱是否已被注册
:param db: :param db: 数据库会话
:param email: :param email: 电子邮箱
:return: :return:
""" """
return await self.select_model_by_column(db, email=email) return await self.select_model_by_column(db, email=email)
@@ -165,51 +168,53 @@ class CRUDUser(CRUDPlus[User]):
""" """
重置用户密码 重置用户密码
:param db: :param db: 数据库会话
:param pk: :param pk: 用户 ID
:param new_pwd: :param new_pwd: 新密码(已加密)
:return: :return:
""" """
return await self.update_model(db, pk, {'password': new_pwd}) return await self.update_model(db, pk, {'password': new_pwd})
async def get_list(self, dept: int = None, username: str = None, phone: str = None, status: int = None) -> Select: async def get_list(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
""" """
获取用户列表 获取用户列表
:param dept: :param dept: 部门 ID
:param username: :param username: 用户名
:param phone: :param phone: 电话号码
:param status: :param status: 用户状态
:return: :return:
""" """
stmt = ( stmt = (
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))
) )
where_list = []
filters = []
if dept: if dept:
where_list.append(self.model.dept_id == dept) filters.append(self.model.dept_id == dept)
if username: if username:
where_list.append(self.model.username.like(f'%{username}%')) filters.append(self.model.username.like(f'%{username}%'))
if phone: if phone:
where_list.append(self.model.phone.like(f'%{phone}%')) filters.append(self.model.phone.like(f'%{phone}%'))
if status is not None: if status is not None:
where_list.append(self.model.status == status) filters.append(self.model.status == status)
if where_list:
stmt = stmt.where(and_(*where_list)) if filters:
stmt = stmt.where(and_(*filters))
return stmt return stmt
async def get_super(self, db: AsyncSession, user_id: int) -> bool: async def get_super(self, db: AsyncSession, user_id: int) -> bool:
""" """
获取用户超级管理员状态 获取用户是否为超级管理员
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
@@ -217,10 +222,10 @@ class CRUDUser(CRUDPlus[User]):
async def get_staff(self, db: AsyncSession, user_id: int) -> bool: async def get_staff(self, db: AsyncSession, user_id: int) -> bool:
""" """
获取用户后台登录状态 获取用户是否可以登录后台
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
@@ -230,8 +235,8 @@ class CRUDUser(CRUDPlus[User]):
""" """
获取用户状态 获取用户状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
@@ -239,81 +244,85 @@ class CRUDUser(CRUDPlus[User]):
async def get_multi_login(self, db: AsyncSession, user_id: int) -> bool: async def get_multi_login(self, db: AsyncSession, user_id: int) -> bool:
""" """
获取用户多点登录状态 获取用户是否允许多端登录
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
return user.is_multi_login return user.is_multi_login
async def set_super(self, db: AsyncSession, user_id: int, _super: bool) -> int: async def set_super(self, db: AsyncSession, user_id: int, is_super: bool) -> int:
""" """
设置用户超级管理员 设置用户超级管理员状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param _super: :param is_super: 是否超级管理员
:return: :return:
""" """
return await self.update_model(db, user_id, {'is_superuser': _super}) return await self.update_model(db, user_id, {'is_superuser': is_super})
async def set_staff(self, db: AsyncSession, user_id: int, staff: bool) -> int: async def set_staff(self, db: AsyncSession, user_id: int, is_staff: bool) -> int:
""" """
设置用户后台登录 设置用户后台登录状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param staff: :param is_staff: 是否可登录后台
:return: :return:
""" """
return await self.update_model(db, user_id, {'is_staff': staff}) return await self.update_model(db, user_id, {'is_staff': is_staff})
async def set_status(self, db: AsyncSession, user_id: int, status: bool) -> int: async def set_status(self, db: AsyncSession, user_id: int, status: int) -> int:
""" """
设置用户状态 设置用户状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param status: :param status: 状态
:return: :return:
""" """
return await self.update_model(db, user_id, {'status': status}) return await self.update_model(db, user_id, {'status': status})
async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int: async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int:
""" """
设置用户多登录 设置用户多登录状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param multi_login: :param multi_login: 是否允许多端登录
:return: :return:
""" """
return await self.update_model(db, user_id, {'is_multi_login': multi_login}) return await self.update_model(db, user_id, {'is_multi_login': multi_login})
async def get_with_relation(self, db: AsyncSession, *, user_id: int = None, username: str = None) -> User | None: async def get_with_relation(
self, db: AsyncSession, *, user_id: int | None = None, username: str | None = None
) -> User | None:
""" """
获取用户和(部门,角色,菜单,规则) 获取用户关联信息
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param username: :param username: 用户名
:return: :return:
""" """
stmt = select(self.model).options( stmt = select(self.model).options(
selectinload(self.model.dept), selectinload(self.model.dept),
selectinload(self.model.roles).options( selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes)),
selectinload(Role.menus),
selectinload(Role.rules),
),
) )
filters = [] filters = []
if user_id: if user_id:
filters.append(self.model.id == user_id) filters.append(self.model.id == user_id)
if username: if username:
filters.append(self.model.username == username) filters.append(self.model.username == username)
user = await db.execute(stmt.where(*filters))
if filters:
stmt = stmt.where(and_(*filters))
user = await db.execute(stmt)
return user.scalars().first() return user.scalars().first()
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import UserSocial
from backend.app.admin.schema.user_social import CreateUserSocialParam
from backend.common.enums import UserSocialType
class CRUDOUserSocial(CRUDPlus[UserSocial]):
async def get(self, db: AsyncSession, pk: int, source: UserSocialType) -> UserSocial | None:
"""
获取用户社交账号绑定
:param db:
:param pk:
:param source:
:return:
"""
return await self.select_model_by_column(db, user_id=pk, source=source)
async def create(self, db: AsyncSession, obj_in: CreateUserSocialParam) -> None:
"""
创建用户社交账号绑定
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def delete(self, db: AsyncSession, social_id: int) -> int:
"""
删除用户社交账号绑定
:param db:
:param social_id:
:return:
"""
return await self.delete_model(db, social_id)
user_social_dao: CRUDOUserSocial = CRUDOUserSocial(UserSocial)
+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
+17 -8
View File
@@ -1,26 +1,35 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from sqlalchemy import String from __future__ import annotations
from typing import TYPE_CHECKING
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:
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') # noqa: F821 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')
+9 -4
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Union from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, String from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import INTEGER from sqlalchemy.dialects.postgresql import INTEGER
@@ -8,6 +10,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class Dept(Base): class Dept(Base):
"""部门表""" """部门表"""
@@ -29,8 +34,8 @@ class Dept(Base):
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID' ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
) )
parent: Mapped[Union['Dept', None]] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list['Dept'] | None] = relationship(init=False, back_populates='parent') children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent')
# 部门用户一对多 # 部门用户一对多
users: Mapped[list['User']] = relationship(init=False, back_populates='dept') # noqa: F821 users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
+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',
), ),
) )
+14 -6
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Union from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
@@ -10,6 +12,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_menu from backend.app.admin.model.m2m import sys_role_menu
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import Role
class Menu(Base): class Menu(Base):
"""菜单表""" """菜单表"""
@@ -19,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] = 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按钮)')
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='备注'
) )
@@ -36,8 +44,8 @@ class Menu(Base):
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID' ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
) )
parent: Mapped[Union['Menu', None]] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list['Menu'] | None] = relationship(init=False, back_populates='parent') children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent')
# 菜单角色多对多 # 菜单角色多对多
roles: Mapped[list['Role']] = relationship(init=False, secondary=sys_role_menu, back_populates='menus') # noqa: F821 roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
+2 -1
View File
@@ -3,7 +3,8 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, String
from sqlalchemy.dialects.mysql import JSON, LONGTEXT, TEXT from sqlalchemy.dialects.mysql import JSON, LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, id_key from backend.common.model import DataClassBase, id_key
+12 -5
View File
@@ -1,13 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT 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:
from backend.app.admin.model import DataScope, Menu, User
class Role(Base): class Role(Base):
"""角色表""" """角色表"""
@@ -22,10 +29,10 @@ class Role(Base):
) )
# 角色用户多对多 # 角色用户多对多
users: Mapped[list['User']] = relationship(init=False, secondary=sys_user_role, back_populates='roles') # noqa: F821 users: Mapped[list[User]] = relationship(init=False, secondary=sys_user_role, back_populates='roles')
# 角色菜单多对多 # 角色菜单多对多
menus: Mapped[list['Menu']] = relationship(init=False, secondary=sys_role_menu, back_populates='roles') # noqa: F821 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') # noqa: F821 scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
+9 -7
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Union from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, DateTime, ForeignKey, String from sqlalchemy import VARBINARY, Boolean, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
@@ -12,6 +14,9 @@ from backend.common.model import Base, id_key
from backend.database.db import uuid4_str from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role
class User(Base): class User(Base):
"""用户表""" """用户表"""
@@ -31,7 +36,7 @@ class User(Base):
is_staff: Mapped[bool] = mapped_column( is_staff: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)'
) )
status: Mapped[int] = mapped_column(default=1, comment='用户账号状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_multi_login: Mapped[bool] = mapped_column( is_multi_login: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)'
) )
@@ -48,10 +53,7 @@ class User(Base):
dept_id: Mapped[int | None] = mapped_column( dept_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID' ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID'
) )
dept: Mapped[Union['Dept', None]] = relationship(init=False, back_populates='users') # noqa: F821 dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户社交信息一对多
socials: Mapped[list['UserSocial']] = relationship(init=False, back_populates='user') # noqa: F821
# 用户角色多对多 # 用户角色多对多
roles: Mapped[list['Role']] = relationship(init=False, secondary=sys_user_role, back_populates='users') # noqa: F821 roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
+2
View File
@@ -6,5 +6,7 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase): class GetCaptchaDetail(SchemaBase):
"""验证码详情"""
image_type: str = Field(description='图片类型') image_type: str = Field(description='图片类型')
image: str = Field(description='图片内容') image: str = Field(description='图片内容')
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict
from backend.common.schema import SchemaBase
class SaveBuiltInConfigParam(SchemaBase):
name: str
key: str
value: str
class ConfigSchemaBase(SchemaBase):
name: str
type: str | None
key: str
value: str
is_frontend: bool
remark: str | None
class CreateConfigParam(ConfigSchemaBase):
pass
class UpdateConfigParam(ConfigSchemaBase):
pass
class GetConfigDetail(ConfigSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_time: datetime
updated_time: datetime | None = None
+21 -13
View File
@@ -9,28 +9,36 @@ from backend.common.schema import SchemaBase
class DataRuleSchemaBase(SchemaBase): class DataRuleSchemaBase(SchemaBase):
name: str """数据规则基础模型"""
model: str
column: str name: str = Field(description='规则名称')
operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.OR) model: str = Field(description='模型名称')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq) column: str = Field(description='字段名称')
value: str operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.AND, description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型')
value: str = Field(description='规则值')
class CreateDataRuleParam(DataRuleSchemaBase): class CreateDataRuleParam(DataRuleSchemaBase):
pass """创建数据规则参数"""
class UpdateDataRuleParam(DataRuleSchemaBase): class UpdateDataRuleParam(DataRuleSchemaBase):
pass """更新数据规则参数"""
class GetDataRuleDetail(DataRuleSchemaBase): class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='规则 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
def __hash__(self):
return hash(self.name) class GetDataRuleColumnDetail(SchemaBase):
"""数据规则可用模型字段详情"""
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='数据规则列表')
+17 -13
View File
@@ -9,27 +9,31 @@ from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
class DeptSchemaBase(SchemaBase): class DeptSchemaBase(SchemaBase):
name: str """部门基础模型"""
parent_id: int | None = Field(default=None, description='部门父级ID')
sort: int = Field(default=0, ge=0, description='排序') name: str = Field(description='部门名称')
leader: str | None = None parent_id: int | None = Field(None, description='部门父级 ID')
phone: CustomPhoneNumber | None = None sort: int = Field(0, ge=0, description='排序')
email: CustomEmailStr | None = None leader: str | None = Field(None, description='负责人')
status: StatusType = Field(default=StatusType.enable) phone: CustomPhoneNumber | None = Field(None, description='联系电话')
email: CustomEmailStr | None = Field(None, description='邮箱')
status: StatusType = Field(StatusType.enable, description='状态')
class CreateDeptParam(DeptSchemaBase): class CreateDeptParam(DeptSchemaBase):
pass """创建部门参数"""
class UpdateDeptParam(DeptSchemaBase): class UpdateDeptParam(DeptSchemaBase):
pass """更新部门参数"""
class GetDeptDetail(DeptSchemaBase): class GetDeptDetail(DeptSchemaBase):
"""部门详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='部门 ID')
del_flag: bool del_flag: bool = Field(description='是否删除')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
-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.app.admin.schema.dict_type import GetDictTypeDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DictDataSchemaBase(SchemaBase):
type_id: int
label: str
value: str
sort: int
status: StatusType = Field(default=StatusType.enable)
remark: str | None = None
class CreateDictDataParam(DictDataSchemaBase):
pass
class UpdateDictDataParam(DictDataSchemaBase):
pass
class GetDictDataDetail(DictDataSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
type: GetDictTypeDetail | None = None
created_time: datetime
updated_time: datetime | None = None
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DictTypeSchemaBase(SchemaBase):
name: str
code: str
status: StatusType = Field(default=StatusType.enable)
remark: str | None = None
class CreateDictTypeParam(DictTypeSchemaBase):
pass
class UpdateDictTypeParam(DictTypeSchemaBase):
pass
class GetDictTypeDetail(DictTypeSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_time: datetime
updated_time: datetime | None = None
+22 -18
View File
@@ -2,37 +2,41 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict from pydantic import ConfigDict, Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class LoginLogSchemaBase(SchemaBase): class LoginLogSchemaBase(SchemaBase):
user_uuid: str """登录日志基础模型"""
username: str
status: int user_uuid: str = Field(description='用户 UUID')
ip: str username: str = Field(description='用户名')
country: str | None status: int = Field(description='登录状态')
region: str | None ip: str = Field(description='IP 地址')
city: str | None country: str | None = Field(None, description='国家')
user_agent: str region: str | None = Field(None, description='地区')
browser: str | None city: str | None = Field(None, description='城市')
os: str | None user_agent: str = Field(description='用户代理')
device: str | None browser: str | None = Field(None, description='浏览器')
msg: str os: str | None = Field(None, description='操作系统')
login_time: datetime device: str | None = Field(None, description='设备')
msg: str = Field(description='消息')
login_time: datetime = Field(description='登录时间')
class CreateLoginLogParam(LoginLogSchemaBase): class CreateLoginLogParam(LoginLogSchemaBase):
pass """创建登录日志参数"""
class UpdateLoginLogParam(LoginLogSchemaBase): class UpdateLoginLogParam(LoginLogSchemaBase):
pass """更新登录日志参数"""
class GetLoginLogDetail(LoginLogSchemaBase): class GetLoginLogDetail(LoginLogSchemaBase):
"""登录日志详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='日志 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
+23 -18
View File
@@ -9,32 +9,37 @@ from backend.common.schema import SchemaBase
class MenuSchemaBase(SchemaBase): class MenuSchemaBase(SchemaBase):
title: str """菜单基础模型"""
name: str
parent_id: int | None = Field(default=None, description='菜单父级ID') title: str = Field(description='菜单标题')
sort: int = Field(default=0, ge=0, description='排序') name: str = Field(description='菜单名称')
icon: str | None = None path: str = Field(description='路由路径')
path: str | None = None parent_id: int | None = Field(None, description='菜单父级 ID')
menu_type: MenuType = Field(default=MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)') sort: int = Field(0, ge=0, description='排序')
component: str | None = None icon: str | None = Field(None, description='图标')
perms: str | None = None type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)')
status: StatusType = Field(default=StatusType.enable) component: str | None = Field(None, description='组件路径')
display: StatusType = Field(default=StatusType.enable) perms: str | None = Field(None, description='权限标识')
cache: StatusType = Field(default=StatusType.enable) status: StatusType = Field(StatusType.enable, description='状态')
remark: str | None = None display: StatusType = Field(StatusType.enable, description='是否显示')
cache: StatusType = Field(StatusType.enable, description='是否缓存')
link: str | None = Field(None, description='外链地址')
remark: str | None = Field(None, description='备注')
class CreateMenuParam(MenuSchemaBase): class CreateMenuParam(MenuSchemaBase):
pass """创建菜单参数"""
class UpdateMenuParam(MenuSchemaBase): class UpdateMenuParam(MenuSchemaBase):
pass """更新菜单参数"""
class GetMenuDetail(MenuSchemaBase): class GetMenuDetail(MenuSchemaBase):
"""菜单详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='菜单 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
+28 -23
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -9,37 +10,41 @@ from backend.common.schema import SchemaBase
class OperaLogSchemaBase(SchemaBase): class OperaLogSchemaBase(SchemaBase):
trace_id: str """操作日志基础模型"""
username: str | None = None
method: str trace_id: str = Field(description='追踪 ID')
title: str username: str | None = Field(None, description='用户名')
path: str method: str = Field(description='请求方法')
ip: str title: str = Field(description='操作标题')
country: str | None = None path: str = Field(description='请求路径')
region: str | None = None ip: str = Field(description='IP 地址')
city: str | None = None country: str | None = Field(None, description='国家')
user_agent: str region: str | None = Field(None, description='地区')
os: str | None = None city: str | None = Field(None, description='城市')
browser: str | None = None user_agent: str = Field(description='用户代理')
device: str | None = None os: str | None = Field(None, description='操作系统')
args: dict | None = None browser: str | None = Field(None, description='浏览器')
status: StatusType = Field(default=StatusType.enable) device: str | None = Field(None, description='设备')
code: str args: dict[str, Any] | None = Field(None, description='请求参数')
msg: str | None = None status: StatusType = Field(StatusType.enable, description='状态')
cost_time: float code: str = Field(description='状态码')
opera_time: datetime msg: str | None = Field(None, description='消息')
cost_time: float = Field(description='耗时')
opera_time: datetime = Field(description='操作时间')
class CreateOperaLogParam(OperaLogSchemaBase): class CreateOperaLogParam(OperaLogSchemaBase):
pass """创建操作日志参数"""
class UpdateOperaLogParam(OperaLogSchemaBase): class UpdateOperaLogParam(OperaLogSchemaBase):
pass """更新操作日志参数"""
class GetOperaLogDetail(OperaLogSchemaBase): class GetOperaLogDetail(OperaLogSchemaBase):
"""操作日志详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='日志 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
+27 -14
View File
@@ -4,39 +4,52 @@ 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
class RoleSchemaBase(SchemaBase): class RoleSchemaBase(SchemaBase):
name: str """角色基础模型"""
status: StatusType = Field(default=StatusType.enable)
remark: str | None = None name: str = Field(description='角色名称')
status: StatusType = Field(StatusType.enable, description='状态')
remark: str | None = Field(None, description='备注')
class CreateRoleParam(RoleSchemaBase): class CreateRoleParam(RoleSchemaBase):
pass """创建角色参数"""
class UpdateRoleParam(RoleSchemaBase): class UpdateRoleParam(RoleSchemaBase):
pass """更新角色参数"""
class UpdateRoleMenuParam(SchemaBase): class UpdateRoleMenuParam(SchemaBase):
menus: list[int] """更新角色菜单参数"""
menus: list[int] = Field(description='菜单 ID 列表')
class UpdateRoleRuleParam(SchemaBase): class UpdateRoleScopeParam(SchemaBase):
rules: list[int] """更新角色数据范围参数"""
scopes: list[int] = Field(description='数据范围 ID 列表')
class GetRoleDetail(RoleSchemaBase): class GetRoleDetail(RoleSchemaBase):
"""角色详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='角色 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
menus: list[GetMenuDetail | None] = []
rules: list[GetDataRuleDetail | None] = []
class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
+33 -21
View File
@@ -2,44 +2,56 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from backend.app.admin.schema.user import GetUserInfoNoRelationDetail from pydantic import Field
from backend.app.admin.schema.user import GetUserInfoDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class GetSwaggerToken(SchemaBase): class GetSwaggerToken(SchemaBase):
access_token: str """Swagger 认证令牌"""
token_type: str = 'Bearer'
user: GetUserInfoNoRelationDetail access_token: str = Field(description='访问令牌')
token_type: str = Field('Bearer', description='令牌类型')
user: GetUserInfoDetail = Field(description='用户信息')
class AccessTokenBase(SchemaBase): class AccessTokenBase(SchemaBase):
access_token: str """访问令牌基础模型"""
access_token_expire_time: datetime
session_uuid: str access_token: str = Field(description='访问令牌')
access_token_expire_time: datetime = Field(description='令牌过期时间')
session_uuid: str = Field(description='会话 UUID')
class GetNewToken(AccessTokenBase): class GetNewToken(AccessTokenBase):
pass """获取新令牌"""
class GetLoginToken(AccessTokenBase): class GetLoginToken(AccessTokenBase):
user: GetUserInfoNoRelationDetail """获取登录令牌"""
user: GetUserInfoDetail = Field(description='用户信息')
class KickOutToken(SchemaBase): class KickOutToken(SchemaBase):
session_uuid: str """踢出令牌"""
session_uuid: str = Field(description='会话 UUID')
class GetTokenDetail(SchemaBase): class GetTokenDetail(SchemaBase):
id: int """令牌详情"""
session_uuid: str
username: str id: int = Field(description='用户 ID')
nickname: str session_uuid: str = Field(description='会话 UUID')
ip: str username: str = Field(description='用户名')
os: str nickname: str = Field(description='昵称')
browser: str ip: str = Field(description='IP 地址')
device: str os: str = Field(description='操作系统')
status: StatusType browser: str = Field(description='浏览器')
last_login_time: str device: str = Field(description='设备')
expire_time: datetime status: StatusType = Field(description='状态')
last_login_time: str = Field(description='最后登录时间')
expire_time: datetime = Field(description='过期时间')
+63 -45
View File
@@ -7,84 +7,112 @@ from pydantic import ConfigDict, EmailStr, Field, HttpUrl, model_validator
from typing_extensions import Self from typing_extensions import Self
from backend.app.admin.schema.dept import GetDeptDetail from backend.app.admin.schema.dept import GetDeptDetail
from backend.app.admin.schema.role import GetRoleDetail from backend.app.admin.schema.role import GetRoleWithRelationDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import CustomPhoneNumber, SchemaBase from backend.common.schema import CustomPhoneNumber, SchemaBase
class AuthSchemaBase(SchemaBase): class AuthSchemaBase(SchemaBase):
username: str """用户认证基础模型"""
password: str | None
username: str = Field(description='用户名')
password: str | None = Field(description='密码')
class AuthLoginParam(AuthSchemaBase): class AuthLoginParam(AuthSchemaBase):
captcha: str """用户登录参数"""
captcha: str = Field(description='验证码')
class RegisterUserParam(AuthSchemaBase): class RegisterUserParam(AuthSchemaBase):
nickname: str | None = None """用户注册参数"""
email: EmailStr = Field(examples=['user@example.com'])
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class AddUserParam(AuthSchemaBase): class AddUserParam(AuthSchemaBase):
dept_id: int """添加用户参数"""
roles: list[int]
nickname: str | None = None dept_id: int = Field(description='部门 ID')
email: EmailStr = Field(examples=['user@example.com']) roles: list[int] = Field(description='角色 ID 列表')
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class ResetPasswordParam(SchemaBase):
"""重置密码参数"""
old_password: str = Field(description='旧密码')
new_password: str = Field(description='新密码')
confirm_password: str = Field(description='确认密码')
class UserInfoSchemaBase(SchemaBase): class UserInfoSchemaBase(SchemaBase):
dept_id: int | None = None """用户信息基础模型"""
username: str
nickname: str dept_id: int | None = Field(None, description='部门 ID')
email: EmailStr = Field(examples=['user@example.com']) username: str = Field(description='用户名')
phone: CustomPhoneNumber | None = None nickname: str = Field(description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号')
class UpdateUserParam(UserInfoSchemaBase): class UpdateUserParam(UserInfoSchemaBase):
pass """更新用户参数"""
class UpdateUserRoleParam(SchemaBase): class UpdateUserRoleParam(SchemaBase):
roles: list[int] """更新用户角色参数"""
roles: list[int] = Field(description='角色 ID 列表')
class AvatarParam(SchemaBase): class AvatarParam(SchemaBase):
"""更新头像参数"""
url: HttpUrl = Field(description='头像 http 地址') url: HttpUrl = Field(description='头像 http 地址')
class GetUserInfoNoRelationDetail(UserInfoSchemaBase): class GetUserInfoDetail(UserInfoSchemaBase):
"""用户信息详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept_id: int | None = None dept_id: int | None = Field(None, description='部门 ID')
id: int id: int = Field(description='用户 ID')
uuid: str uuid: str = Field(description='用户 UUID')
avatar: str | None = None avatar: str | None = Field(None, description='头像')
status: StatusType = Field(default=StatusType.enable) status: StatusType = Field(StatusType.enable, description='状态')
is_superuser: bool is_superuser: bool = Field(description='是否超级管理员')
is_staff: bool is_staff: bool = Field(description='是否管理员')
is_multi_login: bool is_multi_login: bool = Field(description='是否允许多端登录')
join_time: datetime = None join_time: datetime = Field(description='加入时间')
last_login_time: datetime | None = None last_login_time: datetime | None = Field(None, description='最后登录时间')
class GetUserInfoDetail(GetUserInfoNoRelationDetail): class GetUserInfoWithRelationDetail(GetUserInfoDetail):
"""用户信息关联详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept: GetDeptDetail | None = None dept: GetDeptDetail | None = Field(None, description='部门信息')
roles: list[GetRoleDetail] roles: list[GetRoleWithRelationDetail] = Field(description='角色列表')
class GetCurrentUserInfoDetail(GetUserInfoDetail): class GetCurrentUserInfoWithRelationDetail(GetUserInfoWithRelationDetail):
"""当前用户信息关联详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept: str | None = None dept: str | None = Field(None, description='部门名称')
roles: list[str] roles: list[str] = Field(description='角色名称列表')
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def handel(cls, data: Any) -> Self: def handel(cls, data: Any) -> Self:
"""处理部门和角色""" """处理部门和角色数据"""
dept = data['dept'] dept = data['dept']
if dept: if dept:
data['dept'] = dept['name'] data['dept'] = dept['name']
@@ -92,13 +120,3 @@ class GetCurrentUserInfoDetail(GetUserInfoDetail):
if roles: if roles:
data['roles'] = [role['name'] for role in roles] data['roles'] = [role['name'] for role in roles]
return data return data
class CurrentUserIns(GetUserInfoDetail):
model_config = ConfigDict(from_attributes=True)
class ResetPasswordParam(SchemaBase):
old_password: str
new_password: str
confirm_password: str
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.common.enums import UserSocialType
from backend.common.schema import SchemaBase
class UserSocialSchemaBase(SchemaBase):
source: UserSocialType
open_id: str | None = None
uid: str | None = None
union_id: str | None = None
scope: str | None = None
code: str | None = None
class CreateUserSocialParam(UserSocialSchemaBase):
user_id: int
class UpdateUserSocialParam(SchemaBase):
pass
+50 -7
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
@@ -30,18 +29,40 @@ from backend.utils.timezone import timezone
class AuthService: 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:
"""
验证用户名和密码
:param db: 数据库会话
:param username: 用户名
:param password: 密码
:return:
"""
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]:
"""
Swagger 文档登录
:param obj: 登录凭证
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
user = await self.user_verify(db, obj.username, obj.password) user = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
@@ -49,23 +70,32 @@ 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
async def login( async def login(
self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
) -> GetLoginToken: ) -> GetLoginToken:
"""
用户登录
:param request: 请求对象
:param response: 响应对象
:param obj: 登录参数
:param background_tasks: 后台任务
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
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(
@@ -133,6 +163,12 @@ class AuthService:
@staticmethod @staticmethod
async def new_token(*, request: Request) -> GetNewToken: async def new_token(*, request: Request) -> GetNewToken:
"""
获取新的访问令牌
:param request: FastAPI 请求对象
:return:
"""
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
if not refresh_token: if not refresh_token:
raise errors.TokenError(msg='Refresh Token 已过期,请重新登录') raise errors.TokenError(msg='Refresh Token 已过期,请重新登录')
@@ -168,6 +204,13 @@ class AuthService:
@staticmethod @staticmethod
async def logout(*, request: Request, response: Response) -> None: async def logout(*, request: Request, response: Response) -> None:
"""
用户登出
:param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:return:
"""
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id user_id = token_payload.id
+56 -25
View File
@@ -2,87 +2,118 @@
# -*- 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:
"""
获取数据规则详情
:param pk: 规则 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() 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='数据规则不存在')
return data_rule return data_rule
@staticmethod
async def get_role_rules(*, pk: int) -> list[int]:
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: 模型名称
: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='数据规则可用模型不存在')
try: model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
except (ImportError, AttributeError):
raise errors.ServerError(msg=f'数据模型 {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) -> Select: async def get_select(*, name: str | None) -> Select:
"""
获取数据规则列表查询条件
:param name: 规则名称
:return:
"""
return await data_rule_dao.get_list(name=name) return await data_rule_dao.get_list(name=name)
@staticmethod @staticmethod
async def get_all() -> Sequence[DataRule]: async def get_all() -> Sequence[DataRule]:
"""获取所有数据规则"""
async with async_db_session() as db: async with async_db_session() as db:
data_rules = await data_rule_dao.get_all(db) data_rules = await data_rule_dao.get_all(db)
return data_rules return data_rules
@staticmethod @staticmethod
async def create(*, obj: CreateDataRuleParam) -> None: async def create(*, obj: CreateDataRuleParam) -> None:
"""
创建数据规则
:param obj: 规则创建参数
:return:
"""
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
async def update(*, pk: int, obj: UpdateDataRuleParam) -> int: async def update(*, pk: int, obj: UpdateDataRuleParam) -> int:
"""
更新数据规则
:param pk: 规则 ID
:param obj: 规则更新参数
:return:
"""
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 pk: 规则 ID 列表
: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()
+44 -6
View File
@@ -15,8 +15,16 @@ from backend.utils.build_tree import get_tree_data
class DeptService: class DeptService:
"""部门服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Dept: async def get(*, pk: int) -> Dept:
"""
获取部门详情
:param pk: 部门 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
dept = await dept_dao.get(db, pk) dept = await dept_dao.get(db, pk)
if not dept: if not dept:
@@ -25,15 +33,31 @@ 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 leader: 部门负责人
:param phone: 联系电话
:param status: 状态
: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
@staticmethod @staticmethod
async def create(*, obj: CreateDeptParam) -> None: async def create(*, obj: CreateDeptParam) -> None:
"""
创建部门
:param obj: 部门创建参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept = await dept_dao.get_by_name(db, obj.name) dept = await dept_dao.get_by_name(db, obj.name)
if dept: if dept:
@@ -46,6 +70,13 @@ class DeptService:
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateDeptParam) -> int: async def update(*, pk: int, obj: UpdateDeptParam) -> int:
"""
更新部门
:param pk: 部门 ID
:param obj: 部门更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept = await dept_dao.get(db, pk) dept = await dept_dao.get(db, pk)
if not dept: if not dept:
@@ -63,16 +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 pk: 部门 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept_user = await dept_dao.get_with_relation(db, pk) dept = await dept_dao.get_with_relation(db, pk)
if dept_user: if dept.users:
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
+32 -3
View File
@@ -13,8 +13,18 @@ from backend.database.db import async_db_session
class LoginLogService: class LoginLogService:
"""登录日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str, status: int, ip: str) -> Select: async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取登录日志列表查询条件
:param username: 用户名
:param status: 状态
:param ip: IP 地址
:return:
"""
return await login_log_dao.get_list(username=username, status=status, ip=ip) return await login_log_dao.get_list(username=username, status=status, ip=ip)
@staticmethod @staticmethod
@@ -28,8 +38,20 @@ class LoginLogService:
status: int, status: int,
msg: str, msg: str,
) -> None: ) -> None:
"""
创建登录日志
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_uuid: 用户 UUID
:param username: 用户名
:param login_time: 登录时间
:param status: 状态
:param msg: 消息
:return:
"""
try: try:
obj_in = CreateLoginLogParam( obj = CreateLoginLogParam(
user_uuid=user_uuid, user_uuid=user_uuid,
username=username, username=username,
status=status, status=status,
@@ -44,18 +66,25 @@ class LoginLogService:
msg=msg, msg=msg,
login_time=login_time, login_time=login_time,
) )
await login_log_dao.create(db, obj_in) await login_log_dao.create(db, obj)
except Exception as e: except Exception as e:
log.error(f'登录日志创建失败: {e}') log.error(f'登录日志创建失败: {e}')
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
"""
删除登录日志
:param pk: 日志 ID 列表
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await login_log_dao.delete(db, pk) count = await login_log_dao.delete(db, pk)
return count return count
@staticmethod @staticmethod
async def delete_all() -> int: async def delete_all() -> int:
"""清空所有登录日志"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await login_log_dao.delete_all(db) count = await login_log_dao.delete_all(db)
return count return count
+52 -17
View File
@@ -5,19 +5,26 @@ 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:
"""菜单服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Menu: async def get(*, pk: int) -> Menu:
"""
获取菜单详情
:param pk: 菜单 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
menu = await menu_dao.get(db, menu_id=pk) menu = await menu_dao.get(db, menu_id=pk)
if not menu: if not menu:
@@ -25,25 +32,27 @@ 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]]:
"""
获取菜单树形结构
:param title: 菜单标题
:param status: 状态
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
menu_select = await menu_dao.get_all(db, title=title, status=status) menu_select = await menu_dao.get_all(db, title=title, status=status)
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]]:
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]]:
"""
获取用户的菜单树形结构
:param request: FastAPI 请求对象
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
roles = request.user.roles roles = request.user.roles
menu_ids = [] menu_ids = []
@@ -52,11 +61,17 @@ 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
async def create(*, obj: CreateMenuParam) -> None: async def create(*, obj: CreateMenuParam) -> None:
"""
创建菜单
:param obj: 菜单创建参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
title = await menu_dao.get_by_title(db, obj.title) title = await menu_dao.get_by_title(db, obj.title)
if title: if title:
@@ -69,6 +84,13 @@ class MenuService:
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateMenuParam) -> int: async def update(*, pk: int, obj: UpdateMenuParam) -> int:
"""
更新菜单
:param pk: 菜单 ID
:param obj: 菜单更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
menu = await menu_dao.get(db, pk) menu = await menu_dao.get(db, pk)
if not menu: if not menu:
@@ -83,16 +105,29 @@ 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 pk: 菜单 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
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
+26 -3
View File
@@ -8,23 +8,46 @@ from backend.database.db import async_db_session
class OperaLogService: 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:
"""
获取操作日志列表查询条件
:param username: 用户名
:param status: 状态
:param ip: IP 地址
:return:
"""
return await opera_log_dao.get_list(username=username, status=status, ip=ip) return await opera_log_dao.get_list(username=username, status=status, ip=ip)
@staticmethod @staticmethod
async def create(*, obj_in: CreateOperaLogParam): async def create(*, obj: CreateOperaLogParam) -> None:
"""
创建操作日志
:param obj: 操作日志创建参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
await opera_log_dao.create(db, obj_in) await opera_log_dao.create(db, obj)
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
"""
删除操作日志
:param pk: 日志 ID 列表
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await opera_log_dao.delete(db, pk) count = await opera_log_dao.delete(db, pk)
return count return count
@staticmethod @staticmethod
async def delete_all() -> int: async def delete_all() -> int:
"""清空所有操作日志"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await opera_log_dao.delete_all(db) count = await opera_log_dao.delete_all(db)
return count return count
+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()
+114 -21
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,17 +12,26 @@ 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:
"""角色服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Role: async def get(*, pk: int) -> Role:
"""
获取角色详情
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_with_relation(db, pk)
if not role: if not role:
@@ -32,22 +40,74 @@ class RoleService:
@staticmethod @staticmethod
async def get_all() -> Sequence[Role]: async def get_all() -> Sequence[Role]:
"""获取所有角色"""
async with async_db_session() as db: async with async_db_session() as db:
roles = await role_dao.get_all(db) roles = await role_dao.get_all(db)
return roles return roles
@staticmethod @staticmethod
async def get_by_user(*, pk: int) -> Sequence[Role]: async def get_users(*, pk: int) -> Sequence[Role]:
"""
获取用户的角色列表
:param pk: 用户 ID
: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, status: int = None) -> Select: async def get_select(*, name: str | None, status: int | None) -> Select:
"""
获取角色列表查询条件
:param name: 角色名称
:param status: 状态
:return:
"""
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:
"""
创建角色
:param obj: 角色创建参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get_by_name(db, obj.name) role = await role_dao.get_by_name(db, obj.name)
if role: if role:
@@ -56,6 +116,13 @@ class RoleService:
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateRoleParam) -> int: async def update(*, pk: int, obj: UpdateRoleParam) -> int:
"""
更新角色
:param pk: 角色 ID
:param obj: 角色更新参数
: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:
@@ -65,12 +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 pk: 角色 ID
:param menu_ids: 菜单 ID 列表
: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:
@@ -78,30 +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 pk: 角色 ID
:param scope_ids: 权限规则 ID 列表
: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 pk: 角色 ID 列表
: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
+163 -82
View File
@@ -25,8 +25,16 @@ from backend.database.redis import redis_client
class UserService: class UserService:
"""用户服务类"""
@staticmethod @staticmethod
async def register(*, obj: RegisterUserParam) -> None: async def register(*, obj: RegisterUserParam) -> None:
"""
注册新用户
:param obj: 用户注册参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if not obj.password: if not obj.password:
raise errors.ForbiddenError(msg='密码为空') raise errors.ForbiddenError(msg='密码为空')
@@ -44,6 +52,13 @@ class UserService:
@staticmethod @staticmethod
async def add(*, request: Request, obj: AddUserParam) -> None: async def add(*, request: Request, obj: AddUserParam) -> None:
"""
添加新用户
:param request: FastAPI 请求对象
:param obj: 用户添加参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
username = await user_dao.get_by_username(db, obj.username) username = await user_dao.get_by_username(db, obj.username)
@@ -69,13 +84,20 @@ class UserService:
@staticmethod @staticmethod
async def pwd_reset(*, request: Request, obj: ResetPasswordParam) -> int: async def pwd_reset(*, request: Request, obj: ResetPasswordParam) -> int:
"""
重置用户密码
:param request: FastAPI 请求对象
:param obj: 密码重置参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
user = await user_dao.get(db, request.user.id) user = await user_dao.get(db, request.user.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password): if not password_verify(obj.old_password, user.password):
raise errors.ForbiddenError(msg='原密码错误') raise errors.ForbiddenError(msg='原密码错误')
np1 = obj.new_password if obj.new_password != obj.confirm_password:
np2 = obj.confirm_password
if np1 != np2:
raise errors.ForbiddenError(msg='密码输入不一致') raise errors.ForbiddenError(msg='密码输入不一致')
new_pwd = get_hash_password(obj.new_password, user.salt) new_pwd = get_hash_password(obj.new_password, user.salt)
count = await user_dao.reset_password(db, request.user.id, new_pwd) count = await user_dao.reset_password(db, request.user.id, new_pwd)
@@ -90,6 +112,12 @@ class UserService:
@staticmethod @staticmethod
async def get_userinfo(*, username: str) -> User: async def get_userinfo(*, username: str) -> User:
"""
获取用户信息
:param username: 用户名
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
user = await user_dao.get_with_relation(db, username=username) user = await user_dao.get_with_relation(db, username=username)
if not user: if not user:
@@ -98,35 +126,49 @@ class UserService:
@staticmethod @staticmethod
async def update(*, request: Request, username: str, obj: UpdateUserParam) -> int: async def update(*, request: Request, username: str, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param request: FastAPI 请求对象
:param username: 用户名
:param obj: 用户更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if not request.user.is_superuser: if request.user.username != username:
if request.user.username != username: raise errors.ForbiddenError(msg='你只能修改自己的信息')
raise errors.ForbiddenError(msg='你只能修改自己的信息') user = await user_dao.get_with_relation(db, username=username)
input_user = await user_dao.get_with_relation(db, username=username) if not user:
if not input_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
async def update_roles(*, request: Request, username: str, obj: UpdateUserRoleParam) -> None: async def update_roles(*, request: Request, username: str, obj: UpdateUserRoleParam) -> None:
"""
更新用户角色
:param request: FastAPI 请求对象
:param username: 用户名
:param obj: 角色更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if not request.user.is_superuser: if not request.user.is_superuser and request.user.username != username:
if request.user.username != username: raise errors.ForbiddenError(msg='你只能修改自己的信息')
raise errors.AuthorizationError
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='用户不存在')
@@ -139,109 +181,148 @@ class UserService:
@staticmethod @staticmethod
async def update_avatar(*, request: Request, username: str, avatar: AvatarParam) -> int: async def update_avatar(*, request: Request, username: str, avatar: AvatarParam) -> int:
"""
更新用户头像
:param request: FastAPI 请求对象
:param username: 用户名
:param avatar: 头像参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if not request.user.is_superuser: if request.user.username != username:
if request.user.username != username: raise errors.AuthorizationError(msg='你只能修改自己的信息')
raise errors.AuthorizationError user = await user_dao.get_by_username(db, username)
input_user = await user_dao.get_by_username(db, username) if not user:
if not input_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:
"""
获取用户列表查询条件
:param dept: 部门 ID
:param username: 用户名
:param phone: 手机号
:param status: 状态
:return:
"""
return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status) return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status)
@staticmethod @staticmethod
async def update_permission(*, request: Request, pk: int) -> int: async def update_permission(*, request: Request, pk: int) -> int:
"""
更新用户权限
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else: if pk == request.user.id:
if pk == request.user.id: 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, False if super_status else True) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}') return count
return count
@staticmethod @staticmethod
async def update_staff(*, request: Request, pk: int) -> int: async def update_staff(*, request: Request, pk: int) -> int:
"""
更新用户职员状态
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else: if pk == request.user.id:
if pk == request.user.id: 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, False if staff_status else True) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}') return count
return count
@staticmethod @staticmethod
async def update_status(*, request: Request, pk: int) -> int: async def update_status(*, request: Request, pk: int) -> int:
"""
更新用户状态
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else: if pk == request.user.id:
if pk == request.user.id: 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, False if status else True) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}') return count
return count
@staticmethod @staticmethod
async def update_multi_login(*, request: Request, pk: int) -> int: async def update_multi_login(*, request: Request, pk: int) -> int:
"""
更新用户多端登录状态
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
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, new_multi_login)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
token = get_token(request)
token_payload = jwt_decode(token)
if pk == user.id:
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}')
else: else:
user_id = request.user.id # 系统管理员修改他人时,他人 token 全部失效
multi_login = await user_dao.get_multi_login(db, pk) if pk != user_id else request.user.is_multi_login if not new_multi_login:
count = await user_dao.set_multi_login(db, pk, False if multi_login else True) key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') await redis_client.delete_prefix(key_prefix)
token = get_token(request) return count
token_payload = jwt_decode(token)
latest_multi_login = await user_dao.get_multi_login(db, pk)
# 超级用户修改自身时,除当前token外,其他token失效
if pk == user_id:
if not latest_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{pk}'
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:
if not latest_multi_login:
key_prefix = [f'{settings.TOKEN_REDIS_PREFIX}:{pk}']
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
if refresh_token:
key_prefix.append(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{pk}')
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count
@staticmethod @staticmethod
async def delete(*, username: str) -> int: async def delete(*, username: str) -> int:
"""
删除用户
:param username: 用户名
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
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.delete(db, input_user.id) count = await user_dao.delete(db, user.id)
key_prefix = [ key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{input_user.id}', f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{input_user.id}', f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
] ]
for key in key_prefix: for key in key_prefix:
await redis_client.delete_prefix(key) await redis_client.delete_prefix(key)
+4 -1
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio.session import AsyncSession
from backend.database.db import create_async_engine_and_session, create_database_url from backend.database.db import create_async_engine_and_session, create_database_url
@@ -8,7 +11,7 @@ TEST_SQLALCHEMY_DATABASE_URL = create_database_url(unittest=True)
_, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL) _, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL)
async def override_get_db(): async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
"""session 生成器""" """session 生成器"""
async with async_test_db_session() as session: async with async_test_db_session() as session:
yield session yield session
-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):
"""Admin Settings"""
# 模版目录
TEMPLATE_BACKEND_DIR_NAME: str = 'py'
# 代码下载
DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
@lru_cache
def get_generator_settings() -> GeneratorSettings:
"""获取 generator 配置"""
return GeneratorSettings()
generator_settings = get_generator_settings()
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.generator.model import GenBusiness
from backend.app.generator.schema.gen_business import CreateGenBusinessParam, UpdateGenBusinessParam
class CRUDGenBusiness(CRUDPlus[GenBusiness]):
async def get(self, db: AsyncSession, pk: int) -> GenBusiness | None:
"""
获取代码生成业务表
:param db:
:param pk:
:return:
"""
return await self.select_model(db, pk)
async def get_by_name(self, db: AsyncSession, name: str) -> GenBusiness | None:
"""
通过 name 获取代码生成业务表
:param db:
:param name:
:return:
"""
return await self.select_model_by_column(db, table_name_en=name)
async def get_all(self, db: AsyncSession) -> Sequence[GenBusiness]:
"""
获取所有代码生成业务表
:return:
"""
return await self.select_models(db)
async def create(self, db: AsyncSession, obj_in: CreateGenBusinessParam) -> None:
"""
创建代码生成业务表
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateGenBusinessParam) -> int:
"""
更新代码生成业务表
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: int) -> int:
"""
删除代码生成业务表
:param db:
:param pk:
:return:
"""
return await self.delete_model(db, pk)
gen_business_dao: CRUDGenBusiness = CRUDGenBusiness(GenBusiness)
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.generator.model import GenModel
from backend.app.generator.schema.gen_model import CreateGenModelParam, UpdateGenModelParam
class CRUDGenModel(CRUDPlus[GenModel]):
async def get(self, db: AsyncSession, pk: int) -> GenModel | None:
"""
获取代码生成模型列
:return:
"""
return await self.select_model(db, pk)
async def get_all_by_business_id(self, db: AsyncSession, business_id: int) -> Sequence[GenModel]:
"""
获取所有代码生成模型列
:param db:
:param business_id:
:return:
"""
return await self.select_models_order(db, sort_columns='sort', gen_business_id=business_id)
async def create(self, db: AsyncSession, obj_in: CreateGenModelParam, pd_type: str | None = None) -> None:
"""
创建代码生成模型表
:param db:
:param obj_in:
:param pd_type:
:return:
"""
await self.create_model(db, obj_in, pd_type=pd_type)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateGenModelParam, pd_type: str | None = None) -> int:
"""
更细代码生成模型表
:param db:
:param pk:
:param obj_in:
:param pd_type:
:return:
"""
return await self.update_model(db, pk, obj_in, pd_type=pd_type)
async def delete(self, db: AsyncSession, pk: int) -> int:
"""
删除代码生成模型表
:param db:
:param pk:
:return:
"""
return await self.delete_model(db, pk)
gen_model_dao: CRUDGenModel = CRUDGenModel(GenModel)
-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
@@ -1,45 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field, model_validator
from typing_extensions import Self
from backend.app.generator.schema.gen_model import GetGenModelDetail
from backend.common.schema import SchemaBase
class GenBusinessSchemaBase(SchemaBase):
app_name: str
table_name_en: str
table_name_zh: str
table_simple_name_zh: str
table_comment: str | None = None
schema_name: str | None = None
default_datetime_column: bool = Field(default=True)
api_version: str = Field(default='v1')
gen_path: str | None = None
remark: str | None = None
@model_validator(mode='after')
def check_schema_name(self) -> Self:
if self.schema_name is None:
self.schema_name = self.table_name_en
return self
class CreateGenBusinessParam(GenBusinessSchemaBase):
pass
class UpdateGenBusinessParam(GenBusinessSchemaBase):
pass
class GetGenBusinessDetail(GenBusinessSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_time: datetime
updated_time: datetime | None = None
gen_model: list[GetGenModelDetail] | None = None
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import ConfigDict, Field, field_validator
from backend.common.schema import SchemaBase
from backend.utils.type_conversion import sql_type_to_sqlalchemy
class GenModelSchemaBase(SchemaBase):
name: str
comment: str | None = None
type: str
default: str | None = None
sort: int
length: int
is_pk: bool = Field(default=False)
is_nullable: bool = Field(default=False)
gen_business_id: int | None = Field(ge=1)
@field_validator('type')
@classmethod
def type_update(cls, v):
return sql_type_to_sqlalchemy(v)
class CreateGenModelParam(GenModelSchemaBase):
pass
class UpdateGenModelParam(GenModelSchemaBase):
pass
class GetGenModelDetail(GenModelSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
pd_type: str
@@ -1,185 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import os.path
import zipfile
from pathlib import Path
from typing import Sequence
import aiofiles
from pydantic.alias_generators import to_pascal
from backend.app.generator.crud.crud_gen import gen_dao
from backend.app.generator.crud.crud_gen_business import gen_business_dao
from backend.app.generator.crud.crud_gen_model import gen_model_dao
from backend.app.generator.model import GenBusiness
from backend.app.generator.schema.gen import ImportParam
from backend.app.generator.schema.gen_business import CreateGenBusinessParam
from backend.app.generator.schema.gen_model import CreateGenModelParam
from backend.app.generator.service.gen_model_service import gen_model_service
from backend.common.exception import errors
from backend.core.path_conf import BasePath
from backend.database.db import async_db_session
from backend.utils.gen_template import gen_template
from backend.utils.type_conversion import sql_type_to_pydantic
class GenService:
@staticmethod
async def get_tables(*, table_schema: str) -> Sequence[str]:
async with async_db_session() as db:
return await gen_dao.get_all_tables(db, table_schema)
@staticmethod
async def import_business_and_model(*, obj: ImportParam) -> None:
async with async_db_session.begin() as db:
table_info = await gen_dao.get_table(db, obj.table_name)
if not table_info:
raise errors.NotFoundError(msg='数据库表不存在')
business_info = await gen_business_dao.get_by_name(db, obj.table_name)
if business_info:
raise errors.ForbiddenError(msg='已存在相同数据库表业务')
table_name = table_info[0]
business_data = {
'app_name': obj.app,
'table_name_en': table_name,
'table_name_zh': table_info[1] or ' '.join(table_name.split('_')),
'table_simple_name_zh': table_info[1] or table_name.split('_')[-1],
'table_comment': table_info[1],
}
new_business = GenBusiness(**CreateGenBusinessParam(**business_data).model_dump())
db.add(new_business)
await db.flush()
column_info = await gen_dao.get_all_columns(db, obj.table_schema, table_name)
for column in column_info:
column_type = column[-1].split('(')[0].upper()
pd_type = sql_type_to_pydantic(column_type)
model_data = {
'name': column[0],
'comment': column[-2],
'type': column_type,
'sort': column[-3],
'length': column[-1].split('(')[1][:-1] if pd_type == 'str' and '(' in column[-1] else 0,
'is_pk': column[1],
'is_nullable': column[2],
'gen_business_id': new_business.id,
}
await gen_model_dao.create(db, CreateGenModelParam(**model_data), pd_type=pd_type)
@staticmethod
async def render_tpl_code(*, business: GenBusiness) -> dict[str, str]:
gen_models = await gen_model_service.get_by_business(business_id=business.id)
if not gen_models:
raise errors.NotFoundError(msg='代码生成模型表为空')
gen_vars = gen_template.get_vars(business, gen_models)
tpl_code_map = {}
for tpl_path in gen_template.get_template_paths():
tpl_code_map[tpl_path] = await gen_template.get_template(tpl_path).render_async(**gen_vars)
return tpl_code_map
async def preview(self, *, pk: int) -> dict[str, bytes]:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
tpl_code_map = await self.render_tpl_code(business=business)
return {
tpl.replace('.jinja', '.py') if tpl.startswith('py') else ...: code.encode('utf-8')
for tpl, code in tpl_code_map.items()
}
@staticmethod
async def get_generate_path(*, pk: int) -> list[str]:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
gen_path = business.gen_path
if not gen_path:
# 伪加密路径
gen_path = 'current-backend-app-path'
target_files = gen_template.get_code_gen_paths(business)
code_gen_paths = []
for target_file in target_files:
code_gen_paths.append(os.path.join(gen_path, *target_file.split('/')[1:]))
return code_gen_paths
async def generate(self, *, pk: int) -> None:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
tpl_code_map = await self.render_tpl_code(business=business)
gen_path = business.gen_path
if not gen_path:
gen_path = os.path.join(BasePath, 'app')
for tpl_path, code in tpl_code_map.items():
code_filepath = os.path.join(
gen_path,
*gen_template.get_code_gen_path(tpl_path, business).split('/')[1:],
)
code_folder = Path(str(code_filepath)).parent
if not code_folder.exists():
code_folder.mkdir(parents=True, exist_ok=True)
# 写入 init 文件
init_filepath = code_folder.joinpath('__init__.py')
if not init_filepath.exists():
async with aiofiles.open(init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content)
if 'api' in str(code_folder):
# api __init__.py
api_init_filepath = code_folder.parent.joinpath('__init__.py')
if not api_init_filepath.exists():
async with aiofiles.open(api_init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content)
# app __init__.py
app_init_filepath = api_init_filepath.parent.joinpath('__init__.py')
if not app_init_filepath:
async with aiofiles.open(app_init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content)
# 写入代码文件呢
async with aiofiles.open(code_filepath, 'w', encoding='utf-8') as f:
await f.write(code)
# model init 文件补充
if code_folder.name == 'model':
async with aiofiles.open(init_filepath, 'a', encoding='utf-8') as f:
await f.write(
f'from backend.app.{business.app_name}.model.{business.table_name_en} '
f'import {to_pascal(business.table_name_en)}\n',
)
async def download(self, *, pk: int) -> io.BytesIO:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
bio = io.BytesIO()
zf = zipfile.ZipFile(bio, 'w')
tpl_code_map = await self.render_tpl_code(business=business)
for tpl_path, code in tpl_code_map.items():
# 写入代码文件
new_code_path = gen_template.get_code_gen_path(tpl_path, business)
zf.writestr(new_code_path, code)
# 写入 init 文件
init_filepath = os.path.join(*new_code_path.split('/')[:-1], '__init__.py')
if 'model' not in new_code_path.split('/'):
zf.writestr(init_filepath, gen_template.init_content)
else:
zf.writestr(
init_filepath,
f'{gen_template.init_content}'
f'from backend.app.{business.app_name}.model.{business.table_name_en} '
f'import {to_pascal(business.table_name_en)}\n',
)
if 'api' in new_code_path:
# api __init__.py
api_init_filepath = os.path.join(*new_code_path.split('/')[:-2], '__init__.py')
zf.writestr(api_init_filepath, gen_template.init_content)
zf.close()
bio.seek(0)
return bio
gen_service: GenService = GenService()
-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
@@ -27,7 +27,7 @@ async def get_all_tasks() -> ResponseSchemaModel[list[str]]:
description='此接口被视为作废,建议使用 flower 查看任务详情', description='此接口被视为作废,建议使用 flower 查看任务详情',
dependencies=[DependsJwtAuth], dependencies=[DependsJwtAuth],
) )
async def get_task_detail(tid: Annotated[str, Path(description='任务ID')]) -> ResponseSchemaModel[TaskResult]: async def get_task_detail(tid: Annotated[str, Path(description='任务 UUID')]) -> ResponseSchemaModel[TaskResult]:
status = task_service.get_detail(tid=tid) status = task_service.get_detail(tid=tid)
return response_base.success(data=status) return response_base.success(data=status)
@@ -40,7 +40,7 @@ async def get_task_detail(tid: Annotated[str, Path(description='任务ID')]) ->
DependsRBAC, DependsRBAC,
], ],
) )
async def revoke_task(tid: Annotated[str, Path(description='任务ID')]) -> ResponseModel: async def revoke_task(tid: Annotated[str, Path(description='任务 UUID')]) -> ResponseModel:
task_service.revoke(tid=tid) task_service.revoke(tid=tid)
return response_base.success() return response_base.success()
+41 -37
View File
@@ -1,16 +1,48 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
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']
def get_broker_url() -> str:
"""获取消息代理 URL"""
if settings.CELERY_BROKER == 'redis':
return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
)
return (
f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
)
def get_result_backend() -> str:
"""获取结果后端 URL"""
return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{settings.CELERY_BACKEND_REDIS_DATABASE}'
)
def get_result_backend_transport_options() -> dict[str, Any]:
"""获取结果后端传输选项"""
return {
'global_keyprefix': settings.CELERY_BACKEND_REDIS_PREFIX,
'retry_policy': {
'timeout': settings.CELERY_BACKEND_REDIS_TIMEOUT,
},
}
def init_celery() -> celery.Celery: def init_celery() -> celery.Celery:
"""初始化 celery 应用""" """初始化 Celery 应用"""
# TODO: Update this work if celery version >= 6.0.0 # TODO: Update this work if celery version >= 6.0.0
# https://github.com/fastapi-practices/fastapi_best_architecture/issues/321 # https://github.com/fastapi-practices/fastapi_best_architecture/issues/321
@@ -18,52 +50,24 @@ def init_celery() -> celery.Celery:
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations() celery.app.trace.reset_worker_optimizations()
# Celery Schedule Tasks
# https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html
beat_schedule = task_settings.CELERY_SCHEDULE
# Celery Config
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
broker_url = (
(
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}'
)
if task_settings.CELERY_BROKER == 'redis'
else (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}'
)
)
result_backend = (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BACKEND_REDIS_DATABASE}'
)
result_backend_transport_options = {
'global_keyprefix': f'{task_settings.CELERY_BACKEND_REDIS_PREFIX}',
'retry_policy': {
'timeout': task_settings.CELERY_BACKEND_REDIS_TIMEOUT,
},
}
app = celery.Celery( app = celery.Celery(
'fba_celery', 'fba_celery',
enable_utc=False, enable_utc=False,
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
beat_schedule=beat_schedule, beat_schedule=settings.CELERY_SCHEDULE,
broker_url=broker_url, broker_url=get_broker_url(),
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
result_backend=result_backend, result_backend=get_result_backend(),
result_backend_transport_options=result_backend_transport_options, result_backend_transport_options=get_result_backend_transport_options(),
task_cls='app.task.celery_task.base:TaskBase', task_cls='app.task.celery_task.base:TaskBase',
task_track_started=True, task_track_started=True,
) )
# Load task modules # 自动发现任务
app.autodiscover_tasks(task_settings.CELERY_TASK_PACKAGES) app.autodiscover_tasks(settings.CELERY_TASK_PACKAGES)
return app return app
# 创建 celery 实例 # 创建 Celery 实例
celery_app: celery.Celery = init_celery() celery_app: celery.Celery = init_celery()
+32 -7
View File
@@ -1,24 +1,49 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import asyncio
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 任务基类"""
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, args, kwargs): async def before_start(self, task_id: str, args, kwargs) -> None:
"""
任务开始前执行钩子
:param task_id: 任务 ID
:return:
"""
await task_notification(msg=f'任务 {task_id} 开始执行') await task_notification(msg=f'任务 {task_id} 开始执行')
async def on_success(self, retval, task_id, args, kwargs): async def on_success(self, retval: Any, task_id: str, args, kwargs) -> None:
"""
任务成功后执行钩子
:param retval: 任务返回值
:param task_id: 任务 ID
:return:
"""
await task_notification(msg=f'任务 {task_id} 执行成功') await task_notification(msg=f'任务 {task_id} 执行成功')
async def on_failure(self, exc, task_id, args, kwargs, einfo): def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None:
await task_notification(msg=f'任务 {task_id} 执行失败') """
任务失败后执行钩子
:param exc: 异常对象
:param task_id: 任务 ID
:param einfo: 异常信息
:return:
"""
loop = asyncio.get_event_loop()
loop.create_task(task_notification(msg=f'任务 {task_id} 执行失败'))
+1
View File
@@ -7,5 +7,6 @@ from backend.app.task.celery import celery_app
@celery_app.task(name='task_demo_async') @celery_app.task(name='task_demo_async')
async def task_demo_async() -> str: async def task_demo_async() -> str:
"""异步示例任务,模拟耗时操作"""
await sleep(20) await sleep(20)
return 'test async' return 'test async'
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from typing import Literal
from celery.schedules import crontab
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BasePath
class TaskSettings(BaseSettings):
"""Task Settings"""
model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore')
# Env Config
ENVIRONMENT: Literal['dev', 'pro']
# Env Celery
CELERY_BROKER_REDIS_DATABASE: int # 仅在 dev 模式时生效
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_SCHEDULE: dict = {
'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):
if values['ENVIRONMENT'] == 'pro':
values['CELERY_BROKER'] = 'rabbitmq'
return values
@lru_cache
def get_task_settings() -> TaskSettings:
"""获取 task 配置"""
return TaskSettings()
task_settings = get_task_settings()
+17 -11
View File
@@ -1,23 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Any
from pydantic import Field from pydantic import Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class RunParam(SchemaBase): class RunParam(SchemaBase):
"""任务运行参数"""
name: str = Field(description='任务名称') name: str = Field(description='任务名称')
args: list | None = Field(default=None, description='任务函数位置参数') args: list[Any] | None = Field(None, description='任务函数位置参数')
kwargs: dict | None = Field(default=None, description='任务函数关键字参数') kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
class TaskResult(SchemaBase): class TaskResult(SchemaBase):
result: str """任务执行结果"""
traceback: str
status: str result: str = Field(description='任务执行结果')
name: str traceback: str | None = Field(None, description='错误堆栈信息')
args: list | None status: str = Field(description='任务状态')
kwargs: dict | None name: str | None = Field(None, description='任务名称')
worker: str args: list[Any] | None = Field(None, description='任务函数位置参数')
retries: int | None kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
queue: str | None worker: str | None = Field(None, description='执行任务的 worker')
retries: int | None = Field(None, description='重试次数')
queue: str | None = Field(None, description='任务队列')
+21 -2
View File
@@ -13,14 +13,21 @@ from backend.common.exception.errors import NotFoundError
class TaskService: class TaskService:
@staticmethod @staticmethod
async def get_list() -> list[str]: async def get_list() -> list[str]:
"""获取所有已注册的 Celery 任务列表"""
registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered) registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered)
if not registered_tasks: if not registered_tasks:
raise errors.ForbiddenError(msg='celery 服务未启动') raise errors.ForbiddenError(msg='Celery 服务未启动')
tasks = list(registered_tasks.values())[0] tasks = list(registered_tasks.values())[0]
return tasks return tasks
@staticmethod @staticmethod
def get_detail(*, tid: str) -> TaskResult: def get_detail(*, tid: str) -> TaskResult:
"""
获取指定任务的详细信息
:param tid: 任务 UUID
:return:
"""
try: try:
result = AsyncResult(id=tid, app=celery_app) result = AsyncResult(id=tid, app=celery_app)
except NotRegistered: except NotRegistered:
@@ -38,7 +45,13 @@ class TaskService:
) )
@staticmethod @staticmethod
def revoke(*, tid: str): def revoke(*, tid: str) -> None:
"""
撤销指定的任务
:param tid: 任务 UUID
:return:
"""
try: try:
result = AsyncResult(id=tid, app=celery_app) result = AsyncResult(id=tid, app=celery_app)
except NotRegistered: except NotRegistered:
@@ -47,6 +60,12 @@ class TaskService:
@staticmethod @staticmethod
def run(*, obj: RunParam) -> str: def run(*, obj: RunParam) -> str:
"""
运行指定的任务
:param obj: 任务运行参数
:return:
"""
task: AsyncResult = celery_app.send_task(name=obj.name, args=obj.args, kwargs=obj.kwargs) task: AsyncResult = celery_app.send_task(name=obj.name, args=obj.args, kwargs=obj.kwargs)
return task.task_id return task.task_id
+21 -160
View File
@@ -2,27 +2,38 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from enum import Enum from enum import Enum
from enum import IntEnum as SourceIntEnum from enum import IntEnum as SourceIntEnum
from typing import Type from typing import Any, Type, TypeVar
T = TypeVar('T', bound=Enum)
class _EnumBase: class _EnumBase:
"""枚举基类,提供通用方法"""
@classmethod @classmethod
def get_member_keys(cls: Type[Enum]) -> list[str]: def get_member_keys(cls: Type[T]) -> list[str]:
"""获取枚举成员名称列表"""
return [name for name in cls.__members__.keys()] return [name for name in cls.__members__.keys()]
@classmethod @classmethod
def get_member_values(cls: Type[Enum]) -> list: def get_member_values(cls: Type[T]) -> list:
"""获取枚举成员值列表"""
return [item.value for item in cls.__members__.values()] return [item.value for item in cls.__members__.values()]
@classmethod
def get_member_dict(cls: Type[T]) -> dict[str, Any]:
"""获取枚举成员字典"""
return {name: item.value for name, item in cls.__members__.items()}
class IntEnum(_EnumBase, SourceIntEnum): class IntEnum(_EnumBase, SourceIntEnum):
"""整型枚举""" """整型枚举基类"""
pass pass
class StrEnum(_EnumBase, str, Enum): class StrEnum(_EnumBase, str, Enum):
"""字符串枚举""" """字符串枚举基类"""
pass pass
@@ -36,14 +47,14 @@ class MenuType(IntEnum):
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 # !=
@@ -56,7 +67,7 @@ class RoleDataRuleExpressionType(IntEnum):
class MethodType(StrEnum): class MethodType(StrEnum):
"""请求方法""" """HTTP 请求方法"""
GET = 'GET' GET = 'GET'
POST = 'POST' POST = 'POST'
@@ -67,7 +78,7 @@ class MethodType(StrEnum):
class LoginLogStatusType(IntEnum): class LoginLogStatusType(IntEnum):
"""日志状态""" """日志状态"""
fail = 0 fail = 0
success = 1 success = 1
@@ -100,7 +111,7 @@ class UserSocialType(StrEnum):
"""用户社交类型""" """用户社交类型"""
github = 'GitHub' github = 'GitHub'
linuxdo = 'LinuxDo' linux_do = 'LinuxDo'
class FileType(StrEnum): class FileType(StrEnum):
@@ -108,153 +119,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'
+20 -7
View File
@@ -1,12 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
全局业务异常类
业务代码执行异常时可以使用 raise xxxError 触发内部错误它尽可能实现带有后台任务的异常但它不适用于**自定义响应状态码**
如果要求使用**自定义响应状态码**则可以通过 return response_base.fail(res=CustomResponseCode.xxx) 直接返回
""" # noqa: E501
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
@@ -16,6 +9,8 @@ from backend.common.response.response_code import CustomErrorCode, StandardRespo
class BaseExceptionMixin(Exception): class BaseExceptionMixin(Exception):
"""基础异常混入类"""
code: int code: int
def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None):
@@ -26,17 +21,23 @@ class BaseExceptionMixin(Exception):
class HTTPError(HTTPException): class HTTPError(HTTPException):
"""HTTP 异常"""
def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None): def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None):
super().__init__(status_code=code, detail=msg, headers=headers) super().__init__(status_code=code, detail=msg, headers=headers)
class CustomError(BaseExceptionMixin): class CustomError(BaseExceptionMixin):
"""自定义异常"""
def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None):
self.code = error.code self.code = error.code
super().__init__(msg=error.msg, data=data, background=background) super().__init__(msg=error.msg, data=data, background=background)
class RequestError(BaseExceptionMixin): class RequestError(BaseExceptionMixin):
"""请求异常"""
code = StandardResponseCode.HTTP_400 code = StandardResponseCode.HTTP_400
def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None):
@@ -44,6 +45,8 @@ class RequestError(BaseExceptionMixin):
class ForbiddenError(BaseExceptionMixin): class ForbiddenError(BaseExceptionMixin):
"""禁止访问异常"""
code = StandardResponseCode.HTTP_403 code = StandardResponseCode.HTTP_403
def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None):
@@ -51,6 +54,8 @@ class ForbiddenError(BaseExceptionMixin):
class NotFoundError(BaseExceptionMixin): class NotFoundError(BaseExceptionMixin):
"""资源不存在异常"""
code = StandardResponseCode.HTTP_404 code = StandardResponseCode.HTTP_404
def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None):
@@ -58,6 +63,8 @@ class NotFoundError(BaseExceptionMixin):
class ServerError(BaseExceptionMixin): class ServerError(BaseExceptionMixin):
"""服务器异常"""
code = StandardResponseCode.HTTP_500 code = StandardResponseCode.HTTP_500
def __init__( def __init__(
@@ -67,6 +74,8 @@ class ServerError(BaseExceptionMixin):
class GatewayError(BaseExceptionMixin): class GatewayError(BaseExceptionMixin):
"""网关异常"""
code = StandardResponseCode.HTTP_502 code = StandardResponseCode.HTTP_502
def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None):
@@ -74,6 +83,8 @@ class GatewayError(BaseExceptionMixin):
class AuthorizationError(BaseExceptionMixin): class AuthorizationError(BaseExceptionMixin):
"""授权异常"""
code = StandardResponseCode.HTTP_401 code = StandardResponseCode.HTTP_401
def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None):
@@ -81,6 +92,8 @@ class AuthorizationError(BaseExceptionMixin):
class TokenError(HTTPError): class TokenError(HTTPError):
"""Token 异常"""
code = StandardResponseCode.HTTP_401 code = StandardResponseCode.HTTP_401
def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None): def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None):
+28 -52
View File
@@ -3,7 +3,6 @@
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError from pydantic import ValidationError
from pydantic.errors import PydanticUserError
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
@@ -12,7 +11,6 @@ from backend.common.exception.errors import BaseExceptionMixin
from backend.common.response.response_code import CustomResponseCode, StandardResponseCode from backend.common.response.response_code import CustomResponseCode, StandardResponseCode
from backend.common.response.response_schema import response_base from backend.common.response.response_schema import response_base
from backend.common.schema import ( from backend.common.schema import (
CUSTOM_USAGE_ERROR_MESSAGES,
CUSTOM_VALIDATION_ERROR_MESSAGES, CUSTOM_VALIDATION_ERROR_MESSAGES,
) )
from backend.core.conf import settings from backend.core.conf import settings
@@ -20,36 +18,34 @@ from backend.utils.serializers import MsgSpecJSONResponse
from backend.utils.trace_id import get_request_trace_id from backend.utils.trace_id import get_request_trace_id
def _get_exception_code(status_code: int): def _get_exception_code(status_code: int) -> int:
""" """
获取返回状态码, OpenAPI, Uvicorn... 可用状态码基于 RFC 定义, 详细代码见下方链接 获取返回状态码可用状态码基于 RFC 定义
`python 状态码标准支持 <https://github.com/python/cpython/blob/6e3cc72afeaee2532b4327776501eb8234ac787b/Lib/http `python 状态码标准支持 <https://github.com/python/cpython/blob/6e3cc72afeaee2532b4327776501eb8234ac787b/Lib/http/__init__.py#L7>`__
/__init__.py#L7>`__
`IANA 状态码注册表 <https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml>`__ `IANA 状态码注册表 <https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml>`__
:param status_code: :param status_code: HTTP 状态码
:return: :return:
""" """
try: try:
STATUS_PHRASES[status_code] STATUS_PHRASES[status_code]
return status_code
except Exception: except Exception:
code = StandardResponseCode.HTTP_400 return StandardResponseCode.HTTP_400
else:
code = status_code
return code
async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError): async def _validation_exception_handler(request: Request, exc: RequestValidationError | ValidationError):
""" """
数据验证异常处理 数据验证异常处理
:param e: :param request: 请求对象
:param exc: 验证异常
:return: :return:
""" """
errors = [] errors = []
for error in e.errors(): for error in exc.errors():
custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type']) custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type'])
if custom_message: if custom_message:
ctx = error.get('ctx') ctx = error.get('ctx')
@@ -87,10 +83,10 @@ def register_exception(app: FastAPI):
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException): async def http_exception_handler(request: Request, exc: HTTPException):
""" """
全局HTTP异常处理 全局 HTTP 异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: HTTP 异常
:return: :return:
""" """
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
@@ -113,10 +109,10 @@ def register_exception(app: FastAPI):
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError): async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError):
""" """
fastapi 数据验证异常处理 FastAPI 数据验证异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 验证异常
:return: :return:
""" """
return await _validation_exception_handler(request, exc) return await _validation_exception_handler(request, exc)
@@ -124,42 +120,21 @@ def register_exception(app: FastAPI):
@app.exception_handler(ValidationError) @app.exception_handler(ValidationError)
async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): async def pydantic_validation_exception_handler(request: Request, exc: ValidationError):
""" """
pydantic 数据验证异常处理 Pydantic 数据验证异常处理
:param request: :param request: 请求对象
:param exc: :param exc: 验证异常
:return: :return:
""" """
return await _validation_exception_handler(request, exc) return await _validation_exception_handler(request, exc)
@app.exception_handler(PydanticUserError)
async def pydantic_user_error_handler(request: Request, exc: PydanticUserError):
"""
Pydantic 用户异常处理
:param request:
:param exc:
:return:
"""
content = {
'code': StandardResponseCode.HTTP_500,
'msg': CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code),
'data': None,
}
request.state.__request_pydantic_user_error__ = content
content.update(trace_id=get_request_trace_id(request))
return MsgSpecJSONResponse(
status_code=StandardResponseCode.HTTP_500,
content=content,
)
@app.exception_handler(AssertionError) @app.exception_handler(AssertionError)
async def assertion_error_handler(request: Request, exc: AssertionError): async def assertion_error_handler(request: Request, exc: AssertionError):
""" """
断言错误处理 断言错误处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 断言错误
:return: :return:
""" """
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
@@ -183,8 +158,8 @@ def register_exception(app: FastAPI):
""" """
全局自定义异常处理 全局自定义异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 自定义异常
:return: :return:
""" """
content = { content = {
@@ -205,8 +180,8 @@ def register_exception(app: FastAPI):
""" """
全局未知异常处理 全局未知异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 未知异常
:return: :return:
""" """
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
@@ -233,10 +208,11 @@ def register_exception(app: FastAPI):
跨域自定义 500 异常处理 跨域自定义 500 异常处理
`Related issue <https://github.com/encode/starlette/issues/1175>`_ `Related issue <https://github.com/encode/starlette/issues/1175>`_
`Solution <https://github.com/fastapi/fastapi/discussions/7847#discussioncomment-5144709>`_ `Solution <https://github.com/fastapi/fastapi/discussions/7847#discussioncomment-5144709>`_
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 自定义异常
:return: :return:
""" """
if isinstance(exc, BaseExceptionMixin): if isinstance(exc, BaseExceptionMixin):
+23 -19
View File
@@ -14,18 +14,19 @@ from backend.core.conf import settings
class InterceptHandler(logging.Handler): class InterceptHandler(logging.Handler):
""" """
Default handler from examples in loguru documentation. 日志拦截处理器用于将标准库的日志重定向到 loguru
See https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
参考https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
""" """
def emit(self, record: logging.LogRecord): def emit(self, record: logging.LogRecord):
# Get corresponding Loguru level if it exists # 获取对应的 Loguru 级别(如果存在)
try: try:
level = logger.level(record.levelname).name level = logger.level(record.levelname).name
except ValueError: except ValueError:
level = record.levelno level = record.levelno
# Find caller from where originated the logged message. # 查找记录日志消息的调用者
frame, depth = inspect.currentframe(), 0 frame, depth = inspect.currentframe(), 0
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
frame = frame.f_back frame = frame.f_back
@@ -34,16 +35,19 @@ class InterceptHandler(logging.Handler):
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def setup_logging(): def setup_logging() -> None:
""" """
From https://github.com/benoitc/gunicorn/issues/1572#issuecomment-638391953 设置日志处理器
https://github.com/pawamoy/pawamoy.github.io/issues/17
参考
- https://github.com/benoitc/gunicorn/issues/1572#issuecomment-638391953
- https://github.com/pawamoy/pawamoy.github.io/issues/17
""" """
# Set the logging handler and level # 设置根日志处理器和级别
logging.root.handlers = [InterceptHandler()] logging.root.handlers = [InterceptHandler()]
logging.root.setLevel(settings.LOG_STD_LEVEL) logging.root.setLevel(settings.LOG_STD_LEVEL)
# Remove all log handlers and propagate to root logger # 配置日志传播规则
for name in logging.root.manager.loggerDict.keys(): for name in logging.root.manager.loggerDict.keys():
logging.getLogger(name).handlers = [] logging.getLogger(name).handlers = []
if 'uvicorn.access' in name or 'watchfiles.main' in name: if 'uvicorn.access' in name or 'watchfiles.main' in name:
@@ -54,17 +58,15 @@ def setup_logging():
# Debug log handlers # Debug log handlers
# logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}') # logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}')
# Define the correlation_id default filter function # 定义 correlation_id 默认过滤函数
# https://github.com/snok/asgi-correlation-id/issues/7 # https://github.com/snok/asgi-correlation-id/issues/7
def correlation_id_filter(record): def correlation_id_filter(record):
cid = correlation_id.get(settings.LOG_CID_DEFAULT_VALUE) cid = correlation_id.get(settings.LOG_CID_DEFAULT_VALUE)
record['correlation_id'] = cid[: settings.LOG_CID_UUID_LENGTH] record['correlation_id'] = cid[: settings.LOG_CID_UUID_LENGTH]
return record return record
# Remove default loguru logger # 配置 loguru 处理器
logger.remove() logger.remove() # 移除默认处理器
# Set the loguru default handlers
logger.configure( logger.configure(
handlers=[ handlers=[
{ {
@@ -77,16 +79,17 @@ def setup_logging():
) )
def set_custom_logfile(): def set_custom_logfile() -> None:
"""设置自定义日志文件"""
log_path = path_conf.LOG_DIR log_path = path_conf.LOG_DIR
if not os.path.exists(log_path): if not os.path.exists(log_path):
os.mkdir(log_path) os.mkdir(log_path)
# log files # 日志文件
log_access_file = os.path.join(log_path, settings.LOG_ACCESS_FILENAME) log_access_file = os.path.join(log_path, settings.LOG_ACCESS_FILENAME)
log_error_file = os.path.join(log_path, settings.LOG_ERROR_FILENAME) log_error_file = os.path.join(log_path, settings.LOG_ERROR_FILENAME)
# set loguru logger default config # 日志文件通用配置
# https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add # https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
log_config = { log_config = {
'format': settings.LOG_FILE_FORMAT, 'format': settings.LOG_FILE_FORMAT,
@@ -96,7 +99,7 @@ def set_custom_logfile():
'compression': 'tar.gz', 'compression': 'tar.gz',
} }
# stdout file # 标准输出文件
logger.add( logger.add(
str(log_access_file), str(log_access_file),
level=settings.LOG_ACCESS_FILE_LEVEL, level=settings.LOG_ACCESS_FILE_LEVEL,
@@ -106,7 +109,7 @@ def set_custom_logfile():
**log_config, **log_config,
) )
# stderr file # 标准错误文件
logger.add( logger.add(
str(log_error_file), str(log_error_file),
level=settings.LOG_ERROR_FILE_LEVEL, level=settings.LOG_ERROR_FILE_LEVEL,
@@ -117,4 +120,5 @@ def set_custom_logfile():
) )
# 创建 logger 实例
log = logger log = logger
+14 -6
View File
@@ -13,7 +13,7 @@ from backend.utils.timezone import timezone
# MappedBase -> id: Mapped[id_key] # MappedBase -> id: Mapped[id_key]
# DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False) # DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False)
id_key = Annotated[ id_key = Annotated[
int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键id') int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键 ID')
] ]
@@ -38,31 +38,39 @@ class DateTimeMixin(MappedAsDataclass):
class MappedBase(AsyncAttrs, DeclarativeBase): class MappedBase(AsyncAttrs, DeclarativeBase):
""" """
生命式基类, 作为所有基类或数据模型类的父类而存在 声明式基类, 作为所有基类或数据模型类的父类而存在
`AsyncAttrs <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncAttrs>`__ `AsyncAttrs <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncAttrs>`__
`DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__ `DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__
`mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__ `mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__
""" """
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
"""生成表名"""
return cls.__name__.lower() return cls.__name__.lower()
@declared_attr.directive
def __table_args__(cls) -> dict:
"""表配置"""
return {'comment': cls.__doc__ or ''}
class DataClassBase(MappedAsDataclass, MappedBase): class DataClassBase(MappedAsDataclass, MappedBase):
""" """
声明性数据类基类, 它将带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时 声明性数据类基类, 带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时
`MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__ `MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__
""" # noqa: E501 """
__abstract__ = True __abstract__ = True
class Base(DataClassBase, DateTimeMixin): class Base(DataClassBase, DateTimeMixin):
""" """
声明性 Mixin 数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构, 你可以简单的理解它为含有基础表结构的数据类基类 声明性数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构
""" # noqa: E501 """
__abstract__ = True __abstract__ = True
+33 -25
View File
@@ -3,12 +3,12 @@
from __future__ import annotations from __future__ import annotations
from math import ceil from math import ceil
from typing import TYPE_CHECKING, Generic, Sequence, TypeVar 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
@@ -21,8 +21,10 @@ SchemaT = TypeVar('SchemaT')
class _CustomPageParams(BaseModel, AbstractParams): class _CustomPageParams(BaseModel, AbstractParams):
page: int = Query(1, ge=1, description='Page number') """自定义分页参数"""
size: int = Query(20, gt=0, le=100, description='Page size') # 默认 20 条记录
page: int = Query(1, ge=1, 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(
@@ -32,47 +34,53 @@ class _CustomPageParams(BaseModel, AbstractParams):
class _Links(BaseModel): class _Links(BaseModel):
first: str = Field(..., description='首页链接') """分页链接"""
last: str = Field(..., description='尾页链接')
self: str = Field(..., description='当前页链接') first: str = Field(description='页链接')
last: str = Field(description='尾页链接')
self: str = Field(description='当前页链接')
next: str | None = Field(None, description='下一页链接') next: str | None = Field(None, description='下一页链接')
prev: str | None = Field(None, description='上一页链接') prev: str | None = Field(None, description='上一页链接')
class _PageDetails(BaseModel): class _PageDetails(BaseModel):
items: list = Field([], description='当前页数据') """分页详情"""
total: int = Field(..., description='总条数')
page: int = Field(..., description='当前页') items: list = Field([], description='当前页数据列表')
size: int = Field(..., description='每页数量') total: int = Field(description='数据总条数')
total_pages: int = Field(..., description='总页数') page: int = Field(description='当前页码')
links: _Links size: int = Field(description='每页数量')
total_pages: int = Field(description='总页数')
links: _Links = Field(description='分页链接')
class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]): class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
"""自定义分页类"""
__params_type__ = _CustomPageParams __params_type__ = _CustomPageParams
@classmethod @classmethod
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
total_pages = ceil(total / params.size) total_pages = ceil(total / size)
links = create_links( links = create_links(
first={'page': 1, 'size': size}, first={'page': 1, 'size': size},
last={'page': f'{ceil(total / params.size)}', 'size': size} if total > 0 else {'page': 1, 'size': size}, last={'page': total_pages, 'size': size} if total > 0 else {'page': 1, 'size': size},
next={'page': f'{page + 1}', 'size': size} if (page + 1) <= total_pages else None, next={'page': page + 1, 'size': size} if (page + 1) <= total_pages else None,
prev={'page': f'{page - 1}', 'size': size} if (page - 1) >= 1 else None, prev={'page': page - 1, 'size': size} if (page - 1) >= 1 else None,
).model_dump() ).model_dump()
return cls( return cls(
items=items, items=items,
total=total, total=total,
page=params.page, page=page,
size=params.size, size=size,
total_pages=total_pages, total_pages=total_pages,
links=links, # type: ignore links=links, # type: ignore
) )
@@ -80,7 +88,7 @@ class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
class PageData(_PageDetails, Generic[SchemaT]): class PageData(_PageDetails, Generic[SchemaT]):
""" """
包含 data schema 的统一返回模型适用于分页接口 包含返回数据 schema 的统一返回模型适用于分页接口
E.g. :: E.g. ::
@@ -103,15 +111,15 @@ class PageData(_PageDetails, Generic[SchemaT]):
items: Sequence[SchemaT] items: Sequence[SchemaT]
async def paging_data(db: AsyncSession, select: Select) -> dict: async def paging_data(db: AsyncSession, select: Select) -> dict[str, Any]:
""" """
基于 SQLAlchemy 创建分页数据 基于 SQLAlchemy 创建分页数据
:param db: :param db: 数据库会话
:param select: :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
+8 -8
View File
@@ -9,17 +9,13 @@ class CustomCodeBase(Enum):
"""自定义状态码基类""" """自定义状态码基类"""
@property @property
def code(self): def code(self) -> int:
""" """获取状态码"""
获取状态码
"""
return self.value[0] return self.value[0]
@property @property
def msg(self): def msg(self) -> str:
""" """获取状态码信息"""
获取状态码信息
"""
return self.value[1] return self.value[1]
@@ -43,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):
"""自定义错误状态码""" """自定义错误状态码"""

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