Compare commits

..
46 Commits
Author SHA1 Message Date
Wu Clan 2b56168ad0 Refactor foreign keys and relationships to pure logic (#901)
* Refactor foreign keys and relationships to pure logic

* Revert of some changes

* More revert

* Update the user paginate

* Update user create and update

* Update dept select and delete

* Rename the join query functions

* Update select_join_serialize doc and README

* Fix typo in README

* Update the user delete

* Update the user social

* Update the dict plugin crud

* Update the dict plugin version

* Bump dependencies and pre-commits

* Update the code generator plugin crud

* Update the menu crud

* Update the role crud

* Update the data scope and rule crud

* Restore get_paginated to get_select

* Update the code generator plugin version

* Add the py version in pre-commit

* Remove the plugin include parameter config

* Add more cache cleaning TODO

* Rename get_with_relation to get_join

* Add the user cache clear

* Fix known compatibility issues

* Update the version number to 1.11.0

* Fix lint

* Optimize select_join_serialize logic

* Delete cache cleanup comments

* Update the oauth2 plugin version

* Fix user-role table cleanup when user update
2025-11-12 13:06:26 +08:00
Wu Clan b9255815e1 Update the changelog for v1.10.4 (#916) 2025-11-12 12:01:57 +08:00
Wu Clan 93e2f0f5f9 Fix dept and menu table subqueries (#914)
* Fix dept and menu table subqueries

* Fix user cache cleanup when updating data scope
2025-11-11 17:31:59 +08:00
Wu Clan 437c026da9 Fix user cache cleanup when updating data scope (#915) 2025-11-11 17:30:43 +08:00
Wu Clan 316079045f Add user social unbinding account interface (#913) 2025-11-11 17:06:11 +08:00
Wu Clan 5ae9f8b288 Fix the user menu sidebar parsing (#912) 2025-11-11 17:05:43 +08:00
Wu Clan bef65e1baa Fix create and delete department validations (#911) 2025-11-11 17:05:24 +08:00
Wu Clan 87a3cd8a9c Fix user cache cleanup when operating data rules (#910) 2025-11-11 17:05:08 +08:00
Wu Clan be7b742891 Fix typo in the data permission prompt (#909) 2025-11-11 17:04:25 +08:00
Wu Clan 1f98797a60 Update the version number to 1.10.4 (#907) 2025-11-10 23:51:28 +08:00
Wu Clan 8505e32757 Add dept validation to user updates (#906) 2025-11-10 23:50:38 +08:00
Wu Clan 91297d1ccd Fix response status codes in the request logs (#905) 2025-11-08 16:40:16 +08:00
Wu Clan 0ea213edfd Optimize the data permission condition build (#904) 2025-11-08 16:35:15 +08:00
Wu Clan 292f5d7e48 Optimize the request params of the service layer (#903) 2025-11-08 16:24:19 +08:00
Wu Clan ef871f3b5c Add the user social independent enum file (#902) 2025-11-08 16:21:51 +08:00
Wu Clan 6b17da59f8 Update the time column type in the task (#900) 2025-11-06 21:04:51 +08:00
Wu Clan 86d580edd8 Optimize redis batch get and delete operations (#899)
* Optimize redis batch get and delete operations

* Fix typo
2025-11-04 20:59:45 +08:00
Wu Clan cdbe37de7d Update the interface timing accuracy in log (#897)
* Update the interface timing accuracy in log

* Update log request finish
2025-11-04 20:59:00 +08:00
Wu Clan f46974ddd9 Bump fastapi oauth2 from 0.0.1 to 0.0.2 (#896) 2025-11-02 19:42:51 +08:00
Wu Clan a14d4243d9 Update the changelog for v1.10.3 (#895) 2025-10-30 19:28:00 +08:00
Wu Clan 90588ebae8 Add the celery rabbitmq vhost config (#894) 2025-10-30 19:24:46 +08:00
Wu Clan 00bc02f49c Fix the venv pip availability in Linux (#893)
* Fix the venv pip availability in Linux

* Remove pip install timeout
2025-10-30 18:06:58 +08:00
Wu Clan 7e9ce2e691 Fix celery compatibility with psycopg version (#892) 2025-10-30 13:32:12 +08:00
Wu Clan 81ec0f0bd9 Fix import in code generation api template (#891) 2025-10-30 11:14:05 +08:00
Wu Clan 37f9716c4f Bump dependencies to the latest version (#890)
* Bump dependencies to the latest version

* Bump fastapi to the latest version

* Fix lint
2025-10-30 11:07:40 +08:00
Wu Clan 4540cd9338 Update the length style of the model columns (#883)
* Update the length style of the model columns

* Fix opera log table username column length

* Fix login log table username column length
2025-10-23 15:00:28 +08:00
Wu Clan 3c39d2f2f3 Add Google OAuth2 callback to opera log exclusion (#882) 2025-10-22 18:58:36 +08:00
Wu Clan 1e01ffc3d9 Fix the OAuth2 link acquisition in HTTPS (#881)
* Fix the OAuth2 link acquisition in HTTPS

* Update OAUTH2_BACKEND_URL to OAUTH2_BACKEND_BASE_URL
2025-10-22 18:52:40 +08:00
Wu Clan f4b1c43b55 Update the serializer of httpurl type (#879)
* Update the serializer of httpurl type

* Fix lint
2025-10-22 17:30:13 +08:00
Wu Clan 02254938b4 Update the length of the user email column (#878) 2025-10-22 15:58:50 +08:00
Wu Clan 054ff0cab2 Fix the httpurl type compatibility with postgresql (#877)
* Fix the httpurl type compatibility with postgresql

* Add noqa comments
2025-10-22 15:58:42 +08:00
Wu Clan 4f898a0264 Update the container naming in docker scripts (#876) 2025-10-22 15:33:10 +08:00
Wu Clan 70d90c2c01 Add port for fba sever in docker compose (#875) 2025-10-22 15:32:56 +08:00
Wu Clan 964143b30d Fix docker default database env variables (#874) 2025-10-22 14:01:18 +08:00
Wu Clan ce9f59e7d6 Update the changelog for v1.10.2 (#873) 2025-10-21 15:56:46 +08:00
Wu Clan 1f958fc42a Bump fastapi to remove warning for python 3.14 (#872) 2025-10-21 15:45:05 +08:00
Wu Clan e6e53296a8 Optimize sqlalchemy types to simplify compatibility (#870)
* Optimize sqlalchemy types to simplify compatibility

* Update TimeZone type comment
2025-10-20 17:41:00 +08:00
Wu Clan f533de8abd Bump rtoml and uvicorn to support python 3.14 (#871) 2025-10-20 12:42:28 +08:00
Wu Clan adc6427aab Update the changelog for v1.10.1 (#869) 2025-10-18 18:29:30 +08:00
Wu Clan d034b78b48 Bump dependencies to the latest version (#868) 2025-10-18 18:14:43 +08:00
Wu Clan 6deda906de Update the release changelog workflow (#867) 2025-10-18 18:08:29 +08:00
Wu Clan a063105de8 Fix the volume in the docker compose script (#865)
* Fix the volume in the docker compose script

* Fix the db comment
2025-10-18 15:57:29 +08:00
Wu Clan 02b4215742 Fix tasks related to deleting db logs (#863) 2025-10-18 15:49:55 +08:00
Wu Clan 56b6e2113f Update the changelog for v1.10.0 (#864) 2025-10-18 15:44:17 +08:00
Wu Clan 4fcc78eb94 Update the changelog for v1.9.0 (#862) 2025-10-18 15:41:28 +08:00
Wu Clan b28a4d7673 Update the primary database to postgresql (#829)
* Update the primary database to postgresql

* Delete the prints

* Fix lint

* Update models in code generation

* Delete print

* Fix the salt column type
2025-10-17 23:03:17 +08:00
95 changed files with 2408 additions and 1726 deletions
+2
View File
@@ -18,4 +18,6 @@ jobs:
with:
file: CHANGELOG.md
pull_request: true
commit_summary_template: 'Update the changelog for %s'
args: -c
github_token: ${{ secrets.GH_TOKEN }}
+1
View File
@@ -7,3 +7,4 @@ venv/
.python-version
.ruff_cache/
.pytest_cache/
.claude/
+4
View File
@@ -1,8 +1,12 @@
default_language_version:
python: '>= 3.10'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: check-json
- id: check-yaml
- id: check-toml
+254 -1
View File
@@ -1,3 +1,127 @@
<a id="v1.10.4"></a>
# [v1.10.4](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.4) - 2025-11-12
## What's Changed
* Update changelog for v1.10.3 by [@wu-clan](https://github.com/wu-clan) in [#895](https://github.com/fastapi-practices/fastapi_best_architecture/pull/895)
* Bump fastapi oauth2 from 0.0.1 to 0.0.2 by [@wu-clan](https://github.com/wu-clan) in [#896](https://github.com/fastapi-practices/fastapi_best_architecture/pull/896)
* Update the interface timing accuracy in log by [@wu-clan](https://github.com/wu-clan) in [#897](https://github.com/fastapi-practices/fastapi_best_architecture/pull/897)
* Optimize redis batch get and delete operations by [@wu-clan](https://github.com/wu-clan) in [#899](https://github.com/fastapi-practices/fastapi_best_architecture/pull/899)
* Update the time column type in the task by [@wu-clan](https://github.com/wu-clan) in [#900](https://github.com/fastapi-practices/fastapi_best_architecture/pull/900)
* Add the user social independent enum file by [@wu-clan](https://github.com/wu-clan) in [#902](https://github.com/fastapi-practices/fastapi_best_architecture/pull/902)
* Optimize the request params of the service layer by [@wu-clan](https://github.com/wu-clan) in [#903](https://github.com/fastapi-practices/fastapi_best_architecture/pull/903)
* Optimize the data permission condition build by [@wu-clan](https://github.com/wu-clan) in [#904](https://github.com/fastapi-practices/fastapi_best_architecture/pull/904)
* Fix response status codes in the request logs by [@wu-clan](https://github.com/wu-clan) in [#905](https://github.com/fastapi-practices/fastapi_best_architecture/pull/905)
* Add dept validation to user updates by [@wu-clan](https://github.com/wu-clan) in [#906](https://github.com/fastapi-practices/fastapi_best_architecture/pull/906)
* Update the version number to 1.10.4 by [@wu-clan](https://github.com/wu-clan) in [#907](https://github.com/fastapi-practices/fastapi_best_architecture/pull/907)
* Fix typo in the data permission prompt by [@wu-clan](https://github.com/wu-clan) in [#909](https://github.com/fastapi-practices/fastapi_best_architecture/pull/909)
* Fix user cache cleanup when operating data rules by [@wu-clan](https://github.com/wu-clan) in [#910](https://github.com/fastapi-practices/fastapi_best_architecture/pull/910)
* Fix create and delete department validations by [@wu-clan](https://github.com/wu-clan) in [#911](https://github.com/fastapi-practices/fastapi_best_architecture/pull/911)
* Fix the user menu sidebar parsing by [@wu-clan](https://github.com/wu-clan) in [#912](https://github.com/fastapi-practices/fastapi_best_architecture/pull/912)
* Add user social unbinding account interface by [@wu-clan](https://github.com/wu-clan) in [#913](https://github.com/fastapi-practices/fastapi_best_architecture/pull/913)
* Fix user cache cleanup when updating data scope by [@wu-clan](https://github.com/wu-clan) in [#915](https://github.com/fastapi-practices/fastapi_best_architecture/pull/915)
* Fix dept and menu table subqueries by [@wu-clan](https://github.com/wu-clan) in [#914](https://github.com/fastapi-practices/fastapi_best_architecture/pull/914)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.4]
<a id="v1.10.3"></a>
# [v1.10.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.3) - 2025-10-30
## What's Changed
* Update changelog for v1.10.2 by [@wu-clan](https://github.com/wu-clan) in [#873](https://github.com/fastapi-practices/fastapi_best_architecture/pull/873)
* Fix docker default database env variables by [@wu-clan](https://github.com/wu-clan) in [#874](https://github.com/fastapi-practices/fastapi_best_architecture/pull/874)
* Add port for fba sever in docker compose by [@wu-clan](https://github.com/wu-clan) in [#875](https://github.com/fastapi-practices/fastapi_best_architecture/pull/875)
* Update the container naming in docker scripts by [@wu-clan](https://github.com/wu-clan) in [#876](https://github.com/fastapi-practices/fastapi_best_architecture/pull/876)
* Fix the httpurl type compatibility with postgresql by [@wu-clan](https://github.com/wu-clan) in [#877](https://github.com/fastapi-practices/fastapi_best_architecture/pull/877)
* Update the default length of user email column by [@wu-clan](https://github.com/wu-clan) in [#878](https://github.com/fastapi-practices/fastapi_best_architecture/pull/878)
* Update the serializer of httpurl type by [@wu-clan](https://github.com/wu-clan) in [#879](https://github.com/fastapi-practices/fastapi_best_architecture/pull/879)
* Fix the OAuth2 link acquisition in HTTPS by [@wu-clan](https://github.com/wu-clan) in [#881](https://github.com/fastapi-practices/fastapi_best_architecture/pull/881)
* Add Google OAuth2 callback to opera log exclusion by [@wu-clan](https://github.com/wu-clan) in [#882](https://github.com/fastapi-practices/fastapi_best_architecture/pull/882)
* Update the length style of the model columns by [@wu-clan](https://github.com/wu-clan) in [#883](https://github.com/fastapi-practices/fastapi_best_architecture/pull/883)
* Bump dependencies to the latest version by [@wu-clan](https://github.com/wu-clan) in [#890](https://github.com/fastapi-practices/fastapi_best_architecture/pull/890)
* Fix import in code generation api template by [@wu-clan](https://github.com/wu-clan) in [#891](https://github.com/fastapi-practices/fastapi_best_architecture/pull/891)
* Fix celery compatibility with psycopg version by [@wu-clan](https://github.com/wu-clan) in [#892](https://github.com/fastapi-practices/fastapi_best_architecture/pull/892)
* Fix the venv pip availability in Linux by [@wu-clan](https://github.com/wu-clan) in [#893](https://github.com/fastapi-practices/fastapi_best_architecture/pull/893)
* Add the celery rabbitmq vhost config by [@wu-clan](https://github.com/wu-clan) in [#894](https://github.com/fastapi-practices/fastapi_best_architecture/pull/894)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.2...v1.10.3
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.3]
<a id="v1.10.2"></a>
# [v1.10.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.2) - 2025-10-21
## What's Changed
* Update the changelog for v1.10.1 by [@wu-clan](https://github.com/wu-clan) in [#869](https://github.com/fastapi-practices/fastapi_best_architecture/pull/869)
* Bump rtoml and uvicorn to support python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#871](https://github.com/fastapi-practices/fastapi_best_architecture/pull/871)
* Optimize sqlalchemy types to simplify compatibility by [@wu-clan](https://github.com/wu-clan) in [#870](https://github.com/fastapi-practices/fastapi_best_architecture/pull/870)
* Bump fastapi to remove warning for python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#872](https://github.com/fastapi-practices/fastapi_best_architecture/pull/872)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.1...v1.10.2
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.2]
<a id="v1.10.1"></a>
# [v1.10.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.1) - 2025-10-18
## What's Changed
* Update the changelog for v1.10.0 by [@wu-clan](https://github.com/wu-clan) in [#864](https://github.com/fastapi-practices/fastapi_best_architecture/pull/864)
* Fix tasks related to deleting db logs by [@wu-clan](https://github.com/wu-clan) in [#863](https://github.com/fastapi-practices/fastapi_best_architecture/pull/863)
* Fix the volume in the docker compose script by [@wu-clan](https://github.com/wu-clan) in [#865](https://github.com/fastapi-practices/fastapi_best_architecture/pull/865)
* Update the release changelog workflow by [@wu-clan](https://github.com/wu-clan) in [#867](https://github.com/fastapi-practices/fastapi_best_architecture/pull/867)
* Bump dependencies to the latest version by [@wu-clan](https://github.com/wu-clan) in [#868](https://github.com/fastapi-practices/fastapi_best_architecture/pull/868)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.0...v1.10.1
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.1]
<a id="v1.10.0"></a>
# [v1.10.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.0) - 2025-10-17
## What's Changed
* Fix the import table cli command by [@wu-clan](https://github.com/wu-clan) in [#860](https://github.com/fastapi-practices/fastapi_best_architecture/pull/860)
* Update unique judgment logic for dict data by [@wu-clan](https://github.com/wu-clan) in [#859](https://github.com/fastapi-practices/fastapi_best_architecture/pull/859)
* Fix the api ninja in code generation by [@wu-clan](https://github.com/wu-clan) in [#858](https://github.com/fastapi-practices/fastapi_best_architecture/pull/858)
* Fix dict plugin pgsql init script missing by [@wu-clan](https://github.com/wu-clan) in [#857](https://github.com/fastapi-practices/fastapi_best_architecture/pull/857)
* Update the changelog for v1.9.0 by [@wu-clan](https://github.com/wu-clan) in [#856](https://github.com/fastapi-practices/fastapi_best_architecture/pull/856)
* Fix ctx in validation exception handler by [@wu-clan](https://github.com/wu-clan) in [#861](https://github.com/fastapi-practices/fastapi_best_architecture/pull/861)
* Update the primary database to postgresql by [@wu-clan](https://github.com/wu-clan) in [#829](https://github.com/fastapi-practices/fastapi_best_architecture/pull/829)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.9.0...v1.10.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.0]
<a id="v1.9.0"></a>
# [v1.9.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.9.0) - 2025-10-16
@@ -13,6 +137,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.3...v1.9.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.9.0]
@@ -31,6 +159,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.2...v1.8.3
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.8.3]
@@ -66,6 +198,12 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.1...v1.8.2
## Contributors
<a href="https://github.com/MortyZhaoy"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FMortyZhaoy.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@MortyZhaoy"></a>
<a href="https://github.com/byte-voyager"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fbyte-voyager.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@byte-voyager"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.8.2]
@@ -110,6 +248,15 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.0...v1.8.1
## Contributors
<a href="https://github.com/IAseven"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FIAseven.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@IAseven"></a>
<a href="https://github.com/siyue-wang"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fsiyue-wang.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@siyue-wang"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
<a href="https://github.com/wuyao4"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwuyao4.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wuyao4"></a>
<a href="https://github.com/yanlingsishao"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyanlingsishao.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yanlingsishao"></a>
<a href="https://github.com/yzbf-lin"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyzbf-lin.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yzbf-lin"></a>
[Changes][v1.8.1]
@@ -154,6 +301,12 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.7.0...v1.8.0
## Contributors
<a href="https://github.com/IAseven"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FIAseven.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@IAseven"></a>
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.8.0]
@@ -189,6 +342,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.6.0...v1.7.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.7.0]
@@ -212,6 +369,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.2...v1.6.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.6.0]
@@ -238,6 +399,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.1...v1.5.2
## Contributors
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.5.2]
@@ -254,6 +420,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.0...v1.5.1
## Contributors
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.5.1]
@@ -277,6 +448,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.3...v1.5.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.5.0]
@@ -298,6 +473,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.2...v1.4.3
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.3]
@@ -319,6 +498,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.1...v1.4.2
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.2]
@@ -336,6 +520,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.1]
@@ -357,6 +545,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.0]
@@ -374,6 +566,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.3.0]
@@ -391,6 +587,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.2.0]
@@ -408,6 +608,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.1.2]
@@ -422,6 +626,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.1.1]
@@ -441,6 +649,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.1.0]
@@ -465,6 +678,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.5]
@@ -493,6 +710,12 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
## Contributors
<a href="https://github.com/PoetryL"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FPoetryL.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@PoetryL"></a>
<a href="https://github.com/ThankCat"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FThankCat.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@ThankCat"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.4]
@@ -511,6 +734,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.3]
@@ -526,6 +754,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.2]
@@ -545,6 +777,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.1]
@@ -870,9 +1107,25 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/commits/v1.0.0
## Contributors
<a href="https://github.com/Meepoljdx"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FMeepoljdx.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@Meepoljdx"></a>
<a href="https://github.com/dependabot"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdependabot.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@dependabot"></a>
<a href="https://github.com/dividduang"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdividduang.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@dividduang"></a>
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/obrua"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fobrua.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@obrua"></a>
<a href="https://github.com/qhp13654398483"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fqhp13654398483.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@qhp13654398483"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
<a href="https://github.com/yshan2028"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyshan2028.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yshan2028"></a>
[Changes][v1.0.0]
[v1.10.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
[v1.10.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.2...v1.10.3
[v1.10.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.1...v1.10.2
[v1.10.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.0...v1.10.1
[v1.10.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.9.0...v1.10.0
[v1.9.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.3...v1.9.0
[v1.8.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.2...v1.8.3
[v1.8.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.1...v1.8.2
@@ -899,4 +1152,4 @@
[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
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.0 -->
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
+6 -6
View File
@@ -1,5 +1,5 @@
# Select the image to build based on SERVER_TYPE, defaulting to fastapi_server, or docker-compose build args
ARG SERVER_TYPE=fastapi_server
# Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args
ARG SERVER_TYPE=fba_server
# === Python environment from uv ===
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
@@ -41,22 +41,22 @@ COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf
WORKDIR /fba/backend
# === FastAPI server image ===
FROM base_server AS fastapi_server
FROM base_server AS fba_server
COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fastapi_server
RUN mkdir -p /var/log/fba
EXPOSE 8001
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
# === Celery server image ===
FROM base_server AS celery
FROM base_server AS fba_celery
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/celery
RUN mkdir -p /var/log/fba
RUN chmod +x celery-start.sh
+3 -3
View File
@@ -1,10 +1,10 @@
# Env
ENVIRONMENT='dev'
# Database
DATABASE_TYPE='mysql'
DATABASE_TYPE='postgresql'
DATABASE_HOST='127.0.0.1'
DATABASE_PORT=3306
DATABASE_USER='root'
DATABASE_PORT=5432
DATABASE_USER='postgres'
DATABASE_PASSWORD='123456'
# Redis
REDIS_HOST='127.0.0.1'
+3 -3
View File
@@ -50,10 +50,10 @@
4. Format and Lint
Auto-formatting and lint via `pre-commit`
Auto-formatting and lint via `prek`
```shell
pre-commit run --all-files
prek run --all-files
```
5. Commit and push
@@ -78,6 +78,6 @@
- `scripts/format.sh`: Perform ruff format check
- `scripts/lint.sh`: Perform pre-commit formatting
- `scripts/lint.sh`: Perform prek formatting
- `scripts/export.sh`: Execute uv export dependency package
+1 -1
View File
@@ -1,6 +1,6 @@
from backend.common.i18n import i18n
__version__ = '1.8.2'
__version__ = '1.11.0'
# 初始化 i18n
-1
View File
@@ -12,7 +12,6 @@ def get_app_models() -> list[type]:
apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
objs = []
for app in apps:
module_path = f'backend.app.{app}.model'
obj = get_model_objects(module_path)
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter()
async def get_sessions(
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.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:*')
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
data: list[GetTokenDetail] = []
+3 -1
View File
@@ -30,7 +30,9 @@ async def get_dept_tree(
phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_tree(db=db, request=request, name=name, leader=leader, phone=phone, status=status)
dept = await dept_service.get_tree(
db=db, request_user=request.user, name=name, leader=leader, phone=phone, status=status
)
return response_base.success(data=dept)
+6 -4
View File
@@ -102,7 +102,9 @@ async def update_user_permission(
async def update_user_password(
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
) -> ResponseModel:
count = await user_service.update_password(db=db, request=request, obj=obj)
count = await user_service.update_password(
db=db, user_id=request.user.id, hash_password=request.user.password, obj=obj
)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -126,7 +128,7 @@ async def update_user_nickname(
request: Request,
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
) -> ResponseModel:
count = await user_service.update_nickname(db=db, request=request, nickname=nickname)
count = await user_service.update_nickname(db=db, user_id=request.user.id, nickname=nickname)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -138,7 +140,7 @@ async def update_user_avatar(
request: Request,
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
) -> ResponseModel:
count = await user_service.update_avatar(db=db, request=request, avatar=avatar)
count = await user_service.update_avatar(db=db, user_id=request.user.id, avatar=avatar)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -151,7 +153,7 @@ async def update_user_email(
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
email: Annotated[str, Body(embed=True, description='用户邮箱')],
) -> ResponseModel:
count = await user_service.update_email(db=db, request=request, captcha=captcha, email=email)
count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email)
if count > 0:
return response_base.success()
return response_base.fail()
+1 -1
View File
@@ -33,7 +33,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
if name is not None:
filters['name__like'] = f'%{name}%'
return await self.select_order('id', load_strategies={'scopes': 'noload'}, **filters)
return await self.select_order('id', **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
"""
+35 -12
View File
@@ -1,11 +1,19 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, select
from sqlalchemy import Select, delete, insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataRule, DataScope
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam
from backend.app.admin.model.m2m import data_scope_rule
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
CreateDataScopeRuleParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.utils.serializers import select_join_serialize
class CRUDDataScope(CRUDPlus[DataScope]):
@@ -31,7 +39,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
"""
return await self.select_model_by_column(db, name=name)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope:
async def get_join(self, db: AsyncSession, pk: int) -> Any:
"""
获取数据范围关联数据
@@ -39,7 +47,16 @@ class CRUDDataScope(CRUDPlus[DataScope]):
:param pk: 范围 ID
:return:
"""
return await self.select_model(db, pk, load_strategies=['rules'])
result = await self.select_models(
db,
id=pk,
join_conditions=[
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == self.model.id),
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
],
)
return select_join_serialize(result, relationships=['DataScope-m2m-DataRule:rules'])
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
"""
@@ -65,7 +82,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
if status is not None:
filters['status'] = status
return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters)
return await self.select_order('id', **filters)
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
"""
@@ -88,7 +105,8 @@ class CRUDDataScope(CRUDPlus[DataScope]):
"""
return await self.update_model(db, pk, obj)
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
@staticmethod
async def update_rules(db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
@@ -97,11 +115,16 @@ class CRUDDataScope(CRUDPlus[DataScope]):
: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)
data_scope_rule_stmt = delete(data_scope_rule).where(data_scope_rule.c.data_scope_id == pk)
await db.execute(data_scope_rule_stmt)
data_scope_rule_data = [
CreateDataScopeRuleParam(data_scope_id=pk, data_rule_id=rule_id).model_dump() for rule_id in rule_ids.rules
]
data_scope_rule_stmt = insert(data_scope_rule)
await db.execute(data_scope_rule_stmt, data_scope_rule_data)
return len(rule_ids.rules)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
+20 -13
View File
@@ -1,12 +1,14 @@
from collections.abc import Sequence
from typing import Any
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import Dept
from backend.app.admin.model import Dept, User
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.security.permission import filter_data_permission
from backend.utils.serializers import select_join_serialize
class CRUDDept(CRUDPlus[Dept]):
@@ -20,7 +22,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID
: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=False)
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
"""
@@ -30,12 +32,12 @@ class CRUDDept(CRUDPlus[Dept]):
:param name: 部门名称
: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=False)
async def get_all(
self,
request: Request,
db: AsyncSession,
request_user: GetUserInfoWithRelationDetail,
name: str | None,
leader: str | None,
phone: str | None,
@@ -44,15 +46,15 @@ class CRUDDept(CRUDPlus[Dept]):
"""
获取所有部门
:param request: FastAPI 请求对象
:param db: 数据库会话
:param request_user: 请求用户
:param name: 部门名称
:param leader: 负责人
:param phone: 联系电话
:param status: 部门状态
:return:
"""
filters = {'del_flag': 0}
filters = {'del_flag': False}
if name is not None:
filters['name__like'] = f'%{name}%'
@@ -63,8 +65,8 @@ class CRUDDept(CRUDPlus[Dept]):
if status is not None:
filters['status'] = status
data_filtered = await filter_data_permission(db, request)
return await self.select_models_order(db, 'sort', 'desc', data_filtered, **filters)
data_filter = filter_data_permission(request_user)
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
"""
@@ -97,7 +99,7 @@ class CRUDDept(CRUDPlus[Dept]):
"""
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) -> Dept | None:
async def get_join(self, db: AsyncSession, dept_id: int) -> Any | None:
"""
获取部门及关联数据
@@ -105,7 +107,12 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID
:return:
"""
return await self.select_model(db, dept_id, load_strategies=['users'])
result = await self.select_model(
db,
dept_id,
join_conditions=[JoinConfig(model=User, join_on=User.dept_id == self.model.id, fill_result=True)],
)
return select_join_serialize(result, relationships=['Dept-o2m-User'])
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
"""
@@ -115,7 +122,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID
:return:
"""
return await self.select_models(db, parent_id=dept_id, del_flag=0)
return await self.select_models(db, parent_id=dept_id, del_flag=False)
dept_dao: CRUDDept = CRUDDept(Dept)
+7 -3
View File
@@ -1,9 +1,11 @@
from collections.abc import Sequence
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Menu
from backend.app.admin.model.m2m import role_menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
@@ -92,9 +94,12 @@ class CRUDMenu(CRUDPlus[Menu]):
:param menu_id: 菜单 ID
:return:
"""
role_menu_stmt = delete(role_menu).where(role_menu.c.menu_id == menu_id)
await db.execute(role_menu_stmt)
return await self.delete_model(db, menu_id)
async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]:
async def get_children(self, db: AsyncSession, menu_id: int) -> Sequence[Menu | None]:
"""
获取子菜单列表
@@ -102,8 +107,7 @@ class CRUDMenu(CRUDPlus[Menu]):
:param menu_id: 菜单 ID
:return:
"""
menu = await self.select_model(db, menu_id, load_strategies=['children'])
return menu.children
return await self.select_models(db, parent_id=menu_id)
menu_dao: CRUDMenu = CRUDMenu(Menu)
+58 -25
View File
@@ -1,16 +1,21 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, select
from sqlalchemy import Select, delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataScope, Menu, Role
from backend.app.admin.model.m2m import role_data_scope, role_menu
from backend.app.admin.schema.role import (
CreateRoleMenuParam,
CreateRoleParam,
CreateRoleScopeParam,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.utils.serializers import select_join_serialize
class CRUDRole(CRUDPlus[Role]):
@@ -26,7 +31,20 @@ class CRUDRole(CRUDPlus[Role]):
"""
return await self.select_model(db, role_id)
async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None:
@staticmethod
async def get_menus(db: AsyncSession, role_id: int) -> Sequence[Menu] | None:
"""
获取角色菜单
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
menu_stmt = select(Menu).join(role_menu, Menu.id == role_menu.c.menu_id).where(role_menu.c.role_id == role_id)
result = await db.execute(menu_stmt)
return result.scalars().all()
async def get_join(self, db: AsyncSession, role_id: int) -> Any:
"""
获取角色及关联数据
@@ -34,7 +52,18 @@ class CRUDRole(CRUDPlus[Role]):
:param role_id: 角色 ID
:return:
"""
return await self.select_model(db, role_id, load_strategies=['menus', 'scopes'])
result = await self.select_models(
db,
id=role_id,
join_conditions=[
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == self.model.id),
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == self.model.id),
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
],
)
return select_join_serialize(result, relationships=['Role-m2m-Menu', 'Role-m2m-DataScope:scopes'])
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
"""
@@ -61,15 +90,7 @@ class CRUDRole(CRUDPlus[Role]):
if status is not None:
filters['status'] = status
return await self.select_order(
'id',
load_strategies={
'users': 'noload',
'menus': 'noload',
'scopes': 'noload',
},
**filters,
)
return await self.select_order('id', **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
"""
@@ -102,7 +123,8 @@ class CRUDRole(CRUDPlus[Role]):
"""
return await self.update_model(db, role_id, obj)
async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
@staticmethod
async def update_menus(db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
"""
更新角色菜单
@@ -111,13 +133,19 @@ class CRUDRole(CRUDPlus[Role]):
:param menu_ids: 菜单 ID 列表
:return:
"""
current_role = await self.get_with_relation(db, role_id)
stmt = select(Menu).where(Menu.id.in_(menu_ids.menus))
menus = await db.execute(stmt)
current_role.menus = menus.scalars().all()
return len(current_role.menus)
role_menu_stmt = delete(role_menu).where(role_menu.c.role_id == role_id)
await db.execute(role_menu_stmt)
async def update_scopes(self, db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
role_menu_data = [
CreateRoleMenuParam(role_id=role_id, menu_id=menu_id).model_dump() for menu_id in menu_ids.menus
]
role_menu_stmt = insert(role_menu)
await db.execute(role_menu_stmt, role_menu_data)
return len(menu_ids.menus)
@staticmethod
async def update_scopes(db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
"""
更新角色数据范围
@@ -126,11 +154,16 @@ class CRUDRole(CRUDPlus[Role]):
:param scope_ids: 权限范围 ID 列表
:return:
"""
current_role = await self.get_with_relation(db, role_id)
stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes))
scopes = await db.execute(stmt)
current_role.scopes = scopes.scalars().all()
return len(current_role.scopes)
role_scope_stmt = delete(role_data_scope).where(role_data_scope.c.role_id == role_id)
await db.execute(role_scope_stmt)
role_scope_data = [
CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump() for scope_id in scope_ids.scopes
]
role_scope_stmt = insert(role_data_scope)
await db.execute(role_scope_stmt, role_scope_data)
return len(scope_ids.scopes)
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
"""
+73 -26
View File
@@ -1,18 +1,22 @@
from typing import Any
import bcrypt
from sqlalchemy import select
from sqlalchemy import Select, delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy.sql import Select
from sqlalchemy_crud_plus import CRUDPlus
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import Dept, Role, User
from backend.app.admin.model import DataRule, DataScope, Dept, Menu, Role, User
from backend.app.admin.model.m2m import data_scope_rule, role_data_scope, role_menu, user_role
from backend.app.admin.schema.user import (
AddOAuth2UserParam,
AddUserParam,
AddUserRoleParam,
UpdateUserParam,
)
from backend.common.security.jwt import get_hash_password
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
@@ -69,15 +73,20 @@ class CRUDUser(CRUDPlus[User]):
"""
salt = bcrypt.gensalt()
obj.password = get_hash_password(obj.password, salt)
dict_obj = obj.model_dump(exclude={'roles'})
dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj)
stmt = select(Role).where(Role.id.in_(obj.roles))
roles = await db.execute(stmt)
new_user.roles = roles.scalars().all()
db.add(new_user)
await db.flush()
role_stmt = select(Role).where(Role.id.in_(obj.roles))
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_data = [AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
"""
@@ -90,12 +99,15 @@ class CRUDUser(CRUDPlus[User]):
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj)
stmt = select(Role)
role = await db.execute(stmt)
new_user.roles = [role.scalars().first()] # 默认绑定第一个角色
db.add(new_user)
await db.flush()
role_stmt = select(Role)
result = await db.execute(role_stmt)
role = result.scalars().first() # 默认绑定第一个角色
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
await db.execute(user_role_stmt)
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
"""
@@ -108,11 +120,20 @@ class CRUDUser(CRUDPlus[User]):
"""
role_ids = obj.roles
del obj.roles
count = await self.update_model(db, input_user.id, obj)
stmt = select(Role).where(Role.id.in_(role_ids))
roles = await db.execute(stmt)
input_user.roles = roles.scalars().all()
role_stmt = select(Role).where(Role.id.in_(role_ids))
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_stmt = delete(user_role).where(user_role.c.user_id == input_user.id)
await db.execute(user_role_stmt)
user_role_data = [AddUserRoleParam(user_id=input_user.id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
return count
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
@@ -156,6 +177,11 @@ class CRUDUser(CRUDPlus[User]):
:param user_id: 用户 ID
:return:
"""
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
await user_social_dao.delete_by_user_id(db, user_id)
return await self.delete_model(db, user_id)
async def check_email(self, db: AsyncSession, email: str) -> User | None:
@@ -205,9 +231,10 @@ class CRUDUser(CRUDPlus[User]):
return await self.select_order(
'id',
'desc',
load_options=[
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
join_conditions=[
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
],
**filters,
)
@@ -256,13 +283,13 @@ class CRUDUser(CRUDPlus[User]):
"""
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
async def get_with_relation(
async def get_join(
self,
db: AsyncSession,
*,
user_id: int | None = None,
username: str | None = None,
) -> User | None:
) -> Any | None:
"""
获取用户关联信息
@@ -278,12 +305,32 @@ class CRUDUser(CRUDPlus[User]):
if username:
filters['username'] = username
return await self.select_model_by_column(
result = await self.select_models(
db,
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))],
load_strategies=['dept'],
join_conditions=[
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == Role.id),
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == Role.id),
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == DataScope.id),
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
],
**filters,
)
return select_join_serialize(
result,
relationships=[
'User-m2o-Dept',
'User-m2m-Role',
'Role-m2m-Menu',
'Role-m2m-DataScope:scopes',
'DataScope-m2m-DataRule:rules',
],
)
user_dao: CRUDUser = CRUDUser(User)
+6 -16
View File
@@ -1,16 +1,9 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_data_scope_rule
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataScope
class DataRule(Base):
"""数据规则表"""
@@ -18,14 +11,11 @@ class DataRule(Base):
__tablename__ = 'sys_data_rule'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称')
model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名')
column: Mapped[str] = mapped_column(String(20), comment='模型字段名')
name: Mapped[str] = mapped_column(sa.String(512), unique=True, comment='名称')
model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in',
)
value: Mapped[str] = mapped_column(String(255), comment='规则值')
# 数据范围规则多对多
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
+3 -16
View File
@@ -1,16 +1,9 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_data_scope_rule, 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):
"""数据范围表"""
@@ -18,11 +11,5 @@ 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='名称')
name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
# 数据范围规则多对多
rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='scopes')
# 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
+9 -31
View File
@@ -1,16 +1,9 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class Dept(Base):
"""部门表"""
@@ -18,28 +11,13 @@ class Dept(Base):
__tablename__ = 'sys_dept'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), comment='部门名称')
name: Mapped[str] = mapped_column(sa.String(64), comment='部门名称')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
leader: Mapped[str | None] = mapped_column(String(20), default=None, comment='负责人')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机')
email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱')
leader: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='负责人')
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
email: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='邮箱')
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
del_flag: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='删除标志(0删除 1存在)',
)
del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)')
# 父级部门一对多
parent_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey('sys_dept.id', ondelete='SET NULL'),
default=None,
index=True,
comment='父部门ID',
)
parent: Mapped[Dept | None] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list[Dept] | None] = relationship(init=False, back_populates='parent')
# 部门用户一对多
users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
# 父级部门
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
+14 -15
View File
@@ -1,11 +1,10 @@
from datetime import datetime
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone
@@ -15,18 +14,18 @@ class LoginLog(DataClassBase):
__tablename__ = 'sys_login_log'
id: Mapped[id_key] = mapped_column(init=False)
user_uuid: Mapped[str] = mapped_column(String(50), comment='用户UUID')
username: Mapped[str] = mapped_column(String(20), comment='用户名')
user_uuid: Mapped[str] = mapped_column(sa.String(64), comment='用户UUID')
username: Mapped[str] = mapped_column(sa.String(64), comment='用户名')
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
ip: Mapped[str] = mapped_column(String(50), comment='登录IP地址')
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
user_agent: Mapped[str] = mapped_column(String(255), comment='请求头')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
ip: Mapped[str] = mapped_column(sa.String(64), comment='登录IP地址')
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str] = mapped_column(sa.String(256), comment='请求头')
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
msg: Mapped[str] = mapped_column(UniversalText, comment='提示消息')
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
created_time: Mapped[datetime] = mapped_column(
TimeZone,
+21 -35
View File
@@ -1,53 +1,39 @@
from sqlalchemy import BigInteger, Column, ForeignKey, Table
import sqlalchemy as sa
from backend.common.model import MappedBase
sys_user_role = Table(
# 用户角色表
user_role = sa.Table(
'sys_user_role',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column('user_id', BigInteger, ForeignKey('sys_user.id', ondelete='CASCADE'), primary_key=True, comment='用户ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
)
sys_role_menu = Table(
# 角色菜单表
role_menu = sa.Table(
'sys_role_menu',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
Column('menu_id', BigInteger, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'),
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
sa.Column('menu_id', sa.BigInteger, primary_key=True, comment='菜单ID'),
)
sys_role_data_scope = Table(
# 角色数据范围表
role_data_scope = sa.Table(
'sys_role_data_scope',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'),
Column(
'data_scope_id',
BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True,
comment='数据范围 ID',
),
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
)
sys_data_scope_rule = Table(
# 数据范围规则表
data_scope_rule = sa.Table(
'sys_data_scope_rule',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column(
'data_scope_id',
BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True,
comment='数据范围 ID',
),
Column(
'data_rule_id',
BigInteger,
ForeignKey('sys_data_rule.id', ondelete='CASCADE'),
primary_key=True,
comment='数据规则 ID',
),
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 ID'),
)
+13 -41
View File
@@ -1,17 +1,8 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_menu
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import Role
from backend.common.model import Base, UniversalText, id_key
class Menu(Base):
@@ -20,38 +11,19 @@ class Menu(Base):
__tablename__ = 'sys_menu'
id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(String(50), comment='菜单标题')
name: Mapped[str] = mapped_column(String(50), comment='菜单名称')
path: Mapped[str | None] = mapped_column(String(200), comment='路由地址')
title: Mapped[str] = mapped_column(sa.String(64), comment='菜单标题')
name: Mapped[str] = mapped_column(sa.String(64), comment='菜单名称')
path: Mapped[str | None] = mapped_column(sa.String(200), comment='路由地址')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标')
icon: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='菜单图标')
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识')
component: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='权限标识')
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='外链地址',
)
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
link: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='外链地址')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
# 父级菜单一对多
parent_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey('sys_menu.id', ondelete='SET NULL'),
default=None,
index=True,
comment='父菜单ID',
)
parent: Mapped[Menu | None] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list[Menu] | None] = relationship(init=False, back_populates='parent')
# 菜单角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
# 父级菜单
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父菜单ID')
+20 -24
View File
@@ -1,11 +1,10 @@
from datetime import datetime
from sqlalchemy import String
from sqlalchemy.dialects.mysql import JSON, LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone
@@ -15,28 +14,25 @@ class OperaLog(DataClassBase):
__tablename__ = 'sys_opera_log'
id: Mapped[id_key] = mapped_column(init=False)
trace_id: Mapped[str] = mapped_column(String(32), comment='请求跟踪 ID')
username: Mapped[str | None] = mapped_column(String(20), comment='用户名')
method: Mapped[str] = mapped_column(String(20), comment='请求类型')
title: Mapped[str] = mapped_column(String(255), comment='操作模块')
path: Mapped[str] = mapped_column(String(500), comment='请求路径')
ip: Mapped[str] = mapped_column(String(50), comment='IP地址')
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
user_agent: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='请求头')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
args: Mapped[str | None] = mapped_column(JSON(), comment='请求参数')
trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
method: Mapped[str] = mapped_column(sa.String(32), comment='请求类型')
title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP地址')
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str] = mapped_column(sa.String(512), comment='请求头')
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='请求参数')
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码')
msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
code: Mapped[str] = mapped_column(sa.String(32), insert_default='200', comment='操作状态码')
msg: Mapped[str | None] = mapped_column(UniversalText, comment='提示消息')
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms')
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
created_time: Mapped[datetime] = mapped_column(
TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
)
+6 -32
View File
@@ -1,17 +1,8 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
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
if TYPE_CHECKING:
from backend.app.admin.model import DataScope, Menu, User
from backend.common.model import Base, UniversalText, id_key
class Role(Base):
@@ -20,24 +11,7 @@ class Role(Base):
__tablename__ = 'sys_role'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称')
name: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='角色名称')
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
is_filter_scopes: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=True,
comment='过滤数据权限(0否 1是)',
)
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
# 角色用户多对多
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')
# 角色数据范围多对多
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
+17 -47
View File
@@ -1,20 +1,13 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.admin.model.m2m import sys_user_role
from backend.common.model import Base, TimeZone, id_key
from backend.database.db import uuid4_str
from backend.utils.timezone import timezone
if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role
class User(Base):
"""用户表"""
@@ -22,45 +15,22 @@ class User(Base):
__tablename__ = 'sys_user'
id: Mapped[id_key] = mapped_column(init=False)
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(String(20), comment='昵称')
password: Mapped[str | None] = mapped_column(String(255), comment='密码')
salt: Mapped[bytes | None] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐')
email: Mapped[str | None] = mapped_column(String(50), default=None, unique=True, index=True, comment='邮箱')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像')
uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(255), comment='加密盐')
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, unique=True, index=True, comment='邮箱')
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_superuser: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='超级权限(0否 1是)',
)
is_staff: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='后台管理登陆(0否 1是)',
)
is_multi_login: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='是否重复登陆(0否 1是)',
)
is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
last_login_time: Mapped[datetime | None] = mapped_column(
TimeZone,
init=False,
onupdate=timezone.now,
comment='上次登录',
TimeZone, init=False, onupdate=timezone.now, comment='上次登录'
)
# 部门用户一对多
dept_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'),
default=None,
comment='部门关联ID',
)
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
# 逻辑外键
dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='部门关联ID')
+1 -1
View File
@@ -34,7 +34,7 @@ class DeleteDataRuleParam(SchemaBase):
class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情"""
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True, frozen=True)
id: int = Field(description='规则 ID')
created_time: datetime = Field(description='创建时间')
+8 -1
View File
@@ -22,6 +22,13 @@ class UpdateDataScopeParam(DataScopeBase):
"""更新数据范围参数"""
class CreateDataScopeRuleParam(SchemaBase):
"""创建数据范围规则参数"""
data_scope_id: int = Field(description='数据范围 ID')
data_rule_id: int = Field(description='数据规则 ID')
class UpdateDataScopeRuleParam(SchemaBase):
"""更新数据范围规则参数"""
@@ -47,4 +54,4 @@ class GetDataScopeDetail(DataScopeBase):
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情"""
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
+16 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_scope import GetDataScopeDetail
from backend.app.admin.schema.data_scope import GetDataScopeWithRelationDetail
from backend.app.admin.schema.menu import GetMenuDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
@@ -31,12 +31,26 @@ class DeleteRoleParam(SchemaBase):
pks: list[int] = Field(description='角色 ID 列表')
class CreateRoleMenuParam(SchemaBase):
"""创建角色菜单参数"""
role_id: int = Field(description='角色 ID')
menu_id: int = Field(description='菜单 ID')
class UpdateRoleMenuParam(SchemaBase):
"""更新角色菜单参数"""
menus: list[int] = Field(description='菜单 ID 列表')
class CreateRoleScopeParam(SchemaBase):
"""创建角色数据范围参数"""
role_id: int = Field(description='角色 ID')
data_scope_id: int = Field(description='数据范围 ID')
class UpdateRoleScopeParam(SchemaBase):
"""更新角色数据范围参数"""
@@ -57,4 +71,4 @@ class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
scopes: list[GetDataScopeWithRelationDetail | None] = Field([], description='数据范围列表')
+12 -5
View File
@@ -1,13 +1,13 @@
from datetime import datetime
from typing import Any
from typing import Annotated, Any
from pydantic import ConfigDict, Field, HttpUrl, model_validator
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer, model_validator
from typing_extensions import Self
from backend.app.admin.schema.dept import GetDeptDetail
from backend.app.admin.schema.role import GetRoleWithRelationDetail
from backend.common.enums import StatusType
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
class AuthSchemaBase(SchemaBase):
@@ -34,13 +34,20 @@ class AddUserParam(AuthSchemaBase):
roles: list[int] = Field(description='角色 ID 列表')
class AddUserRoleParam(SchemaBase):
"""添加用户角色"""
user_id: int = Field(description='用户 ID')
role_id: int = Field(description='角色 ID')
class AddOAuth2UserParam(AuthSchemaBase):
"""添加 OAuth2 用户参数"""
password: str | None = Field(None, description='密码')
nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱')
avatar: HttpUrl | None = Field(None, description='头像地址')
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
class ResetPasswordParam(SchemaBase):
@@ -57,7 +64,7 @@ class UserInfoSchemaBase(SchemaBase):
dept_id: int | None = Field(None, description='部门 ID')
username: str = Field(description='用户名')
nickname: str = Field(description='昵称')
avatar: HttpUrl | None = Field(None, description='头像地址')
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号')
+1 -1
View File
@@ -208,7 +208,7 @@ class AuthService:
raise errors.NotFoundError(msg='用户不存在')
if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
if not user.is_multi_login and await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
if not user.is_multi_login and await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
new_token = await create_new_token(
refresh_token,
@@ -11,6 +11,7 @@ from backend.app.admin.schema.data_rule import (
GetDataRuleColumnDetail,
UpdateDataRuleParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.core.conf import settings
@@ -113,6 +114,7 @@ class DataRuleService:
if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj)
await user_cache_manager.clear_by_data_rule_id(db, [pk])
return count
@staticmethod
@@ -125,6 +127,7 @@ class DataRuleService:
:return:
"""
count = await data_rule_dao.delete(db, obj.pks)
await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
return count
@@ -11,10 +11,9 @@ from backend.app.admin.schema.data_scope import (
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.core.conf import settings
from backend.database.redis import redis_client
class DataScopeService:
@@ -57,7 +56,7 @@ class DataScopeService:
:return:
"""
data_scope = await data_scope_dao.get_with_relation(db, pk)
data_scope = await data_scope_dao.get_join(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@@ -105,9 +104,7 @@ class DataScopeService:
if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj)
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}')
await user_cache_manager.clear_by_data_scope_id(db, [pk])
return count
@staticmethod
@@ -115,11 +112,13 @@ class DataScopeService:
"""
更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:param rule_ids: 规则 ID 列表
:return:
"""
count = await data_scope_dao.update_rules(db, pk, rule_ids)
await user_cache_manager.clear_by_data_scope_id(db, [pk])
return count
@staticmethod
@@ -132,12 +131,7 @@ class DataScopeService:
:return:
"""
count = await data_scope_dao.delete(db, obj.pks)
for pk in obj.pks:
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}')
await user_cache_manager.clear_by_data_scope_id(db, obj.pks)
return count
+8 -6
View File
@@ -1,11 +1,11 @@
from typing import Any
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
@@ -34,7 +34,7 @@ class DeptService:
async def get_tree(
*,
db: AsyncSession,
request: Request,
request_user: GetUserInfoWithRelationDetail,
name: str | None,
leader: str | None,
phone: str | None,
@@ -44,7 +44,7 @@ class DeptService:
获取部门树形结构
:param db: 数据库会话
:param request: FastAPI 请求对象
:param request_user: 请求用户
:param name: 部门名称
:param leader: 部门负责人
:param phone: 联系电话
@@ -52,7 +52,7 @@ class DeptService:
:return:
"""
dept_select = await dept_dao.get_all(request, db, name, leader, phone, status)
dept_select = await dept_dao.get_all(db, request_user, name, leader, phone, status)
tree_data = get_tree_data(dept_select)
return tree_data
@@ -68,7 +68,7 @@ class DeptService:
dept = await dept_dao.get_by_name(db, obj.name)
if dept:
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id:
if obj.parent_id is not None:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
raise errors.NotFoundError(msg='父级部门不存在')
@@ -107,7 +107,9 @@ class DeptService:
:param pk: 部门 ID
:return:
"""
dept = await dept_dao.get_with_relation(db, pk)
dept = await dept_dao.get_join(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
if dept.users:
raise errors.ConflictError(msg='部门下存在用户,无法删除')
children = await dept_dao.get_children(db, pk)
+10 -13
View File
@@ -6,9 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.model import Menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
@@ -54,7 +53,7 @@ class MenuService:
:param request: FastAPI 请求对象
:return:
"""
menu_data = None
if request.user.is_superuser:
menu_data = await menu_dao.get_sidebar(db, None)
else:
@@ -64,8 +63,11 @@ class MenuService:
for role in roles:
menu_ids.update(menu.id for menu in role.menus)
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
menu_tree = get_vben5_tree_data(menu_data)
return menu_tree
if menu_data:
return get_vben5_tree_data(menu_data)
return []
@staticmethod
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
@@ -109,9 +111,7 @@ class MenuService:
if obj.parent_id == menu.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级')
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}')
await user_cache_manager.clear_by_menu_id(db, [pk])
return count
@staticmethod
@@ -127,12 +127,9 @@ class MenuService:
children = await menu_dao.get_children(db, pk)
if children:
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk)
count = await menu_dao.delete(db, pk)
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}')
if count:
await user_cache_manager.clear_by_menu_id(db, [pk])
return count
+10 -17
View File
@@ -14,10 +14,9 @@ from backend.app.admin.schema.role import (
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data
@@ -34,7 +33,7 @@ class RoleService:
:return:
"""
role = await role_dao.get_with_relation(db, pk)
role = await role_dao.get_join(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
return role
@@ -74,10 +73,11 @@ class RoleService:
:return:
"""
role = await role_dao.get_with_relation(db, pk)
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menu_tree = get_tree_data(role.menus) if role.menus else []
menus = await role_dao.get_menus(db, pk)
menu_tree = get_tree_data(menus) if menus else []
return menu_tree
@staticmethod
@@ -90,7 +90,7 @@ class RoleService:
:return:
"""
role = await role_dao.get_with_relation(db, pk)
role = await role_dao.get_join(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
scope_ids = [scope.id for scope in role.scopes]
@@ -128,8 +128,7 @@ class RoleService:
if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='角色已存在')
count = await role_dao.update(db, pk, obj)
for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await user_cache_manager.clear_by_role_id(db, [pk])
return count
@staticmethod
@@ -151,8 +150,7 @@ class RoleService:
if not menu:
raise errors.NotFoundError(msg='菜单不存在')
count = await role_dao.update_menus(db, pk, menu_ids)
for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await user_cache_manager.clear_by_role_id(db, [pk])
return count
@staticmethod
@@ -174,8 +172,7 @@ class RoleService:
if not scope:
raise errors.NotFoundError(msg='数据范围不存在')
count = await role_dao.update_scopes(db, pk, scope_ids)
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await user_cache_manager.clear_by_role_id(db, [pk])
return count
@staticmethod
@@ -189,11 +186,7 @@ class RoleService:
"""
count = await role_dao.delete(db, obj.pks)
for pk in obj.pks:
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}')
await user_cache_manager.clear_by_role_id(db, obj.pks)
return count
+30 -43
View File
@@ -23,6 +23,7 @@ from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import get_token, jwt_decode, password_verify
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.serializers import select_join_serialize
class UserService:
@@ -38,7 +39,7 @@ class UserService:
:param username: 用户名
:return:
"""
user = await user_dao.get_with_relation(db, user_id=pk, username=username)
user = await user_dao.get_join(db, user_id=pk, username=username)
if not user:
raise errors.NotFoundError(msg='用户不存在')
return user
@@ -52,7 +53,7 @@ class UserService:
:param pk: 用户 ID
:return:
"""
user = await user_dao.get_with_relation(db, user_id=pk)
user = await user_dao.get_join(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
return user.roles
@@ -70,7 +71,10 @@ class UserService:
:return:
"""
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
return await paging_data(db, user_select)
data = await paging_data(db, user_select)
if data['items']:
data['items'] = select_join_serialize(data['items'], relationships=['User-m2o-Dept', 'User-m2m-Role'])
return data
@staticmethod
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
@@ -103,11 +107,13 @@ class UserService:
:param obj: 用户更新参数
:return:
"""
user = await user_dao.get_with_relation(db, user_id=pk)
user = await user_dao.get_join(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
if obj.dept_id and obj.dept_id != user.dept_id and not await dept_dao.get(db, dept_id=obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
@@ -200,93 +206,74 @@ class UserService:
return count
@staticmethod
async def update_nickname(*, db: AsyncSession, request: Request, nickname: str) -> int:
async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
"""
更新当前用户昵称
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_id: 用户 ID
:param nickname: 用户昵称
:return:
"""
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.update_nickname(db, token_payload.id, nickname)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
count = await user_dao.update_nickname(db, user_id, nickname)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_avatar(*, db: AsyncSession, request: Request, avatar: str) -> int:
async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
"""
更新当前用户头像
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_id: 用户 ID
:param avatar: 头像地址
:return:
"""
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.update_avatar(db, token_payload.id, avatar)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
count = await user_dao.update_avatar(db, user_id, avatar)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_email(*, db: AsyncSession, request: Request, captcha: str, email: str) -> int:
async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int:
"""
更新当前用户邮箱
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_id: 用户 ID
:param captcha: 邮箱验证码
:param email: 邮箱
:return:
"""
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
if not captcha_code:
raise errors.RequestError(msg='验证码已失效,请重新获取')
if captcha != captcha_code:
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
count = await user_dao.update_email(db, token_payload.id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
count = await user_dao.update_email(db, user_id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_password(*, db: AsyncSession, request: Request, obj: ResetPasswordParam) -> int:
async def update_password(*, db: AsyncSession, user_id: int, hash_password: str, obj: ResetPasswordParam) -> int:
"""
更新当前用户密码
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_id: 用户 ID
:param hash_password: 哈希密码
:param obj: 密码重置参数
:return:
"""
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
if not password_verify(obj.old_password, hash_password):
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='密码输入不一致')
count = await user_dao.reset_password(db, user.id, obj.new_password)
count = await user_dao.reset_password(db, user_id, obj.new_password)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
View File
+98
View File
@@ -0,0 +1,98 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.model.m2m import data_scope_rule, role_data_scope, role_menu, user_role
from backend.core.conf import settings
from backend.database.redis import redis_client
class UserCacheManager:
"""用户缓存管理"""
@staticmethod
async def clear(user_ids: Sequence[int]) -> None:
"""
清理用户缓存
:param user_ids: 用户 ID 列表
:return:
"""
if user_ids:
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids])
async def clear_by_role_id(self, db: AsyncSession, role_ids: list[int]) -> None:
"""
通过角色 ID 清理用户缓存
:param db: 数据库会话
:param role_ids: 角色 ID 列表
:return:
"""
stmt = select(user_role.c.user_id).where(user_role.c.role_id.in_(role_ids)).distinct()
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_menu_id(self, db: AsyncSession, menu_ids: list[int]) -> None:
"""
通过菜单 ID 清理用户缓存
:param db: 数据库会话
:param menu_ids: 菜单 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_menu, user_role.c.role_id == role_menu.c.role_id)
.where(role_menu.c.menu_id.in_(menu_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_data_scope_id(self, db: AsyncSession, scope_ids: list[int]) -> None:
"""
通过数据范围 ID 清理用户缓存
:param db: 数据库会话
:param scope_ids: 数据范围 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
.where(role_data_scope.c.data_scope_id.in_(scope_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_data_rule_id(self, db: AsyncSession, rule_ids: list[int]) -> None:
"""
通过数据规则 ID 清理用户缓存
:param db: 数据库会话
:param rule_ids: 数据规则 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
.join(data_scope_rule, role_data_scope.c.data_scope_id == data_scope_rule.c.data_scope_id)
.where(data_scope_rule.c.data_rule_id.in_(rule_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
user_cache_manager: UserCacheManager = UserCacheManager()
+10 -5
View File
@@ -27,15 +27,20 @@ def init_celery() -> celery.Celery:
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations()
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}/{settings.CELERY_RABBITMQ_VHOST}'
if settings.CELERY_BROKER == 'redis':
broker_url = f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
result_backend = f'db+postgresql+psycopg://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}'
if settings.DATABASE_TYPE == 'mysql':
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
app = celery.Celery(
'fba_celery',
broker_url=f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
if settings.CELERY_BROKER == 'redis'
else f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}',
broker_url=broker_url,
broker_connection_retry_on_startup=True,
result_backend=f'db+{settings.DATABASE_TYPE}+{"pymysql" if settings.DATABASE_TYPE == "mysql" else "psycopg"}'
f'://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}',
result_backend=result_backend,
result_extended=True,
database_engine_options={'echo': settings.DATABASE_ECHO},
# result_expires=0,
+7 -8
View File
@@ -1,11 +1,10 @@
from datetime import datetime, timezone
import sqlalchemy as sa
from celery import states
from sqlalchemy.types import PickleType
from backend.common.model import MappedBase
from backend.common.model import MappedBase, TimeZone
from backend.utils.timezone import timezone
"""
重写 celery.backends.database.models 内部所有模型适配 fba 创建表和 alembic 迁移
@@ -20,12 +19,12 @@ class Task(MappedBase):
id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True)
task_id = sa.Column(sa.String(155), unique=True)
status = sa.Column(sa.String(50), default=states.PENDING)
status = sa.Column(sa.String(64), default=states.PENDING)
result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(
sa.DateTime,
default=datetime.now(timezone.utc),
onupdate=datetime.now(timezone.utc),
TimeZone,
default=timezone.now,
onupdate=timezone.now,
nullable=True,
)
traceback = sa.Column(sa.Text, nullable=True)
@@ -87,7 +86,7 @@ class TaskSet(MappedBase):
id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True)
taskset_id = sa.Column(sa.String(155), unique=True)
result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(sa.DateTime, default=datetime.now(timezone.utc), nullable=True)
date_done = sa.Column(TimeZone, default=timezone.now, nullable=True)
def __init__(self, taskset_id, result) -> None: # noqa: ANN001
self.taskset_id = taskset_id
+16 -33
View File
@@ -2,18 +2,13 @@ import asyncio
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
String,
event,
)
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.exception import errors
from backend.common.model import Base, TimeZone, id_key
from backend.common.model import Base, TimeZone, UniversalText, id_key
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.timezone import timezone
@@ -25,37 +20,25 @@ class TaskScheduler(Base):
__tablename__ = 'task_scheduler'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='任务名称')
task: Mapped[str] = mapped_column(String(255), comment='要运行的 Celery 任务')
args: Mapped[str | None] = mapped_column(JSON(), comment='任务可接收的位置参数')
kwargs: Mapped[str | None] = mapped_column(JSON(), comment='任务可接收的关键字参数')
queue: Mapped[str | None] = mapped_column(String(255), comment='CELERY_TASK_QUEUES 中定义的队列')
exchange: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的交换机')
routing_key: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的路由密钥')
name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='任务名称')
task: Mapped[str] = mapped_column(sa.String(256), comment='要运行的 Celery 任务')
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的位置参数')
kwargs: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的关键字参数')
queue: Mapped[str | None] = mapped_column(sa.String(256), comment='CELERY_TASK_QUEUES 中定义的队列')
exchange: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的交换机')
routing_key: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的路由密钥')
start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
expire_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务不再触发的截止时间')
expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差')
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
interval_period: Mapped[str | None] = mapped_column(String(255), comment='任务运行之间的周期类型')
crontab: Mapped[str | None] = mapped_column(String(50), default='* * * * *', comment='任务运行的 Crontab 计划')
one_off: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='是否仅运行一次',
)
enabled: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=True,
comment='是否启用任务',
)
interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型')
crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='任务运行的 Crontab 计划')
one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次')
enabled: Mapped[bool] = mapped_column(default=True, comment='是否启用任务')
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
no_changes: bool = False
+5 -2
View File
@@ -2,17 +2,20 @@ from celery import shared_task
from backend.app.admin.service.login_log_service import login_log_service
from backend.app.admin.service.opera_log_service import opera_log_service
from backend.database.db import async_db_session
@shared_task
async def delete_db_opera_log() -> str:
"""自动删除数据库操作日志"""
await opera_log_service.delete_all()
async with async_db_session.begin() as db:
await opera_log_service.delete_all(db=db)
return 'Success'
@shared_task
async def delete_db_login_log() -> str:
"""自动删除数据库登录日志"""
await login_log_service.delete_all()
async with async_db_session.begin() as db:
await login_log_service.delete_all(db=db)
return 'Success'
+1 -1
View File
@@ -399,7 +399,7 @@ class DatabaseScheduler(Scheduler):
"""获取所有任务调度"""
async with async_db_session() as db:
logger.debug('DatabaseScheduler: Fetching database schedule')
stmt = select(TaskScheduler).where(TaskScheduler.enabled == 1)
stmt = select(TaskScheduler).where(TaskScheduler.enabled == True) # noqa: E712
query = await db.execute(stmt)
schedulers = query.scalars().all()
s = {}
-8
View File
@@ -103,14 +103,6 @@ class StatusType(IntEnum):
enable = 1
class UserSocialType(StrEnum):
"""用户社交类型"""
github = 'GitHub'
google = 'Google'
linux_do = 'LinuxDo'
class FileType(StrEnum):
"""文件类型"""
+22 -7
View File
@@ -1,10 +1,12 @@
from datetime import datetime
from typing import Annotated
from sqlalchemy import BigInteger, DateTime, TypeDecorator
from sqlalchemy import BigInteger, DateTime, Text, TypeDecorator
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
from backend.core.conf import settings
from backend.utils.snowflake import snowflake
from backend.utils.timezone import timezone
@@ -41,16 +43,21 @@ snowflake_id_key = Annotated[
]
# Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
class UserMixin(MappedAsDataclass):
"""用户 Mixin 数据类"""
class UniversalText(TypeDecorator[str]):
"""PostgreSQL、MySQL 兼容性(长)文本类型"""
created_by: Mapped[int] = mapped_column(sort_order=998, comment='创建者')
updated_by: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者')
impl = LONGTEXT if settings.DATABASE_TYPE == 'mysql' else Text
cache_ok = True
def process_bind_param(self, value: str | None, dialect) -> str | None: # noqa: ANN001
return value
def process_result_value(self, value: str | None, dialect) -> str | None: # noqa: ANN001
return value
class TimeZone(TypeDecorator[datetime]):
"""时区感知 DateTime"""
"""PostgreSQL、MySQL 兼容性时区感知类型"""
impl = DateTime(timezone=True)
cache_ok = True
@@ -71,6 +78,14 @@ class TimeZone(TypeDecorator[datetime]):
return value
# Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
class UserMixin(MappedAsDataclass):
"""用户 Mixin 数据类"""
created_by: Mapped[int] = mapped_column(sort_order=998, comment='创建者')
updated_by: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者')
class DateTimeMixin(MappedAsDataclass):
"""日期时间 Mixin 数据类"""
+7 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Annotated
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
@@ -27,3 +27,9 @@ class SchemaBase(BaseModel):
else timezone.to_str(x),
},
)
def ser_string(value: Any) -> str | None:
if value:
return str(value)
return value
+2 -3
View File
@@ -22,7 +22,6 @@ from backend.common.exception.errors import TokenError
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.serializers import select_as_dict
from backend.utils.timezone import timezone
@@ -246,7 +245,7 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
"""
from backend.app.admin.crud.crud_user import user_dao
user = await user_dao.get_with_relation(db, user_id=pk)
user = await user_dao.get_join(db, user_id=pk)
if not user:
raise errors.TokenError(msg='Token 无效')
if not user.status:
@@ -297,7 +296,7 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
if not cache_user:
async with async_db_session() as db:
current_user = await get_current_user(db, user_id)
user = GetUserInfoWithRelationDetail(**select_as_dict(current_user))
user = GetUserInfoWithRelationDetail.model_validate(current_user)
await redis_client.setex(
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
settings.TOKEN_EXPIRE_SECONDS,
+13 -25
View File
@@ -1,8 +1,7 @@
from fastapi import Request
from sqlalchemy import ColumnElement, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.context import ctx
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.exception import errors
@@ -42,54 +41,43 @@ class RequestPermission:
ctx.permission = self.value
async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnElement[bool]: # noqa: C901
def filter_data_permission(request_user: GetUserInfoWithRelationDetail) -> ColumnElement[bool]: # noqa: C901
"""
过滤数据权限控制用户可见数据范围
使用场景
- 控制用户能看到哪些数据
:param db: 数据库会话
:param request: FastAPI 请求对象
:param request_user: 请求用户
:return:
"""
# 是否过滤数据权限
if request.user.is_superuser:
if request_user.is_superuser:
return or_(1 == 1)
for role in request.user.roles:
for role in request_user.roles:
if not role.is_filter_scopes:
return or_(1 == 1)
# 获取数据范围
data_scope_ids = set()
for role in request.user.roles:
# 获取数据规则
data_rules = set()
for role in request_user.roles:
for scope in role.scopes:
if scope.status:
data_scope_ids.add(scope.id)
data_rules.update(scope.rules)
# 无规则用户不做过滤
if not list(data_scope_ids):
if not list(data_rules):
return or_(1 == 1)
# 获取数据范围规则
unique_data_rules = {}
for data_scope_id in list(data_scope_ids):
data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope_id)
for rule in data_scope_with_relation.rules:
unique_data_rules[rule.id] = rule
# 转换为列表
data_rule_list = list(unique_data_rules.values())
where_and_list = []
where_or_list = []
for data_rule in data_rule_list:
for data_rule in list(data_rules):
# 验证规则模型
rule_model = data_rule.model
if rule_model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则模型不存在')
raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
# 验证规则列
@@ -98,7 +86,7 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl
]
column = data_rule.column
if column not in model_columns:
raise errors.NotFoundError(msg='数据规则模型列不存在')
raise errors.NotFoundError(msg='数据规则可用模型列不存在')
# 构建过滤条件
column_obj = getattr(model_ins, column)
+7 -1
View File
@@ -90,7 +90,7 @@ class Settings(BaseSettings):
# 数据权限
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
'部门': 'backend.app.admin.model.Dept',
'Dept': 'backend.app.admin.model.Dept',
}
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
'id',
@@ -173,6 +173,7 @@ class Settings(BaseSettings):
'/openapi',
f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
f'{FASTAPI_API_V1_PATH}/oauth2/github/callback',
f'{FASTAPI_API_V1_PATH}/oauth2/google/callback',
f'{FASTAPI_API_V1_PATH}/oauth2/linux-do/callback',
]
OPERA_LOG_ENCRYPT_TYPE: int = 1 # 0: AES (性能损耗); 1: md5; 2: ItsDangerous; 3: 不加密, others: 替换为 ******
@@ -188,6 +189,7 @@ class Settings(BaseSettings):
# Plugin 配置
PLUGIN_PIP_CHINA: bool = True
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
PLUGIN_PIP_MAX_RETRY: int = 3
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
# I18n 配置
@@ -208,6 +210,7 @@ class Settings(BaseSettings):
# 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_RABBITMQ_VHOST: str = ''
CELERY_REDIS_PREFIX: str = 'fba:celery'
CELERY_TASK_MAX_RETRIES: int = 5
@@ -228,6 +231,9 @@ class Settings(BaseSettings):
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# 基础配置
OAUTH2_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback'
OAUTH2_GOOGLE_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/google/callback'
OAUTH2_LINUX_DO_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/linux-do/callback'
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
##################################################
+26 -14
View File
@@ -38,26 +38,38 @@ class RedisCli(Redis):
log.error('❌ 数据库 redis 连接异常 {}', e)
sys.exit()
async def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None) -> None:
async def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None, batch_size: int = 1000) -> None:
"""
删除指定前缀的所有 key
:param prefix: 前缀
:param exclude: 排除的 key
:param prefix: 要删除的键前缀
:param exclude: 排除的键或键列表
:param batch_size: 批量删除的大小避免一次性删除过多键导致 Redis 阻塞
:return:
"""
keys = []
exclude_set = set(exclude) if isinstance(exclude, list) else {exclude} if isinstance(exclude, str) else set()
batch_keys = []
async for key in self.scan_iter(match=f'{prefix}*'):
if isinstance(exclude, str):
if key != exclude:
keys.append(key)
elif isinstance(exclude, list):
if key not in exclude:
keys.append(key)
else:
keys.append(key)
if keys:
await self.delete(*keys)
if key not in exclude_set:
batch_keys.append(key)
if len(batch_keys) >= batch_size:
await self.delete(*batch_keys)
batch_keys.clear()
if batch_keys:
await self.delete(*batch_keys)
async def get_prefix(self, prefix: str, count: int = 100) -> list[str]:
"""
获取指定前缀的所有 key
:param prefix: 要搜索的键前缀
:param count: 每次扫描批次的数量值越大扫描速度越快但会占用更多服务器资源
:return:
"""
return [key async for key in self.scan_iter(match=f'{prefix}*', count=count)]
# 创建 redis 客户端单例
-8
View File
@@ -32,12 +32,4 @@ class AccessMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
if request.method != 'OPTIONS':
log.debug('<-- 请求结束')
log.info(
f'{request.client.host: <15} | {request.method: <8} | {response.status_code: <6} | '
f'{path} | {(time.perf_counter() - perf_time) * 1000:.3f}ms',
)
return response
+5 -2
View File
@@ -51,7 +51,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
error = None
try:
response = await call_next(request)
elapsed = (time.perf_counter() - ctx.perf_time) * 1000
elapsed = round((time.perf_counter() - ctx.perf_time) * 1000, 3)
for e in [
'__request_http_exception__',
'__request_validation_exception__',
@@ -65,7 +65,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
log.error(f'请求异常: {msg}')
break
except Exception as e:
elapsed = (time.perf_counter() - ctx.perf_time) * 1000
elapsed = round((time.perf_counter() - ctx.perf_time) * 1000, 3)
code = getattr(e, 'code', StandardResponseCode.HTTP_500) # 兼容 SQLAlchemy 异常用法
msg = getattr(e, 'msg', str(e)) # 不建议使用 traceback 模块获取错误信息,会暴漏代码信息
status = StatusType.disable
@@ -86,6 +86,9 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
log.debug(f'接口摘要:[{summary}]')
log.debug(f'请求地址:[{ctx.ip}]')
log.debug(f'请求参数:{args}')
log.info(f'{request.client.host: <15} | {request.method: <8} | {code!s: <6} | {path} | {elapsed:.3f}ms')
if request.method != 'OPTIONS':
log.debug('<-- 请求结束')
# 日志创建
opera_log_in = CreateOperaLogParam(
@@ -52,7 +52,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
if table_name is not None:
filters['table_name__like'] = f'%{table_name}%'
return await self.select_order('id', 'desc', load_strategies={'gen_column': 'noload'}, **filters)
return await self.select_order('id', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateGenBusinessParam) -> None:
"""
+17 -27
View File
@@ -1,16 +1,8 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.plugin.code_generator.model import GenColumn
from backend.common.model import Base, UniversalText, id_key
class GenBusiness(Base):
@@ -19,21 +11,19 @@ class GenBusiness(Base):
__tablename__ = 'gen_business'
id: Mapped[id_key] = mapped_column(init=False)
app_name: Mapped[str] = mapped_column(String(50), comment='应用名称(英文)')
table_name: Mapped[str] = mapped_column(String(255), unique=True, comment='表名称(英文)')
doc_comment: Mapped[str] = mapped_column(String(255), comment='文档注释(用于函数/参数文档)')
table_comment: Mapped[str | None] = mapped_column(String(255), default=None, comment='表描述')
app_name: Mapped[str] = mapped_column(sa.String(64), comment='应用名称(英文)')
table_name: Mapped[str] = mapped_column(sa.String(256), unique=True, comment='表名称(英文)')
doc_comment: Mapped[str] = mapped_column(sa.String(256), comment='文档注释(用于函数/参数文档)')
table_comment: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='表描述')
# relate_model_fk: Mapped[int | None] = mapped_column(default=None, comment='关联表外键')
class_name: Mapped[str | None] = mapped_column(String(50), default=None, comment='基础类名(默认为英文表名称)')
schema_name: Mapped[str | None] = mapped_column(String(50), default=None, comment='Schema 名称 (默认为英文表名称)')
filename: Mapped[str | None] = mapped_column(String(50), default=None, comment='基础文件名(默认为英文表名称')
default_datetime_column: Mapped[bool] = mapped_column(default=True, comment='是否存在默认时间列')
api_version: Mapped[str] = mapped_column(String(20), default='v1', comment='代码生成 api 版本,默认为 v1')
gen_path: Mapped[str | None] = mapped_column(String(255), default=None, comment='代码生成路径(默认为 app 根路径)')
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
class_name: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='基础类名(默认为英文表名称)')
schema_name: Mapped[str | None] = mapped_column(
sa.String(64), default=None, comment='Schema 名称 (默认为英文表名称)'
)
# 代码生成业务模型列一对多
gen_column: Mapped[list[GenColumn]] = relationship(init=False, back_populates='gen_business')
filename: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='基础文件名(默认为英文表名称)')
default_datetime_column: Mapped[bool] = mapped_column(default=True, comment='是否存在默认时间列')
api_version: Mapped[str] = mapped_column(sa.String(32), default='v1', comment='代码生成 api 版本,默认为 v1')
gen_path: Mapped[str | None] = mapped_column(
sa.String(256), default=None, comment='代码生成路径(默认为 app 根路径)'
)
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
+10 -28
View File
@@ -1,16 +1,8 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import DataClassBase, id_key
if TYPE_CHECKING:
from backend.plugin.code_generator.model import GenBusiness
from backend.common.model import DataClassBase, UniversalText, id_key
class GenColumn(DataClassBase):
@@ -19,25 +11,15 @@ class GenColumn(DataClassBase):
__tablename__ = 'gen_column'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), comment='列名称')
comment: Mapped[str | None] = mapped_column(String(255), default=None, comment='列描述')
type: Mapped[str] = mapped_column(String(20), default='String', comment='SQLA 模型列类型')
pd_type: Mapped[str] = mapped_column(String(20), default='str', comment='列类型对应的 pydantic 类型')
default: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='列默认值',
)
name: Mapped[str] = mapped_column(sa.String(64), comment='列名称')
comment: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='列描述')
type: Mapped[str] = mapped_column(sa.String(32), default='String', comment='SQLA 模型列类型')
pd_type: Mapped[str] = mapped_column(sa.String(32), default='str', comment='列类型对应的 pydantic 类型')
default: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='列默认值')
sort: Mapped[int | None] = mapped_column(default=1, comment='列排序')
length: Mapped[int] = mapped_column(default=0, comment='列长度')
is_pk: Mapped[bool] = mapped_column(default=False, comment='是否主键')
is_nullable: Mapped[bool] = mapped_column(default=False, comment='是否可为空')
# 代码生成业务模型列一对多
gen_business_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey('gen_business.id', ondelete='CASCADE'),
default=0,
comment='代码生成业务ID',
)
gen_business: Mapped[GenBusiness | None] = relationship(init=False, back_populates='gen_column')
# 逻辑外键
gen_business_id: Mapped[int] = mapped_column(sa.BigInteger, default=0, comment='代码生成业务ID')
+1 -1
View File
@@ -1,6 +1,6 @@
[plugin]
summary = '代码生成'
version = '0.0.5'
version = '0.0.6'
description = '生成通用业务代码'
author = 'wu-clan'
@@ -15,7 +15,6 @@ from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.database.db import CurrentSession
router = APIRouter()
+9 -18
View File
@@ -1,9 +1,8 @@
from sqlalchemy import Boolean, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
from backend.common.model import Base, UniversalText, id_key
class Config(Base):
@@ -12,17 +11,9 @@ class Config(Base):
__tablename__ = 'sys_config'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(20), comment='名称')
type: Mapped[str | None] = mapped_column(String(20), server_default=None, comment='类型')
key: Mapped[str] = mapped_column(String(50), unique=True, comment='键名')
value: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='键值')
is_frontend: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='是否前端',
)
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
name: Mapped[str] = mapped_column(sa.String(32), comment='名称')
type: Mapped[str | None] = mapped_column(sa.String(32), server_default=None, comment='类型')
key: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='键名')
value: Mapped[str] = mapped_column(UniversalText, comment='键值')
is_frontend: Mapped[bool] = mapped_column(default=False, comment='是否前端')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
+6 -6
View File
@@ -1,8 +1,8 @@
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
values
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, null, now(), null);
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
@@ -1,8 +1,8 @@
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
values
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, null, now(), null);
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
@@ -1,11 +1,10 @@
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
values
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, null, now(), null);
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
-- reset auto-increment values for each table based on max id
select setval(pg_get_serial_sequence('sys_config', 'id'),coalesce(max(id), 0) + 1, true) from sys_config;
@@ -1,8 +1,8 @@
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
values
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, null, now(), null);
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
+7 -8
View File
@@ -19,7 +19,7 @@ class CRUDDictData(CRUDPlus[DictData]):
:param pk: 字典数据 ID
:return:
"""
return await self.select_model(db, pk, load_strategies={'type': 'noload'})
return await self.select_model(db, pk)
async def get_by_type_code(self, db: AsyncSession, type_code: str) -> Sequence[DictData]:
"""
@@ -34,7 +34,6 @@ class CRUDDictData(CRUDPlus[DictData]):
sort_columns='sort',
sort_orders='desc',
type_code=type_code,
load_strategies={'type': 'noload'},
)
async def get_all(self, db: AsyncSession) -> Sequence[DictData]:
@@ -44,7 +43,7 @@ class CRUDDictData(CRUDPlus[DictData]):
:param db: 数据库会话
:return:
"""
return await self.select_models(db, load_strategies={'type': 'noload'})
return await self.select_models(db)
async def get_select(
self,
@@ -77,7 +76,7 @@ class CRUDDictData(CRUDPlus[DictData]):
if type_id is not None:
filters['type_id'] = type_id
return await self.select_order('id', 'desc', load_strategies={'type': 'noload'}, **filters)
return await self.select_order('id', 'desc', **filters)
async def get_by_label_and_type_code(self, db: AsyncSession, label: str, type_code: str) -> DictData | None:
"""
@@ -128,15 +127,15 @@ class CRUDDictData(CRUDPlus[DictData]):
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DictData | None:
async def delete_by_type_id(self, db: AsyncSession, type_ids: list[int]) -> int:
"""
获取字典数据及关联数据
通过类型 ID 删除字典数据
:param db: 数据库会话
:param pk: 字典数据 ID
:param type_ids: 字典类型 ID 列表
:return:
"""
return await self.select_model(db, pk, load_strategies=['type'])
return await self.delete_model_by_column(db, allow_multiple=True, type_id__in=type_ids)
dict_data_dao: CRUDDictData = CRUDDictData(DictData)
+5 -3
View File
@@ -4,6 +4,7 @@ from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.plugin.dict.crud.crud_dict_data import dict_data_dao
from backend.plugin.dict.model import DictType
from backend.plugin.dict.schema.dict_type import CreateDictTypeParam, UpdateDictTypeParam
@@ -28,9 +29,9 @@ class CRUDDictType(CRUDPlus[DictType]):
:param db: 数据库会话
:return:
"""
return await self.select_models(db, load_strategies={'datas': 'noload'})
return await self.select_models(db)
async def get_select(self, *, name: str | None, code: str | None) -> Select:
async def get_select(self, name: str | None, code: str | None) -> Select:
"""
获取字典类型列表查询表达式
@@ -45,7 +46,7 @@ class CRUDDictType(CRUDPlus[DictType]):
if code is not None:
filters['code__like'] = f'%{code}%'
return await self.select_order('id', 'desc', load_strategies={'datas': 'noload'}, **filters)
return await self.select_order('id', 'desc', **filters)
async def get_by_code(self, db: AsyncSession, code: str) -> DictType | None:
"""
@@ -86,6 +87,7 @@ class CRUDDictType(CRUDPlus[DictType]):
:param pks: 字典类型 ID 列表
:return:
"""
await dict_data_dao.delete_by_type_id(db, pks)
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
+10 -27
View File
@@ -1,16 +1,8 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.plugin.dict.model import DictType
from backend.common.model import Base, UniversalText, id_key
class DictData(Base):
@@ -19,22 +11,13 @@ class DictData(Base):
__tablename__ = 'sys_dict_data'
id: Mapped[id_key] = mapped_column(init=False)
type_code: Mapped[str] = mapped_column(String(32), comment='对应的字典类型编码')
label: Mapped[str] = mapped_column(String(32), comment='字典标签')
value: Mapped[str] = mapped_column(String(32), comment='字典值')
color: Mapped[str | None] = mapped_column(String(32), default=None, comment='标签颜色')
type_code: Mapped[str] = mapped_column(sa.String(32), comment='对应的字典类型编码')
label: Mapped[str] = mapped_column(sa.String(32), comment='字典标签')
value: Mapped[str] = mapped_column(sa.String(32), comment='字典值')
color: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='标签颜色')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
# 字典类型一对多
type_id: Mapped[int] = mapped_column(
ForeignKey('sys_dict_type.id', ondelete='CASCADE'),
default=0,
comment='字典类型关联ID',
)
type: Mapped[DictType] = relationship(init=False, back_populates='datas')
# 逻辑外键
type_id: Mapped[int] = mapped_column(sa.BigInteger, default=0, comment='字典类型关联ID')
+6 -21
View File
@@ -1,16 +1,8 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.plugin.dict.model import DictData
from backend.common.model import Base, UniversalText, id_key
class DictType(Base):
@@ -19,13 +11,6 @@ class DictType(Base):
__tablename__ = 'sys_dict_type'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(32), comment='字典类型名称')
code: Mapped[str] = mapped_column(String(32), unique=True, comment='字典类型编码')
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
# 字典类型一对多
datas: Mapped[list[DictData]] = relationship(init=False, back_populates='type')
name: Mapped[str] = mapped_column(sa.String(32), comment='字典类型名称')
code: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='字典类型编码')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
+1 -1
View File
@@ -1,6 +1,6 @@
[plugin]
summary = '数据字典'
version = '0.0.7'
version = '0.0.8'
description = '通常用于约束前端工程数据展示'
author = 'wu-clan'
@@ -52,6 +52,5 @@ values
(35, 'sys_plugin_type', '压缩包', '0', 'gold', 1, 1, '插件类型-压缩包', 12, now(), null),
(36, 'sys_plugin_type', 'GIT', '1', 'processing', 2, 1, '插件类型-GIT', 12, now(), null);
-- reset auto-increment values for each table based on max id
select setval(pg_get_serial_sequence('sys_dict_type', 'id'),coalesce(max(id), 0) + 1, true) from sys_dict_type;
select setval(pg_get_serial_sequence('sys_dict_data', 'id'),coalesce(max(id), 0) + 1, true) from sys_dict_data;
+5 -5
View File
@@ -1,8 +1,8 @@
from sqlalchemy import TEXT, String
from sqlalchemy.dialects.mysql import LONGTEXT
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
from backend.common.model import Base, UniversalText, id_key
class Notice(Base):
@@ -11,7 +11,7 @@ class Notice(Base):
__tablename__ = 'sys_notice'
id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(String(50), comment='标题')
title: Mapped[str] = mapped_column(sa.String(64), comment='标题')
type: Mapped[int] = mapped_column(comment='类型(0:通知、1:公告)')
status: Mapped[int] = mapped_column(comment='状态(0:隐藏、1:显示)')
content: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='内容')
content: Mapped[str] = mapped_column(UniversalText, comment='内容')
+7 -7
View File
@@ -1,14 +1,14 @@
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from fastapi_limiter.depends import RateLimiter
from fastapi_oauth20 import FastAPIOAuth20, GitHubOAuth20
from starlette.responses import RedirectResponse
from backend.common.enums import UserSocialType
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSessionTransaction
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
router = APIRouter()
@@ -17,8 +17,8 @@ github_client = GitHubOAuth20(settings.OAUTH2_GITHUB_CLIENT_ID, settings.OAUTH2_
@router.get('', summary='获取 Github 授权链接')
async def get_github_oauth2_url(request: Request) -> ResponseSchemaModel[str]:
auth_url = await github_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
async def get_github_oauth2_url() -> ResponseSchemaModel[str]:
auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)
return response_base.success(data=auth_url)
@@ -34,11 +34,11 @@ async def github_oauth2_callback( # noqa: ANN201
background_tasks: BackgroundTasks,
oauth2: Annotated[
FastAPIOAuth20,
Depends(FastAPIOAuth20(github_client, redirect_route_name='github_oauth2_callback')),
Depends(FastAPIOAuth20(github_client, redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)),
],
):
token, _state = oauth2
access_token = token['access_token']
token_data, _state = oauth2
access_token = token_data['access_token']
user = await github_client.get_userinfo(access_token)
data = await oauth2_service.create_with_login(
db=db,
+7 -7
View File
@@ -1,14 +1,14 @@
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from fastapi_limiter.depends import RateLimiter
from fastapi_oauth20 import FastAPIOAuth20, GoogleOAuth20
from starlette.responses import RedirectResponse
from backend.common.enums import UserSocialType
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSessionTransaction
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
router = APIRouter()
@@ -17,8 +17,8 @@ google_client = GoogleOAuth20(settings.OAUTH2_GOOGLE_CLIENT_ID, settings.OAUTH2_
@router.get('', summary='获取 google 授权链接')
async def get_google_oauth2_url(request: Request) -> ResponseSchemaModel[str]:
auth_url = await google_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
async def get_google_oauth2_url() -> ResponseSchemaModel[str]:
auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI)
return response_base.success(data=auth_url)
@@ -34,11 +34,11 @@ async def google_oauth2_callback( # noqa: ANN201
background_tasks: BackgroundTasks,
oauth2: Annotated[
FastAPIOAuth20,
Depends(FastAPIOAuth20(google_client, redirect_route_name='google_oauth2_callback')),
Depends(FastAPIOAuth20(google_client, redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI)),
],
):
token, _state = oauth2
access_token = token['access_token']
token_data, _state = oauth2
access_token = token_data['access_token']
user = await google_client.get_userinfo(access_token)
data = await oauth2_service.create_with_login(
db=db,
+7 -7
View File
@@ -1,14 +1,14 @@
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from fastapi_limiter.depends import RateLimiter
from fastapi_oauth20 import FastAPIOAuth20, LinuxDoOAuth20
from starlette.responses import RedirectResponse
from backend.common.enums import UserSocialType
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSessionTransaction
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
router = APIRouter()
@@ -17,8 +17,8 @@ linux_do_client = LinuxDoOAuth20(settings.OAUTH2_LINUX_DO_CLIENT_ID, settings.OA
@router.get('', summary='获取 LinuxDo 授权链接')
async def get_linux_do_oauth2_url(request: Request) -> ResponseSchemaModel[str]:
auth_url = await linux_do_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
async def get_linux_do_oauth2_url() -> ResponseSchemaModel[str]:
auth_url = await linux_do_client.get_authorization_url(redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)
return response_base.success(data=auth_url)
@@ -34,11 +34,11 @@ async def linux_do_oauth2_callback( # noqa: ANN201
background_tasks: BackgroundTasks,
oauth2: Annotated[
FastAPIOAuth20,
Depends(FastAPIOAuth20(linux_do_client, redirect_route_name='linux_do_oauth2_callback')),
Depends(FastAPIOAuth20(linux_do_client, redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)),
],
):
token, _state = oauth2
access_token = token['access_token']
token_data, _state = oauth2
access_token = token_data['access_token']
user = await linux_do_client.get_userinfo(access_token)
data = await oauth2_service.create_with_login(
db=db,
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Request
from backend.common.response.response_schema import ResponseModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.database.db import CurrentSessionTransaction
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.service.user_social import user_social_service
router = APIRouter()
@router.delete('/me', summary='解绑用户社交账号', dependencies=[DependsJwtAuth])
async def unbinding_user(db: CurrentSessionTransaction, request: Request, source: UserSocialType) -> ResponseModel:
await user_social_service.unbinding(db=db, user_id=request.user.id, source=source)
return response_base.success()
+19 -8
View File
@@ -8,23 +8,23 @@ from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
class CRUDUserSocial(CRUDPlus[UserSocial]):
"""用户社交账号数据库操作类"""
async def check_binding(self, db: AsyncSession, pk: int, source: str) -> UserSocial | None:
async def check_binding(self, db: AsyncSession, user_id: int, source: str) -> UserSocial | None:
"""
检查系统用户社交账号绑定
:param db: 数据库会话
:param pk: 用户 ID
:param user_id: 用户 ID
:param source: 社交账号类型
:return:
"""
return await self.select_model_by_column(db, user_id=pk, source=source)
return await self.select_model_by_column(db, user_id=user_id, source=source)
async def get_by_sid(self, db: AsyncSession, sid: str, source: str) -> UserSocial | None:
"""
通过 UUID 获取社交用户
通过 sid 获取社交用户
:param db: 数据库会话
:param sid: 第三方 UUID
:param sid: 第三方用户唯一编码
:param source: 社交账号类型
:return:
"""
@@ -40,15 +40,26 @@ class CRUDUserSocial(CRUDPlus[UserSocial]):
"""
await self.create_model(db, obj)
async def delete(self, db: AsyncSession, social_id: int) -> int:
async def delete(self, db: AsyncSession, user_id: int, source: str) -> int:
"""
删除用户社交账号绑定
:param db: 数据库会话
:param social_id: 社交账号绑定 ID
:param user_id: 用户 ID
:param source: 社交账号类型
:return:
"""
return await self.delete_model(db, social_id)
return await self.delete_model_by_column(db, user_id=user_id, source=source)
async def delete_by_user_id(self, db: AsyncSession, user_id: int) -> int:
"""
通过用户 ID 删除用户社交
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.delete_model_by_column(db, user_id=user_id)
user_social_dao: CRUDUserSocial = CRUDUserSocial(UserSocial)
+9
View File
@@ -0,0 +1,9 @@
from backend.common.enums import StrEnum
class UserSocialType(StrEnum):
"""用户社交类型"""
github = 'GitHub'
google = 'Google'
linux_do = 'LinuxDo'
+6 -17
View File
@@ -1,15 +1,9 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class UserSocial(Base):
"""用户社交表(OAuth2"""
@@ -17,13 +11,8 @@ class UserSocial(Base):
__tablename__ = 'sys_user_social'
id: Mapped[id_key] = mapped_column(init=False)
sid: Mapped[str] = mapped_column(String(255), comment='第三方用户 ID')
source: Mapped[str] = mapped_column(String(20), comment='第三方用户来源')
sid: Mapped[str] = mapped_column(sa.String(256), comment='第三方用户 ID')
source: Mapped[str] = mapped_column(sa.String(32), comment='第三方用户来源')
# 用户社交信息一对多
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey('sys_user.id', ondelete='CASCADE'),
comment='用户关联ID',
)
user: Mapped[User | None] = relationship(init=False, backref='socials')
# 逻辑外键
user_id: Mapped[int] = mapped_column(sa.BigInteger, comment='用户关联ID')
+1 -1
View File
@@ -1,6 +1,6 @@
[plugin]
summary = 'OAuth 2.0'
version = '0.0.5'
version = '0.0.9'
description = '通过 OAuth 2.0 的方式登录系统'
author = 'wu-clan'
+1 -1
View File
@@ -1 +1 @@
fastapi-oauth20>=0.0.1
fastapi-oauth20>=0.0.2
+1 -1
View File
@@ -1,7 +1,7 @@
from pydantic import Field
from backend.common.enums import UserSocialType
from backend.common.schema import SchemaBase
from backend.plugin.oauth2.enums import UserSocialType
class UserSocialSchemaBase(SchemaBase):
@@ -9,12 +9,13 @@ from backend.app.admin.schema.token import GetLoginToken
from backend.app.admin.schema.user import AddOAuth2UserParam
from backend.app.admin.service.login_log_service import login_log_service
from backend.common.context import ctx
from backend.common.enums import LoginLogStatusType, UserSocialType
from backend.common.enums import LoginLogStatusType
from backend.common.i18n import t
from backend.common.security import jwt
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
from backend.utils.timezone import timezone
@@ -0,0 +1,25 @@
from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.exception import errors
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.plugin.oauth2.enums import UserSocialType
class UserSocialService:
@staticmethod
async def unbinding(*, db: AsyncSession, user_id: int, source: UserSocialType) -> int:
"""
解绑用户社交账号
:param db: 数据库会话
:param user_id: 用户 ID
:param source: 解绑源
:return:
"""
bind = user_social_dao.check_binding(db, user_id, source.value)
if not bind:
raise errors.NotFoundError(msg=f'用户未绑定 {source.value} 账号')
return await user_social_dao.delete(db, user_id, source.value)
user_social_service: UserSocialService = UserSocialService()
+79 -14
View File
@@ -142,14 +142,7 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
raise PluginConfigError(f'插件 {plugin} 配置文件缺少必要字段: {", ".join(missing_fields)}')
if data.get('api'):
# TODO: 删除过时的 include 配置
include = data.get('app', {}).get('include')
if include:
warnings.warn(
f'插件 {plugin} 配置 app.include 即将在未来版本中弃用,请尽快更新配置为 app.extend, 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/plugin/dev.html#%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE',
FutureWarning,
)
if not include and not data.get('app', {}).get('extend'):
if not data.get('app', {}).get('extend'):
raise PluginConfigError(f'扩展级插件 {plugin} 配置文件缺少 app.extend 配置')
extend_plugins.append(data)
else:
@@ -219,8 +212,7 @@ def inject_extend_router(plugin: dict[str, Any]) -> None:
# 获取目标 app 路由
relative_path = os.path.relpath(root, plugin_api_path)
# TODO: 删除过时的 include 配置
app_name = plugin.get('app', {}).get('include') or plugin.get('app', {}).get('extend')
app_name = plugin.get('app', {}).get('extend')
target_module_path = f'backend.app.{app_name}.api.{relative_path.replace(os.sep, ".")}'
target_module = import_module_cached(target_module_path)
target_router = getattr(target_module, 'router', None)
@@ -286,7 +278,62 @@ def build_final_router() -> APIRouter:
return main_router
def install_requirements(plugin: str | None) -> None:
def _ensure_pip_available() -> bool:
"""确保 pip 在虚拟环境中可用"""
try:
result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True)
if result.returncode == 0:
return True
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
pass
# 尝试使用 ensurepip
try:
subprocess.check_call(
[sys.executable, '-m', 'ensurepip', '--default-pip'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True)
if result.returncode == 0:
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
pass
# 尝试下载并安装
try:
import os
import tempfile
import httpx
try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
with httpx.Client(timeout=3) as client:
get_pip_url = 'https://bootstrap.pypa.io/get-pip.py'
response = client.get(get_pip_url)
response.raise_for_status()
f.write(response.text)
temp_file = f.name
except Exception: # noqa: ignore
return False
try:
subprocess.check_call([sys.executable, temp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True)
return result.returncode == 0
finally:
try:
os.unlink(temp_file)
except OSError:
pass
except Exception: # noqa: ignore
pass
return False
def install_requirements(plugin: str | None) -> None: # noqa: C901
"""
安装插件依赖
@@ -316,12 +363,30 @@ def install_requirements(plugin: str | None) -> None:
if missing_dependencies:
try:
ensurepip_install = [sys.executable, '-m', 'ensurepip', '--upgrade']
if not _ensure_pip_available():
raise PluginInstallError(f'pip 安装失败,无法继续安装插件 {plugin} 依赖')
pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file]
if settings.PLUGIN_PIP_CHINA:
pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
subprocess.check_call(ensurepip_install, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.check_call(pip_install, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
max_retries = settings.PLUGIN_PIP_MAX_RETRY
for attempt in range(max_retries):
try:
subprocess.check_call(
pip_install,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
break
except subprocess.TimeoutExpired:
if attempt == max_retries - 1:
raise PluginInstallError(f'插件 {plugin} 依赖安装超时')
continue
except subprocess.CalledProcessError as e:
if attempt == max_retries - 1:
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
continue
except subprocess.CalledProcessError as e:
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
pre-commit run --all-files
prek run --all-files
@@ -1,5 +1,5 @@
insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time)
values (2048601258595581952, '测试', 0, null, null, null, 1, 0, null, now(), null);
values (2048601258595581952, '测试', 0, null, null, null, 1, false, null, now(), null);
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values
@@ -82,7 +82,7 @@ values
(2049629108257816588, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 2049629108257816580, '2025-06-26 20:29:06', null);
insert into sys_role (id, name, status, is_filter_scopes, remark, created_time, updated_time)
values (2048601263515500544, '测试', 1, 1, null, now(), null);
values (2048601263515500544, '测试', 1, true, null, now(), null);
insert into sys_role_menu (id, role_id, menu_id)
values
@@ -91,10 +91,10 @@ values
(2048601263708438528, 2048601263515500544, 2049629108245233666),
(2048601263775547392, 2048601263515500544, 2049629108253622282);
insert into sys_user (id, uuid, username, nickname, password, salt, email, is_superuser, is_staff, status, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
values
(2048601263834267648, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, 1, 1, 1, null, null, now(), now(), 2048601258595581952, now(), null),
(2049946297615646720, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 0, 0, 1, 0, null, null, now(), now(), 2048601258595581952, now(), null);
(2048601263834267648, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 2048601258595581952, now(), null),
(2049946297615646720, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 2048601258595581952, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
+10 -5
View File
@@ -1,5 +1,5 @@
insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time)
values (1, '测试', 0, null, null, null, 1, 0, null, now(), null);
values (1, '测试', 0, null, null, null, 1, false, null, now(), null);
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values
@@ -82,7 +82,7 @@ values
(77, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 69, '2025-06-26 20:29:06', null);
insert into sys_role (id, name, status, is_filter_scopes, remark, created_time, updated_time)
values (1, '测试', 1, 1, null, now(), null);
values (1, '测试', 1, true, null, now(), null);
insert into sys_role_menu (id, role_id, menu_id)
values
@@ -91,10 +91,10 @@ values
(3, 1, 3),
(4, 1, 53);
insert into sys_user (id, uuid, username, nickname, password, salt, email, is_superuser, is_staff, status, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
values
(1, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, 1, 1, 1, null, null, now(), now(), 1, now(), null),
(2, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 0, 0, 1, 0, null, null, now(), now(), 1, now(), null);
(1, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 1, now(), null),
(2, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 1, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
@@ -111,6 +111,11 @@ values
(1, '部门名称等于测试', '部门', 'name', 1, 0, '测试', now(), null),
(2, '父部门 ID 等于 1', '部门', 'parent_id', 0, 0, '1', now(), null);
insert into sys_role_data_scope (id, role_id, data_scope_id)
values
(1, 1, 1),
(2, 1, 2);
insert into sys_data_scope_rule (id, data_scope_id, data_rule_id)
values
(1, 1, 1),
@@ -1,5 +1,5 @@
insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time)
values (2048601264366944256, '测试', 0, null, null, null, 1, 0, null, now(), null);
values (2048601264366944256, '测试', 0, null, null, null, 1, false, null, now(), null);
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values
@@ -82,7 +82,7 @@ values
(2049629108257816588, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 2049629108257816580, '2025-06-26 20:29:06', null);
insert into sys_role (id, name, status, is_filter_scopes, remark, created_time, updated_time)
values (2048601269345583104, '测试', 1, 1, null, now(), null);
values (2048601269345583104, '测试', 1, true, null, now(), null);
insert into sys_role_menu (id, role_id, menu_id)
values
@@ -91,10 +91,10 @@ values
(2048601269546909696, 2048601269345583104, 2049629108245233666),
(2048601269609824256, 2048601269345583104, 2049629108253622282);
insert into sys_user (id, uuid, username, nickname, password, salt, email, is_superuser, is_staff, status, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
values
(2048601269672738816, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, 1, 1, 1, null, null, now(), now(), 2048601264366944256, now(), null),
(2049946297615646720, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 0, 0, 1, 0, null, null, now(), now(), 2048601264366944256, now(), null);
(2048601269672738816, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 2048601264366944256, now(), null),
(2049946297615646720, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 2048601264366944256, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
+11 -6
View File
@@ -1,5 +1,5 @@
insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time)
values (1, '测试', 0, null, null, null, 1, 0, null, now(), null);
values (1, '测试', 0, null, null, null, 1, false, null, now(), null);
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values
@@ -82,7 +82,7 @@ values
(77, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 69, '2025-06-26 20:29:06', null);
insert into sys_role (id, name, status, is_filter_scopes, remark, created_time, updated_time)
values (1, '测试', 1, 1, null, now(), null);
values (1, '测试', 1, true, null, now(), null);
insert into sys_role_menu (id, role_id, menu_id)
values
@@ -91,10 +91,10 @@ values
(3, 1, 3),
(4, 1, 53);
insert into sys_user (id, uuid, username, nickname, password, salt, email, is_superuser, is_staff, status, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
values
(1, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, 1, 1, 1, null, null, now(), now(), 1, now(), null),
(2, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 0, 0, 1, 0, null, null, now(), now(), 1, now(), null);
(1, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 1, now(), null),
(2, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 1, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
@@ -111,13 +111,17 @@ values
(1, '部门名称等于测试', '部门', 'name', 1, 0, '测试', now(), null),
(2, '父部门 ID 等于 1', '部门', 'parent_id', 0, 0, '1', now(), null);
insert into sys_role_data_scope (id, role_id, data_scope_id)
values
(1, 1, 1),
(2, 1, 2);
insert into sys_data_scope_rule (id, data_scope_id, data_rule_id)
values
(1, 1, 1),
(2, 2, 1),
(3, 2, 2);
-- reset auto-increment values for each table based on max id
select setval(pg_get_serial_sequence('sys_dept', 'id'),coalesce(max(id), 0) + 1, true) from sys_dept;
select setval(pg_get_serial_sequence('sys_menu', 'id'),coalesce(max(id), 0) + 1, true) from sys_menu;
select setval(pg_get_serial_sequence('sys_role', 'id'),coalesce(max(id), 0) + 1, true) from sys_role;
@@ -126,4 +130,5 @@ select setval(pg_get_serial_sequence('sys_user', 'id'),coalesce(max(id), 0) + 1,
select setval(pg_get_serial_sequence('sys_user_role', 'id'),coalesce(max(id), 0) + 1, true) from sys_user_role;
select setval(pg_get_serial_sequence('sys_data_scope', 'id'),coalesce(max(id), 0) + 1, true) from sys_data_scope;
select setval(pg_get_serial_sequence('sys_data_rule', 'id'),coalesce(max(id), 0) + 1, true) from sys_data_rule;
select setval(pg_get_serial_sequence('sys_role_data_scope', 'id'),coalesce(max(id), 0) + 1, true) from sys_role_data_scope;
select setval(pg_get_serial_sequence('sys_data_scope_rule', 'id'),coalesce(max(id), 0) + 1, true) from sys_data_scope_rule;
+310 -5
View File
@@ -1,3 +1,4 @@
from collections import defaultdict, namedtuple
from collections.abc import Sequence
from decimal import Decimal
from typing import Any, TypeVar
@@ -8,11 +9,20 @@ from sqlalchemy import Row, RowMapping
from sqlalchemy.orm import ColumnProperty, SynonymProperty, class_mapper
from starlette.responses import JSONResponse
RowData = Row | RowMapping | Any
RowData = Row[Any] | RowMapping | Any
R = TypeVar('R', bound=RowData)
class MsgSpecJSONResponse(JSONResponse):
"""
使用高性能的 msgspec 库将数据序列化为 JSON 的响应类
"""
def render(self, content: Any) -> bytes:
return json.encode(content)
def select_columns_serialize(row: R) -> dict[str, Any]:
"""
序列化 SQLAlchemy 查询表的列不包含关联列
@@ -62,10 +72,305 @@ def select_as_dict(row: R, *, use_alias: bool = False) -> dict[str, Any]:
return result
class MsgSpecJSONResponse(JSONResponse):
def select_join_serialize( # noqa: C901
row: R | Sequence[R],
relationships: list[str] | None = None,
*,
return_as_dict: bool = False,
) -> dict[str, Any] | list[dict[str, Any]] | tuple[Any, ...] | list[tuple[Any, ...]] | None:
"""
使用高性能的 msgspec 库将数据序列化为 JSON 的响应类
SQLAlchemy 连接查询结果序列化为字典或支持属性访问的 namedtuple
扁平序列化``relationships=None``
| 将所有查询结果平铺到同一层级不进行嵌套处理
输出Result(name='Alice', dept=Dept(...))
嵌套序列化``relationships=['User-m2o-Dept', 'User-m2m-Role:permissions', 'Role-m2m-Menu']``
| 根据指定的关系类型将数据嵌套组织支持层级结构
| row = select(User, Dept, Role).join(...).all()
输出Result(name='Alice', dept=Dept(...), permissions=[Role(..., menus=[Menu(...)])])
:param row: SQLAlchemy 查询结果
:param relationships: 表之间的虚拟关系
source_model_class-type-target_model_class[:custom_name], type: o2m/m2o/o2o/m2m
- o2m (一对多): 目标模型类名会自动添加's'变为复数形式 (: dept->depts)
- m2o (多对一): 目标模型类名保持单数形式 (: user->user)
- o2o (一对一): 目标模型类名保持单数形式 (: profile->profile)
- m2m (多对多): 目标模型类名会自动添加's'变为复数形式 (: role->roles)
- 自定义名称: 可以通过在关系字符串末尾添加 ':custom_name' 来指定自定义的目标字段名
例如: 'User-m2m-Role:permissions' 会将角色数据放在 'permissions' 字段而不是默认的 'roles'
:param return_as_dict: False 返回 namedtupleTrue 返回 dict
:return:
"""
def render(self, content: Any) -> bytes:
return json.encode(content)
def get_relation_key(model_name: str, rel_type: str, custom_field: str | None = None) -> str:
"""获取关系键名"""
return custom_field or (model_name if rel_type in ('o2o', 'm2o') else f'{model_name}s')
def parse_relationships(relationship_list: list[str]) -> tuple[dict, dict, dict]:
"""解析关系定义"""
if not relationship_list:
return {}, {}, {}
parsed_relation_graph = defaultdict(dict)
parsed_reverse_relation = {}
parsed_custom_names = {}
for rel_str in relationship_list:
parts = rel_str.split(':', 1)
rel_part = parts[0].strip()
field_custom_name = parts[1].strip() if len(parts) > 1 else None
rel_info = rel_part.split('-')
if len(rel_info) != 3:
continue
source_model, rel_type, target_model = (info.lower() for info in rel_info)
if rel_type not in ('o2m', 'm2o', 'o2o', 'm2m'):
continue
parsed_relation_graph[source_model][target_model] = rel_type
parsed_reverse_relation[target_model] = source_model
if field_custom_name:
parsed_custom_names[source_model, target_model] = field_custom_name
return parsed_relation_graph, parsed_reverse_relation, parsed_custom_names
def get_model_columns(model_obj: Any) -> list[str]:
"""获取模型列名"""
mapper = class_mapper(type(model_obj))
return [
prop.key
for prop in mapper.iterate_properties
if isinstance(prop, (ColumnProperty, SynonymProperty)) and hasattr(model_obj, prop.key)
]
def get_unique_objects(objs: list[Any], key_attr: str = 'id') -> list[Any]:
"""根据键属性去重对象列表"""
seen = set()
unique = []
for item in objs:
item_id = getattr(item, key_attr, None)
if item_id is not None and item_id not in seen:
seen.add(item_id)
unique.append(item)
return unique
if not row:
return None
rows_list = [row] if not isinstance(row, list) else row
if not rows_list:
return None
# 获取主对象信息
first_row = rows_list[0]
main_obj = first_row[0] if hasattr(first_row, '__getitem__') and first_row else first_row
if main_obj is None:
return None
main_obj_name = type(main_obj).__name__.lower()
main_columns = get_model_columns(main_obj)
# 解析关系
relation_graph, reverse_relation, custom_names = parse_relationships(relationships or [])
has_relationships = bool(relation_graph)
# 预处理所有模型类型和列信息
model_info = {}
cls_idxs = {}
for preprocess_row in rows_list:
preprocess_row_items = preprocess_row if hasattr(preprocess_row, '__getitem__') else (preprocess_row,)
for idx, row_obj in enumerate(preprocess_row_items):
if row_obj is None:
continue
obj_class_name = type(row_obj).__name__.lower()
if obj_class_name not in model_info:
model_info[obj_class_name] = get_model_columns(row_obj)
if obj_class_name not in cls_idxs:
cls_idxs[obj_class_name] = idx
# 数据收集和分组
main_data = {}
grouped_data = defaultdict(lambda: defaultdict(list))
for data_row in rows_list:
data_row_items = data_row if hasattr(data_row, '__getitem__') else (data_row,)
if not data_row_items or data_row_items[0] is None:
continue
main_obj = data_row_items[0]
main_id = getattr(main_obj, 'id', None) or id(main_obj)
if main_id not in main_data:
main_data[main_id] = main_obj
# 收集子对象
for child_obj in data_row_items[1:]:
if child_obj is None:
continue
child_class_name = type(child_obj).__name__.lower()
grouped_data[main_id][child_class_name].append(child_obj)
if not main_data:
return None
# 预生成 namedtuple 类型
namedtuple_cache = {}
if not return_as_dict:
for cls_name, columns in model_info.items():
if columns:
# 为嵌套关系预计算完整字段列表
full_columns = columns.copy()
if has_relationships:
for target_class, relation_type in relation_graph.get(cls_name, {}).items():
field_name = custom_names.get((cls_name, target_class))
rel_key = get_relation_key(target_class, relation_type, field_name)
full_columns.append(rel_key)
full_columns = sorted(set(full_columns)) # 去重并排序
namedtuple_cache[cls_name] = namedtuple(cls_name.capitalize(), full_columns or columns) # noqa: PYI024
def build_flat_result(build_main_id: int, build_main_obj: Any) -> dict[str, Any]: # noqa: C901
"""构建扁平化结果"""
flat_result = {col: getattr(build_main_obj, col, None) for col in main_columns}
for class_name in sorted(grouped_data[build_main_id]):
if class_name == main_obj_name:
continue
flat_objs = get_unique_objects(grouped_data[build_main_id][class_name])
cls_columns = model_info.get(class_name, [])
if not flat_objs:
flat_result[class_name] = []
elif len(flat_objs) == 1:
obj_data = {col: getattr(flat_objs[0], col, None) for col in cls_columns}
# 确保 namedtuple 所需的所有字段都存在
if not return_as_dict and class_name in namedtuple_cache:
nt_fields = getattr(namedtuple_cache[class_name], '_fields', [])
for field in nt_fields:
if field not in obj_data:
obj_data[field] = None
flat_result[class_name] = obj_data if return_as_dict else namedtuple_cache[class_name](**obj_data)
else:
if return_as_dict:
flat_result[class_name] = [
{col: getattr(flat_obj, col, None) for col in cls_columns} for flat_obj in flat_objs
]
else:
nested_result_list = []
for nested_obj in flat_objs:
obj_data = {col: getattr(nested_obj, col, None) for col in cls_columns}
# 确保 namedtuple 所需的所有字段都存在
if class_name in namedtuple_cache:
nt_fields = getattr(namedtuple_cache[class_name], '_fields', [])
for field in nt_fields:
if field not in obj_data:
obj_data[field] = None
nested_result_list.append(namedtuple_cache[class_name](**obj_data))
flat_result[class_name] = nested_result_list
return flat_result
def build_nested_result(nested_main_id: int, nested_main_obj: Any) -> dict[str, Any]: # noqa: C901
"""构建嵌套化结果"""
nested_result = {col: getattr(nested_main_obj, col, None) for col in main_columns}
# 构建关系层级数据结构
hierarchy = defaultdict(lambda: defaultdict(list))
for iter_row in rows_list:
iter_row_items = iter_row if hasattr(iter_row, '__getitem__') else (iter_row,)
if not iter_row_items or iter_row_items[0] is None:
continue
iter_main_id = getattr(iter_row_items[0], 'id', None) or id(iter_row_items[0])
if iter_main_id != nested_main_id:
continue
for _i, related_obj in enumerate(iter_row_items[1:], 1):
if related_obj is None:
continue
related_class_name = type(related_obj).__name__.lower()
if related_class_name in reverse_relation:
parent_cls = reverse_relation[related_class_name]
parent_idx = cls_idxs.get(parent_cls, 0)
if parent_idx < len(iter_row_items):
parent_obj = iter_row_items[parent_idx]
if parent_obj is not None:
parent_obj_id = getattr(parent_obj, 'id', None)
if parent_obj_id is not None:
hierarchy[related_class_name][parent_obj_id].append(related_obj)
def build_recursive(current_cls_name: str, current_parent_id: int) -> list:
"""递归构建嵌套数据"""
recursive_objs = get_unique_objects(hierarchy[current_cls_name].get(current_parent_id, []))
if not recursive_objs:
return []
recursive_result = []
for nested_obj in recursive_objs:
# 基础数据
obj_data = {col: getattr(nested_obj, col, None) for col in model_info[current_cls_name]}
# 处理子关系
for child_cls, child_rel_type in relation_graph.get(current_cls_name, {}).items():
child_parent_id = getattr(nested_obj, 'id', None)
if child_parent_id is None:
continue
child_list = build_recursive(child_cls, child_parent_id)
child_key = get_relation_key(
child_cls, child_rel_type, custom_names.get((current_cls_name, child_cls))
)
if child_rel_type in ('m2o', 'o2o'):
obj_data[child_key] = child_list[0] if child_list else None
else:
obj_data[child_key] = child_list
if not return_as_dict and current_cls_name in namedtuple_cache:
nt_fields = getattr(namedtuple_cache[current_cls_name], '_fields', [])
for field in nt_fields:
if field not in obj_data:
obj_data[field] = None
recursive_result.append(obj_data if return_as_dict else namedtuple_cache[current_cls_name](**obj_data))
return recursive_result
# 构建顶级关系
for top_cls_name, top_rel_type in relation_graph.get(main_obj_name, {}).items():
instances = build_recursive(top_cls_name, nested_main_id)
key = get_relation_key(top_cls_name, top_rel_type, custom_names.get((main_obj_name, top_cls_name)))
if top_rel_type in ('m2o', 'o2o'):
nested_result[key] = instances[0] if instances else None
else:
nested_result[key] = instances
return nested_result
# 构建最终结果
final_result_list = []
for current_main_id in sorted(main_data.keys()):
current_main_obj = main_data[current_main_id]
if has_relationships:
final_result_data = build_nested_result(current_main_id, current_main_obj)
else:
final_result_data = build_flat_result(current_main_id, current_main_obj)
if not return_as_dict:
all_fields = list(final_result_data.keys())
result_type = namedtuple('Result', all_fields) # noqa: PYI024
final_result_list.append(result_type(**final_result_data))
else:
final_result_list.append(final_result_data)
return final_result_list[0] if len(final_result_list) == 1 else final_result_list
+4 -4
View File
@@ -1,10 +1,10 @@
# Env
ENVIRONMENT='prod'
# Database
DATABASE_TYPE='mysql'
DATABASE_HOST='fba_mysql'
DATABASE_PORT=3306
DATABASE_USER='root'
DATABASE_TYPE='postgresql'
DATABASE_HOST='fba_postgres'
DATABASE_PORT=5432
DATABASE_USER='postgres'
DATABASE_PASSWORD='123456'
# Redis
REDIS_HOST='fba_redis'
+37 -35
View File
@@ -8,9 +8,9 @@ networks:
- subnet: 172.10.10.0/24
volumes:
# 如果你是 postgres 用户,应将 fba_mysql 修改为 fba_postgres
fba_mysql:
name: fba_mysql
# 如果你是 mysql 用户,应将 fba_postgres 修改为 fba_mysql
fba_postgres:
name: fba_postgres
fba_redis:
name: fba_redis
fba_static:
@@ -26,11 +26,13 @@ services:
context: .
dockerfile: Dockerfile
image: fba_server:latest
ports:
- "8001:8001"
container_name: fba_server
restart: always
# 如果你是 postgres 用户,应将 fba_mysql 修改为 fba_postgres
# 如果你是 mysql 用户,应将 fba_postgres 修改为 fba_mysql
depends_on:
- fba_mysql
- fba_postgres
- fba_redis
- fba_celery
volumes:
@@ -39,50 +41,50 @@ services:
- fba_static_upload:/fba/backend/static/upload
networks:
- fba_network
# 如果你是 postgres 用户,应将 fba_mysql:3306 修改为 fba_postgres:5432
# 如果你是 mysql 用户,应将 fba_postgres:5432 修改为 fba_mysql:3306
command:
- bash
- -c
- |
wait-for-it -s fba_mysql:3306 -s fba_redis:6379 -t 300
wait-for-it -s fba_postgres:5432 -s fba_redis:6379 -t 300
supervisord -c /etc/supervisor/supervisord.conf
supervisorctl restart
fba_mysql:
image: mysql:8.0.41
fba_postgres:
image: postgres:16
ports:
- "${DOCKER_MYSQL_MAP_PORT:-3306}:3306"
container_name: fba_mysql
- "${DOCKER_POSTGRES_MAP_PORT:-5432}:5432"
container_name: fba_postgres
restart: always
environment:
MYSQL_DATABASE: fba
MYSQL_ROOT_PASSWORD: 123456
POSTGRES_DB: fba
POSTGRES_PASSWORD: 123456
TZ: Asia/Shanghai
volumes:
- fba_mysql:/var/lib/mysql
- fba_postgres:/var/lib/postgresql/data
networks:
- fba_network
command:
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_general_ci
--lower_case_table_names=1
# # 如果你是 postgres 用户,应保留 fba_postgres 容器脚本并删除 fba_mysql 容器脚本
# fba_postgres:
# image: postgres:16
# ports:
# - "${DOCKER_POSTGRES_MAP_PORT:-5432}:5432"
# container_name: fba_postgres
# restart: always
# environment:
# POSTGRES_DB: fba
# POSTGRES_PASSWORD: 123456
# TZ: Asia/Shanghai
# volumes:
# - fba_postgres:/var/lib/postgresql/data
# networks:
# - fba_network
# # 如果你是 mysql 用户,应保留 fba_mysql 容器脚本并删除 fba_postgres 容器脚本
# fba_mysql:
# image: mysql:8.0.41
# ports:
# - "${DOCKER_MYSQL_MAP_PORT:-3306}:3306"
# container_name: fba_mysql
# restart: always
# environment:
# MYSQL_DATABASE: fba
# MYSQL_ROOT_PASSWORD: 123456
# TZ: Asia/Shanghai
# volumes:
# - fba_mysql:/var/lib/mysql
# networks:
# - fba_network
# command:
# --default-authentication-plugin=mysql_native_password
# --character-set-server=utf8mb4
# --collation-server=utf8mb4_general_ci
# --lower_case_table_names=1
fba_redis:
image: redis
@@ -170,7 +172,7 @@ services:
context: .
dockerfile: Dockerfile
args:
- SERVER_TYPE=celery
- SERVER_TYPE=fba_celery
image: fba_celery:latest
ports:
- "8555:8555"
+17 -16
View File
@@ -12,41 +12,42 @@ license = { text = "MIT" }
requires-python = ">=3.10"
dynamic = ['version']
dependencies = [
"alembic>=1.16.5",
"alembic>=1.17.1",
"asgiref>=3.10.0",
"asyncmy>=0.2.10",
"asyncpg>=0.30.0",
"bcrypt>=5.0.0",
"cappa>=0.30.2",
"cappa>=0.30.4",
"celery>=5.5.3",
# When celery version < 6.0.0
# https://github.com/celery/celery/issues/7874
"celery-aio-pool>=0.1.0rc8",
"cryptography>=46.0.2",
"dulwich>=0.24.2",
"cryptography>=46.0.3",
"dulwich>=0.24.7",
"fast-captcha>=0.3.2",
"fastapi-limiter>=0.1.6",
"fastapi-pagination>=0.14.3",
"fastapi[standard-no-fastapi-cloud-cli]>=0.119.0",
"fastapi-pagination>=0.15.0",
"fastapi[standard-no-fastapi-cloud-cli]>=0.121.1",
"flower>=2.0.1",
"gevent>=25.9.1",
"granian>=2.5.5",
"granian>=2.5.7",
"ip2loc>=1.0.0",
"itsdangerous>=2.2.0",
"jinja2>=3.1.6",
"loguru>=0.7.3",
"msgspec>=0.19.0",
"psutil>=7.1.0",
"psycopg[binary]>=3.2.10",
"psutil>=7.1.2",
# https://github.com/fastapi-practices/fastapi_best_architecture/issues/887
"psycopg[binary]==3.2.10",
"pwdlib>=0.2.1",
"pydantic>=2.12.1",
"pydantic>=2.12.3",
"pydantic-settings>=2.11.0",
"pymysql>=1.1.2",
"python-jose>=3.5.0",
"python-socketio>=5.14.1",
"redis[hiredis]>=6.4.0",
"rtoml>=0.12.0",
"sqlalchemy-crud-plus>=1.12.1",
"python-socketio>=5.14.3",
"redis[hiredis]>=7.0.1",
"rtoml>=0.13.0",
"sqlalchemy-crud-plus>=1.13.1",
"sqlalchemy[asyncio]>=2.0.44",
"sqlparse>=0.5.3",
"starlette-context>=0.4.0",
@@ -55,11 +56,11 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.4.0",
"pytest>=9.0.0",
"pytest-sugar>=1.1.1",
]
lint = [
"pre-commit>=4.3.0",
"prek>=0.2.13",
]
server = [
"aio-pika>=9.5.7",
+34 -51
View File
@@ -1,10 +1,12 @@
# This file was autogenerated by uv via the following command:
# uv export -o requirements.txt --no-hashes
-e .
alembic==1.17.0
alembic==1.17.1
# via fastapi-best-architecture
amqp==5.3.1
# via kombu
annotated-doc==0.0.3
# via fastapi
annotated-types==0.7.0
# via pydantic
anyio==4.11.0
@@ -28,7 +30,7 @@ bidict==0.23.1
# via python-socketio
billiard==4.2.2
# via celery
cappa==0.30.2
cappa==0.30.4
# via fastapi-best-architecture
celery==5.5.3
# via
@@ -45,8 +47,6 @@ cffi==2.0.0 ; platform_python_implementation != 'PyPy'
# via
# cryptography
# gevent
cfgv==3.4.0
# via pre-commit
click==8.3.0
# via
# celery
@@ -69,13 +69,11 @@ colorama==0.4.6 ; sys_platform == 'win32'
# loguru
# pytest
# uvicorn
cryptography==46.0.2
cryptography==46.0.3
# via fastapi-best-architecture
distlib==0.4.0
# via virtualenv
dnspython==2.8.0
# via email-validator
dulwich==0.24.3
dulwich==0.24.8
# via fastapi-best-architecture
ecdsa==0.19.1
# via python-jose
@@ -87,24 +85,22 @@ exceptiongroup==1.3.0 ; python_full_version < '3.11'
# pytest
fast-captcha==0.3.2
# via fastapi-best-architecture
fastapi==0.119.0
fastapi==0.121.1
# via
# fastapi-best-architecture
# fastapi-limiter
# fastapi-pagination
fastapi-cli==0.0.13
fastapi-cli==0.0.14
# via fastapi
fastapi-limiter==0.1.6
# via fastapi-best-architecture
fastapi-pagination==0.14.3
fastapi-pagination==0.15.0
# via fastapi-best-architecture
filelock==3.20.0
# via virtualenv
flower==2.0.1
# via fastapi-best-architecture
gevent==25.9.1
# via fastapi-best-architecture
granian==2.5.5
granian==2.5.7
# via fastapi-best-architecture
greenlet==3.2.4
# via
@@ -115,7 +111,7 @@ h11==0.16.0
# httpcore
# uvicorn
# wsproto
hiredis==3.2.1
hiredis==3.3.0
# via redis
httpcore==1.0.9
# via httpx
@@ -123,16 +119,14 @@ httptools==0.7.1
# via uvicorn
httpx==0.28.1
# via fastapi
humanize==4.13.0
humanize==4.14.0
# via flower
identify==2.6.15
# via pre-commit
idna==3.11
# via
# anyio
# email-validator
# httpx
iniconfig==2.1.0
iniconfig==2.3.0
# via pytest
ip2loc==1.0.0
# via fastapi-best-architecture
@@ -158,30 +152,26 @@ mdurl==0.1.2
# via markdown-it-py
msgspec==0.19.0
# via fastapi-best-architecture
nodeenv==1.9.1
# via pre-commit
packaging==25.0
# via
# kombu
# pytest
pillow==11.3.0
pillow==12.0.0
# via fast-captcha
platformdirs==4.5.0
# via virtualenv
pluggy==1.6.0
# via pytest
pre-commit==4.3.0
prek==0.2.13
prometheus-client==0.23.1
# via flower
prompt-toolkit==3.0.52
# via click-repl
psutil==7.1.0
psutil==7.1.3
# via fastapi-best-architecture
psycopg==3.2.10
# via fastapi-best-architecture
psycopg-binary==3.2.10 ; implementation_name != 'pypy'
# via psycopg
pwdlib==0.2.1
pwdlib==0.3.0
# via fastapi-best-architecture
pyasn1==0.6.1
# via
@@ -189,14 +179,14 @@ pyasn1==0.6.1
# rsa
pycparser==2.23 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
# via cffi
pydantic==2.12.1
pydantic==2.12.4
# via
# fastapi
# fastapi-best-architecture
# fastapi-pagination
# pydantic-settings
# sqlalchemy-crud-plus
pydantic-core==2.41.3
pydantic-core==2.41.5
# via pydantic
pydantic-settings==2.11.0
# via fastapi-best-architecture
@@ -206,12 +196,12 @@ pygments==2.19.2
# rich
pymysql==1.1.2
# via fastapi-best-architecture
pytest==8.4.2
pytest==9.0.0
# via pytest-sugar
pytest-sugar==1.1.1
python-dateutil==2.9.0.post0
# via celery
python-dotenv==1.1.1
python-dotenv==1.2.1
# via
# pydantic-settings
# uvicorn
@@ -221,15 +211,13 @@ python-jose==3.5.0
# via fastapi-best-architecture
python-multipart==0.0.20
# via fastapi
python-socketio==5.14.1
python-socketio==5.14.3
# via fastapi-best-architecture
pytz==2025.2
# via flower
pyyaml==6.0.3
# via
# pre-commit
# uvicorn
redis==6.4.0
# via uvicorn
redis==7.0.1
# via
# fastapi-best-architecture
# fastapi-limiter
@@ -242,10 +230,8 @@ rich-toolkit==0.15.1
# via fastapi-cli
rsa==4.9.1
# via python-jose
rtoml==0.12.0
rtoml==0.13.0
# via fastapi-best-architecture
setuptools==80.9.0
# via zope-event
shellingham==1.5.4
# via typer
simple-websocket==1.1.0
@@ -261,17 +247,17 @@ sqlalchemy==2.0.44
# alembic
# fastapi-best-architecture
# sqlalchemy-crud-plus
sqlalchemy-crud-plus==1.12.1
sqlalchemy-crud-plus==1.13.1
# via fastapi-best-architecture
sqlparse==0.5.3
# via fastapi-best-architecture
starlette==0.48.0
starlette==0.49.3
# via
# fastapi
# starlette-context
starlette-context==0.4.0
# via fastapi-best-architecture
termcolor==3.1.0
termcolor==3.2.0
# via pytest-sugar
tomli==2.3.0 ; python_full_version < '3.11'
# via
@@ -279,9 +265,9 @@ tomli==2.3.0 ; python_full_version < '3.11'
# pytest
tornado==6.5.2
# via flower
type-lens==0.2.5
type-lens==0.2.6
# via cappa
typer==0.19.2
typer==0.20.0
# via fastapi-cli
typing-extensions==4.15.0
# via
@@ -304,7 +290,6 @@ typing-extensions==4.15.0
# typer
# typing-inspection
# uvicorn
# virtualenv
typing-inspection==0.4.2
# via
# pydantic
@@ -321,20 +306,18 @@ urllib3==2.5.0
# via dulwich
user-agents==2.2.0
# via fastapi-best-architecture
uvicorn==0.37.0
uvicorn==0.38.0
# via
# fastapi
# fastapi-cli
uvloop==0.21.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
# via uvicorn
vine==5.1.0
# via
# amqp
# celery
# kombu
virtualenv==20.35.3
# via pre-commit
watchfiles==1.1.0
watchfiles==1.1.1
# via uvicorn
wcwidth==0.2.14
# via prompt-toolkit
@@ -344,7 +327,7 @@ win32-setctime==1.2.0 ; sys_platform == 'win32'
# via loguru
wsproto==1.2.0
# via simple-websocket
zope-event==6.0
zope-event==6.1
# via gevent
zope-interface==8.0.1
# via gevent
Generated
+786 -783
View File
File diff suppressed because it is too large Load Diff