mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-20 20:49:53 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b56168ad0 | ||
|
|
b9255815e1 | ||
|
|
93e2f0f5f9 | ||
|
|
437c026da9 | ||
|
|
316079045f | ||
|
|
5ae9f8b288 | ||
|
|
bef65e1baa | ||
|
|
87a3cd8a9c | ||
|
|
be7b742891 | ||
|
|
1f98797a60 | ||
|
|
8505e32757 | ||
|
|
91297d1ccd | ||
|
|
0ea213edfd | ||
|
|
292f5d7e48 | ||
|
|
ef871f3b5c | ||
|
|
6b17da59f8 | ||
|
|
86d580edd8 | ||
|
|
cdbe37de7d | ||
|
|
f46974ddd9 | ||
|
|
a14d4243d9 | ||
|
|
90588ebae8 | ||
|
|
00bc02f49c | ||
|
|
7e9ce2e691 | ||
|
|
81ec0f0bd9 | ||
|
|
37f9716c4f | ||
|
|
4540cd9338 | ||
|
|
3c39d2f2f3 | ||
|
|
1e01ffc3d9 | ||
|
|
f4b1c43b55 | ||
|
|
02254938b4 | ||
|
|
054ff0cab2 | ||
|
|
4f898a0264 | ||
|
|
70d90c2c01 | ||
|
|
964143b30d | ||
|
|
ce9f59e7d6 | ||
|
|
1f958fc42a | ||
|
|
e6e53296a8 | ||
|
|
f533de8abd | ||
|
|
adc6427aab | ||
|
|
d034b78b48 | ||
|
|
6deda906de | ||
|
|
a063105de8 | ||
|
|
02b4215742 | ||
|
|
56b6e2113f | ||
|
|
4fcc78eb94 | ||
|
|
b28a4d7673 |
@@ -18,4 +18,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
file: CHANGELOG.md
|
file: CHANGELOG.md
|
||||||
pull_request: true
|
pull_request: true
|
||||||
|
commit_summary_template: 'Update the changelog for %s'
|
||||||
|
args: -c
|
||||||
github_token: ${{ secrets.GH_TOKEN }}
|
github_token: ${{ secrets.GH_TOKEN }}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ venv/
|
|||||||
.python-version
|
.python-version
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
default_language_version:
|
||||||
|
python: '>= 3.10'
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
- id: check-json
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
|
|
||||||
|
|||||||
+254
-1
@@ -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>
|
<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
|
# [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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[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
|
**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]
|
[Changes][v1.0.1]
|
||||||
|
|
||||||
|
|
||||||
@@ -870,9 +1107,25 @@
|
|||||||
|
|
||||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/commits/v1.0.0
|
**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]
|
[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.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.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
|
[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.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
|
||||||
[v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0
|
[v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0
|
||||||
|
|
||||||
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.0 -->
|
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
|
||||||
|
|||||||
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
# Select the image to build based on SERVER_TYPE, defaulting to fastapi_server, or docker-compose build args
|
# Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args
|
||||||
ARG SERVER_TYPE=fastapi_server
|
ARG SERVER_TYPE=fba_server
|
||||||
|
|
||||||
# === Python environment from uv ===
|
# === Python environment from uv ===
|
||||||
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
|
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
|
WORKDIR /fba/backend
|
||||||
|
|
||||||
# === FastAPI server image ===
|
# === 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/
|
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
|
EXPOSE 8001
|
||||||
|
|
||||||
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
|
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
|
||||||
|
|
||||||
# === Celery server image ===
|
# === Celery server image ===
|
||||||
FROM base_server AS celery
|
FROM base_server AS fba_celery
|
||||||
|
|
||||||
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
|
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
|
RUN chmod +x celery-start.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Env
|
# Env
|
||||||
ENVIRONMENT='dev'
|
ENVIRONMENT='dev'
|
||||||
# Database
|
# Database
|
||||||
DATABASE_TYPE='mysql'
|
DATABASE_TYPE='postgresql'
|
||||||
DATABASE_HOST='127.0.0.1'
|
DATABASE_HOST='127.0.0.1'
|
||||||
DATABASE_PORT=3306
|
DATABASE_PORT=5432
|
||||||
DATABASE_USER='root'
|
DATABASE_USER='postgres'
|
||||||
DATABASE_PASSWORD='123456'
|
DATABASE_PASSWORD='123456'
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_HOST='127.0.0.1'
|
REDIS_HOST='127.0.0.1'
|
||||||
|
|||||||
+3
-3
@@ -50,10 +50,10 @@
|
|||||||
|
|
||||||
4. Format and Lint
|
4. Format and Lint
|
||||||
|
|
||||||
Auto-formatting and lint via `pre-commit`
|
Auto-formatting and lint via `prek`
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
pre-commit run --all-files
|
prek run --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Commit and push
|
5. Commit and push
|
||||||
@@ -78,6 +78,6 @@
|
|||||||
|
|
||||||
- `scripts/format.sh`: Perform ruff format check
|
- `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
|
- `scripts/export.sh`: Execute uv export dependency package
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
from backend.common.i18n import i18n
|
from backend.common.i18n import i18n
|
||||||
|
|
||||||
__version__ = '1.8.2'
|
__version__ = '1.11.0'
|
||||||
|
|
||||||
|
|
||||||
# 初始化 i18n
|
# 初始化 i18n
|
||||||
|
|||||||
@@ -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__']
|
apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
|
||||||
|
|
||||||
objs = []
|
objs = []
|
||||||
|
|
||||||
for app in apps:
|
for app in apps:
|
||||||
module_path = f'backend.app.{app}.model'
|
module_path = f'backend.app.{app}.model'
|
||||||
obj = get_model_objects(module_path)
|
obj = get_model_objects(module_path)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router = APIRouter()
|
|||||||
async def get_sessions(
|
async def get_sessions(
|
||||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||||
) -> ResponseSchemaModel[list[GetTokenDetail]]:
|
) -> ResponseSchemaModel[list[GetTokenDetail]]:
|
||||||
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
|
token_keys = await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:*')
|
||||||
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
|
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
|
||||||
data: list[GetTokenDetail] = []
|
data: list[GetTokenDetail] = []
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ async def get_dept_tree(
|
|||||||
phone: Annotated[str | None, Query(description='联系电话')] = None,
|
phone: Annotated[str | None, Query(description='联系电话')] = None,
|
||||||
status: Annotated[int | None, Query(description='状态')] = None,
|
status: Annotated[int | None, Query(description='状态')] = None,
|
||||||
) -> ResponseSchemaModel[list[GetDeptTree]]:
|
) -> 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)
|
return response_base.success(data=dept)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ async def update_user_permission(
|
|||||||
async def update_user_password(
|
async def update_user_password(
|
||||||
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
|
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
|
||||||
) -> ResponseModel:
|
) -> 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:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
@@ -126,7 +128,7 @@ async def update_user_nickname(
|
|||||||
request: Request,
|
request: Request,
|
||||||
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
|
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
|
||||||
) -> ResponseModel:
|
) -> 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:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
@@ -138,7 +140,7 @@ async def update_user_avatar(
|
|||||||
request: Request,
|
request: Request,
|
||||||
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
|
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
|
||||||
) -> ResponseModel:
|
) -> 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:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
@@ -151,7 +153,7 @@ async def update_user_email(
|
|||||||
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
|
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
|
||||||
email: Annotated[str, Body(embed=True, description='用户邮箱')],
|
email: Annotated[str, Body(embed=True, description='用户邮箱')],
|
||||||
) -> ResponseModel:
|
) -> 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:
|
if count > 0:
|
||||||
return response_base.success()
|
return response_base.success()
|
||||||
return response_base.fail()
|
return response_base.fail()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
|||||||
if name is not None:
|
if name is not None:
|
||||||
filters['name__like'] = f'%{name}%'
|
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:
|
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
from collections.abc import Sequence
|
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.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.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]):
|
class CRUDDataScope(CRUDPlus[DataScope]):
|
||||||
@@ -31,7 +39,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
|||||||
"""
|
"""
|
||||||
return await self.select_model_by_column(db, name=name)
|
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
|
:param pk: 范围 ID
|
||||||
:return:
|
: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]:
|
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
|
||||||
"""
|
"""
|
||||||
@@ -65,7 +82,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
filters['status'] = status
|
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:
|
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)
|
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 列表
|
:param rule_ids: 数据规则 ID 列表
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
current_data_scope = await self.get_with_relation(db, pk)
|
data_scope_rule_stmt = delete(data_scope_rule).where(data_scope_rule.c.data_scope_id == pk)
|
||||||
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules))
|
await db.execute(data_scope_rule_stmt)
|
||||||
rules = await db.execute(stmt)
|
|
||||||
current_data_scope.rules = rules.scalars().all()
|
data_scope_rule_data = [
|
||||||
return len(current_data_scope.rules)
|
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:
|
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.dept import CreateDeptParam, UpdateDeptParam
|
||||||
|
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||||
from backend.common.security.permission import filter_data_permission
|
from backend.common.security.permission import filter_data_permission
|
||||||
|
from backend.utils.serializers import select_join_serialize
|
||||||
|
|
||||||
|
|
||||||
class CRUDDept(CRUDPlus[Dept]):
|
class CRUDDept(CRUDPlus[Dept]):
|
||||||
@@ -20,7 +22,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
:param dept_id: 部门 ID
|
:param dept_id: 部门 ID
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
return await self.select_model_by_column(db, id=dept_id, del_flag=0)
|
return await self.select_model_by_column(db, id=dept_id, del_flag=False)
|
||||||
|
|
||||||
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
|
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
|
||||||
"""
|
"""
|
||||||
@@ -30,12 +32,12 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
:param name: 部门名称
|
:param name: 部门名称
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
return await self.select_model_by_column(db, name=name, del_flag=0)
|
return await self.select_model_by_column(db, name=name, del_flag=False)
|
||||||
|
|
||||||
async def get_all(
|
async def get_all(
|
||||||
self,
|
self,
|
||||||
request: Request,
|
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
request_user: GetUserInfoWithRelationDetail,
|
||||||
name: str | None,
|
name: str | None,
|
||||||
leader: str | None,
|
leader: str | None,
|
||||||
phone: str | None,
|
phone: str | None,
|
||||||
@@ -44,15 +46,15 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
"""
|
"""
|
||||||
获取所有部门
|
获取所有部门
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
|
:param request_user: 请求用户
|
||||||
:param name: 部门名称
|
:param name: 部门名称
|
||||||
:param leader: 负责人
|
:param leader: 负责人
|
||||||
:param phone: 联系电话
|
:param phone: 联系电话
|
||||||
:param status: 部门状态
|
:param status: 部门状态
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
filters = {'del_flag': 0}
|
filters = {'del_flag': False}
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
filters['name__like'] = f'%{name}%'
|
filters['name__like'] = f'%{name}%'
|
||||||
@@ -63,8 +65,8 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
filters['status'] = status
|
filters['status'] = status
|
||||||
|
|
||||||
data_filtered = await filter_data_permission(db, request)
|
data_filter = filter_data_permission(request_user)
|
||||||
return await self.select_models_order(db, 'sort', 'desc', data_filtered, **filters)
|
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
|
||||||
|
|
||||||
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
|
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')
|
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
|
:param dept_id: 部门 ID
|
||||||
:return:
|
: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]:
|
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
|
:param dept_id: 部门 ID
|
||||||
:return:
|
: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)
|
dept_dao: CRUDDept = CRUDDept(Dept)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy_crud_plus import CRUDPlus
|
from sqlalchemy_crud_plus import CRUDPlus
|
||||||
|
|
||||||
from backend.app.admin.model import Menu
|
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
|
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||||
|
|
||||||
|
|
||||||
@@ -92,9 +94,12 @@ class CRUDMenu(CRUDPlus[Menu]):
|
|||||||
:param menu_id: 菜单 ID
|
:param menu_id: 菜单 ID
|
||||||
:return:
|
: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)
|
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
|
:param menu_id: 菜单 ID
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
menu = await self.select_model(db, menu_id, load_strategies=['children'])
|
return await self.select_models(db, parent_id=menu_id)
|
||||||
return menu.children
|
|
||||||
|
|
||||||
|
|
||||||
menu_dao: CRUDMenu = CRUDMenu(Menu)
|
menu_dao: CRUDMenu = CRUDMenu(Menu)
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
from collections.abc import Sequence
|
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.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 import DataScope, Menu, Role
|
||||||
|
from backend.app.admin.model.m2m import role_data_scope, role_menu
|
||||||
from backend.app.admin.schema.role import (
|
from backend.app.admin.schema.role import (
|
||||||
|
CreateRoleMenuParam,
|
||||||
CreateRoleParam,
|
CreateRoleParam,
|
||||||
|
CreateRoleScopeParam,
|
||||||
UpdateRoleMenuParam,
|
UpdateRoleMenuParam,
|
||||||
UpdateRoleParam,
|
UpdateRoleParam,
|
||||||
UpdateRoleScopeParam,
|
UpdateRoleScopeParam,
|
||||||
)
|
)
|
||||||
|
from backend.utils.serializers import select_join_serialize
|
||||||
|
|
||||||
|
|
||||||
class CRUDRole(CRUDPlus[Role]):
|
class CRUDRole(CRUDPlus[Role]):
|
||||||
@@ -26,7 +31,20 @@ class CRUDRole(CRUDPlus[Role]):
|
|||||||
"""
|
"""
|
||||||
return await self.select_model(db, role_id)
|
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
|
:param role_id: 角色 ID
|
||||||
:return:
|
: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]:
|
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
|
||||||
"""
|
"""
|
||||||
@@ -61,15 +90,7 @@ class CRUDRole(CRUDPlus[Role]):
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
filters['status'] = status
|
filters['status'] = status
|
||||||
|
|
||||||
return await self.select_order(
|
return await self.select_order('id', **filters)
|
||||||
'id',
|
|
||||||
load_strategies={
|
|
||||||
'users': 'noload',
|
|
||||||
'menus': 'noload',
|
|
||||||
'scopes': 'noload',
|
|
||||||
},
|
|
||||||
**filters,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
|
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)
|
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 列表
|
:param menu_ids: 菜单 ID 列表
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
current_role = await self.get_with_relation(db, role_id)
|
role_menu_stmt = delete(role_menu).where(role_menu.c.role_id == role_id)
|
||||||
stmt = select(Menu).where(Menu.id.in_(menu_ids.menus))
|
await db.execute(role_menu_stmt)
|
||||||
menus = await db.execute(stmt)
|
|
||||||
current_role.menus = menus.scalars().all()
|
|
||||||
return len(current_role.menus)
|
|
||||||
|
|
||||||
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 列表
|
:param scope_ids: 权限范围 ID 列表
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
current_role = await self.get_with_relation(db, role_id)
|
role_scope_stmt = delete(role_data_scope).where(role_data_scope.c.role_id == role_id)
|
||||||
stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes))
|
await db.execute(role_scope_stmt)
|
||||||
scopes = await db.execute(stmt)
|
|
||||||
current_role.scopes = scopes.scalars().all()
|
role_scope_data = [
|
||||||
return len(current_role.scopes)
|
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:
|
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import Select, delete, insert, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import noload, selectinload
|
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||||
from sqlalchemy.sql import Select
|
|
||||||
from sqlalchemy_crud_plus import CRUDPlus
|
|
||||||
|
|
||||||
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 (
|
from backend.app.admin.schema.user import (
|
||||||
AddOAuth2UserParam,
|
AddOAuth2UserParam,
|
||||||
AddUserParam,
|
AddUserParam,
|
||||||
|
AddUserRoleParam,
|
||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
)
|
)
|
||||||
from backend.common.security.jwt import get_hash_password
|
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
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -69,15 +73,20 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
"""
|
"""
|
||||||
salt = bcrypt.gensalt()
|
salt = bcrypt.gensalt()
|
||||||
obj.password = get_hash_password(obj.password, salt)
|
obj.password = get_hash_password(obj.password, salt)
|
||||||
|
|
||||||
dict_obj = obj.model_dump(exclude={'roles'})
|
dict_obj = obj.model_dump(exclude={'roles'})
|
||||||
dict_obj.update({'salt': salt})
|
dict_obj.update({'salt': salt})
|
||||||
new_user = self.model(**dict_obj)
|
new_user = self.model(**dict_obj)
|
||||||
|
|
||||||
stmt = select(Role).where(Role.id.in_(obj.roles))
|
|
||||||
roles = await db.execute(stmt)
|
|
||||||
new_user.roles = roles.scalars().all()
|
|
||||||
|
|
||||||
db.add(new_user)
|
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:
|
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 = obj.model_dump()
|
||||||
dict_obj.update({'is_staff': True, 'salt': None})
|
dict_obj.update({'is_staff': True, 'salt': None})
|
||||||
new_user = self.model(**dict_obj)
|
new_user = self.model(**dict_obj)
|
||||||
|
|
||||||
stmt = select(Role)
|
|
||||||
role = await db.execute(stmt)
|
|
||||||
new_user.roles = [role.scalars().first()] # 默认绑定第一个角色
|
|
||||||
|
|
||||||
db.add(new_user)
|
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:
|
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -108,11 +120,20 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
"""
|
"""
|
||||||
role_ids = obj.roles
|
role_ids = obj.roles
|
||||||
del obj.roles
|
del obj.roles
|
||||||
|
|
||||||
count = await self.update_model(db, input_user.id, obj)
|
count = await self.update_model(db, input_user.id, obj)
|
||||||
|
|
||||||
stmt = select(Role).where(Role.id.in_(role_ids))
|
role_stmt = select(Role).where(Role.id.in_(role_ids))
|
||||||
roles = await db.execute(stmt)
|
result = await db.execute(role_stmt)
|
||||||
input_user.roles = roles.scalars().all()
|
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
|
return count
|
||||||
|
|
||||||
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
|
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
|
:param user_id: 用户 ID
|
||||||
:return:
|
: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)
|
return await self.delete_model(db, user_id)
|
||||||
|
|
||||||
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
||||||
@@ -205,9 +231,10 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
return await self.select_order(
|
return await self.select_order(
|
||||||
'id',
|
'id',
|
||||||
'desc',
|
'desc',
|
||||||
load_options=[
|
join_conditions=[
|
||||||
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
|
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
|
||||||
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
|
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,
|
**filters,
|
||||||
)
|
)
|
||||||
@@ -256,13 +283,13 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
"""
|
"""
|
||||||
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
|
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
|
||||||
|
|
||||||
async def get_with_relation(
|
async def get_join(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
) -> User | None:
|
) -> Any | None:
|
||||||
"""
|
"""
|
||||||
获取用户关联信息
|
获取用户关联信息
|
||||||
|
|
||||||
@@ -278,12 +305,32 @@ class CRUDUser(CRUDPlus[User]):
|
|||||||
if username:
|
if username:
|
||||||
filters['username'] = username
|
filters['username'] = username
|
||||||
|
|
||||||
return await self.select_model_by_column(
|
result = await self.select_models(
|
||||||
db,
|
db,
|
||||||
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))],
|
join_conditions=[
|
||||||
load_strategies=['dept'],
|
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,
|
**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)
|
user_dao: CRUDUser = CRUDUser(User)
|
||||||
|
|||||||
@@ -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
|
from backend.common.model import Base, id_key
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.app.admin.model import DataScope
|
|
||||||
|
|
||||||
|
|
||||||
class DataRule(Base):
|
class DataRule(Base):
|
||||||
"""数据规则表"""
|
"""数据规则表"""
|
||||||
@@ -18,14 +11,11 @@ class DataRule(Base):
|
|||||||
__tablename__ = 'sys_data_rule'
|
__tablename__ = 'sys_data_rule'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称')
|
name: Mapped[str] = mapped_column(sa.String(512), unique=True, comment='名称')
|
||||||
model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名')
|
model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
|
||||||
column: Mapped[str] = mapped_column(String(20), comment='模型字段名')
|
column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
|
||||||
operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)')
|
operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)')
|
||||||
expression: Mapped[int] = mapped_column(
|
expression: Mapped[int] = mapped_column(
|
||||||
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)',
|
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)',
|
||||||
)
|
)
|
||||||
value: Mapped[str] = mapped_column(String(255), comment='规则值')
|
value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
|
||||||
|
|
||||||
# 数据范围规则多对多
|
|
||||||
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
|
|
||||||
|
|||||||
@@ -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
|
from backend.common.model import Base, id_key
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.app.admin.model import DataRule, Role
|
|
||||||
|
|
||||||
|
|
||||||
class DataScope(Base):
|
class DataScope(Base):
|
||||||
"""数据范围表"""
|
"""数据范围表"""
|
||||||
@@ -18,11 +11,5 @@ class DataScope(Base):
|
|||||||
__tablename__ = 'sys_data_scope'
|
__tablename__ = 'sys_data_scope'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
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正常)')
|
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')
|
|
||||||
|
|||||||
@@ -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 BigInteger, Boolean, ForeignKey, String
|
|
||||||
from sqlalchemy.dialects.postgresql import INTEGER
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from backend.common.model import Base, id_key
|
from backend.common.model import Base, id_key
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.app.admin.model import User
|
|
||||||
|
|
||||||
|
|
||||||
class Dept(Base):
|
class Dept(Base):
|
||||||
"""部门表"""
|
"""部门表"""
|
||||||
@@ -18,28 +11,13 @@ class Dept(Base):
|
|||||||
__tablename__ = 'sys_dept'
|
__tablename__ = 'sys_dept'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
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='排序')
|
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||||
leader: Mapped[str | None] = mapped_column(String(20), default=None, comment='负责人')
|
leader: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='负责人')
|
||||||
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机')
|
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
|
||||||
email: Mapped[str | None] = mapped_column(String(50), 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正常)')
|
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
|
||||||
del_flag: Mapped[bool] = mapped_column(
|
del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)')
|
||||||
Boolean().with_variant(INTEGER, 'postgresql'),
|
|
||||||
default=False,
|
|
||||||
comment='删除标志(0删除 1存在)',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 父级部门一对多
|
# 父级部门
|
||||||
parent_id: Mapped[int | None] = mapped_column(
|
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
|
||||||
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')
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import String
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
|
||||||
from sqlalchemy.dialects.postgresql import TEXT
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -15,18 +14,18 @@ class LoginLog(DataClassBase):
|
|||||||
__tablename__ = 'sys_login_log'
|
__tablename__ = 'sys_login_log'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
user_uuid: Mapped[str] = mapped_column(String(50), comment='用户UUID')
|
user_uuid: Mapped[str] = mapped_column(sa.String(64), comment='用户UUID')
|
||||||
username: Mapped[str] = mapped_column(String(20), comment='用户名')
|
username: Mapped[str] = mapped_column(sa.String(64), comment='用户名')
|
||||||
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
|
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
|
||||||
ip: Mapped[str] = mapped_column(String(50), comment='登录IP地址')
|
ip: Mapped[str] = mapped_column(sa.String(64), comment='登录IP地址')
|
||||||
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
|
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||||
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
|
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||||
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
|
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||||
user_agent: Mapped[str] = mapped_column(String(255), comment='请求头')
|
user_agent: Mapped[str] = mapped_column(sa.String(256), comment='请求头')
|
||||||
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
|
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||||
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
|
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||||
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
|
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||||
msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
|
msg: Mapped[str] = mapped_column(UniversalText, comment='提示消息')
|
||||||
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
|
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
|
||||||
created_time: Mapped[datetime] = mapped_column(
|
created_time: Mapped[datetime] = mapped_column(
|
||||||
TimeZone,
|
TimeZone,
|
||||||
|
|||||||
@@ -1,53 +1,39 @@
|
|||||||
from sqlalchemy import BigInteger, Column, ForeignKey, Table
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from backend.common.model import MappedBase
|
from backend.common.model import MappedBase
|
||||||
|
|
||||||
sys_user_role = Table(
|
# 用户角色表
|
||||||
|
user_role = sa.Table(
|
||||||
'sys_user_role',
|
'sys_user_role',
|
||||||
MappedBase.metadata,
|
MappedBase.metadata,
|
||||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
sa.Column('id', sa.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'),
|
sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
|
||||||
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), 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',
|
'sys_role_menu',
|
||||||
MappedBase.metadata,
|
MappedBase.metadata,
|
||||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
sa.Column('id', sa.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'),
|
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||||
Column('menu_id', BigInteger, ForeignKey('sys_menu.id', ondelete='CASCADE'), 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',
|
'sys_role_data_scope',
|
||||||
MappedBase.metadata,
|
MappedBase.metadata,
|
||||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
|
sa.Column('id', sa.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'),
|
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
|
||||||
Column(
|
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||||
'data_scope_id',
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
|
|
||||||
primary_key=True,
|
|
||||||
comment='数据范围 ID',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sys_data_scope_rule = Table(
|
# 数据范围规则表
|
||||||
|
data_scope_rule = sa.Table(
|
||||||
'sys_data_scope_rule',
|
'sys_data_scope_rule',
|
||||||
MappedBase.metadata,
|
MappedBase.metadata,
|
||||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||||
Column(
|
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||||
'data_scope_id',
|
sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 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',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 backend.common.model import Base, UniversalText, id_key
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Menu(Base):
|
class Menu(Base):
|
||||||
@@ -20,38 +11,19 @@ class Menu(Base):
|
|||||||
__tablename__ = 'sys_menu'
|
__tablename__ = 'sys_menu'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
title: Mapped[str] = mapped_column(String(50), comment='菜单标题')
|
title: Mapped[str] = mapped_column(sa.String(64), comment='菜单标题')
|
||||||
name: Mapped[str] = mapped_column(String(50), comment='菜单名称')
|
name: Mapped[str] = mapped_column(sa.String(64), comment='菜单名称')
|
||||||
path: Mapped[str | None] = mapped_column(String(200), comment='路由地址')
|
path: Mapped[str | None] = mapped_column(sa.String(200), comment='路由地址')
|
||||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||||
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标')
|
icon: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='菜单图标')
|
||||||
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
||||||
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径')
|
component: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='组件路径')
|
||||||
perms: Mapped[str | None] = mapped_column(String(100), 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正常)')
|
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
|
||||||
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
|
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
|
||||||
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
|
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
|
||||||
link: Mapped[str | None] = mapped_column(
|
link: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='外链地址')
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||||
default=None,
|
|
||||||
comment='外链地址',
|
|
||||||
)
|
|
||||||
remark: Mapped[str | None] = mapped_column(
|
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
|
||||||
default=None,
|
|
||||||
comment='备注',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 父级菜单一对多
|
# 父级菜单
|
||||||
parent_id: Mapped[int | None] = mapped_column(
|
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父菜单ID')
|
||||||
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')
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import String
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects.mysql import JSON, LONGTEXT
|
|
||||||
from sqlalchemy.dialects.postgresql import TEXT
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -15,28 +14,25 @@ class OperaLog(DataClassBase):
|
|||||||
__tablename__ = 'sys_opera_log'
|
__tablename__ = 'sys_opera_log'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
trace_id: Mapped[str] = mapped_column(String(32), comment='请求跟踪 ID')
|
trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
|
||||||
username: Mapped[str | None] = mapped_column(String(20), comment='用户名')
|
username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
|
||||||
method: Mapped[str] = mapped_column(String(20), comment='请求类型')
|
method: Mapped[str] = mapped_column(sa.String(32), comment='请求类型')
|
||||||
title: Mapped[str] = mapped_column(String(255), comment='操作模块')
|
title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
|
||||||
path: Mapped[str] = mapped_column(String(500), comment='请求路径')
|
path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
|
||||||
ip: Mapped[str] = mapped_column(String(50), comment='IP地址')
|
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP地址')
|
||||||
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
|
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||||
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
|
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||||
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
|
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||||
user_agent: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='请求头')
|
user_agent: Mapped[str] = mapped_column(sa.String(512), comment='请求头')
|
||||||
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
|
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||||
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
|
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||||
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
|
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||||
args: Mapped[str | None] = mapped_column(JSON(), comment='请求参数')
|
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='请求参数')
|
||||||
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
|
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
|
||||||
code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码')
|
code: Mapped[str] = mapped_column(sa.String(32), insert_default='200', comment='操作状态码')
|
||||||
msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
|
msg: Mapped[str | None] = mapped_column(UniversalText, comment='提示消息')
|
||||||
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms)')
|
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms)')
|
||||||
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
|
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
|
||||||
created_time: Mapped[datetime] = mapped_column(
|
created_time: Mapped[datetime] = mapped_column(
|
||||||
TimeZone,
|
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
|
||||||
init=False,
|
|
||||||
default_factory=timezone.now,
|
|
||||||
comment='创建时间',
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 backend.common.model import Base, UniversalText, id_key
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Role(Base):
|
class Role(Base):
|
||||||
@@ -20,24 +11,7 @@ class Role(Base):
|
|||||||
__tablename__ = 'sys_role'
|
__tablename__ = 'sys_role'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
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正常)')
|
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
|
||||||
is_filter_scopes: Mapped[bool] = mapped_column(
|
is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
|
||||||
Boolean().with_variant(INTEGER, 'postgresql'),
|
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||||
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')
|
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import VARBINARY, Boolean, ForeignKey, String
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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.common.model import Base, TimeZone, id_key
|
||||||
from backend.database.db import uuid4_str
|
from backend.database.db import uuid4_str
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.app.admin.model import Dept, Role
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""用户表"""
|
"""用户表"""
|
||||||
@@ -22,45 +15,22 @@ class User(Base):
|
|||||||
__tablename__ = 'sys_user'
|
__tablename__ = 'sys_user'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True)
|
uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
|
||||||
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名')
|
username: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='用户名')
|
||||||
nickname: Mapped[str] = mapped_column(String(20), comment='昵称')
|
nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
|
||||||
password: Mapped[str | None] = mapped_column(String(255), comment='密码')
|
password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
|
||||||
salt: Mapped[bytes | None] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐')
|
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(255), comment='加密盐')
|
||||||
email: Mapped[str | None] = mapped_column(String(50), default=None, unique=True, index=True, comment='邮箱')
|
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, unique=True, index=True, comment='邮箱')
|
||||||
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
|
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
|
||||||
avatar: Mapped[str | None] = mapped_column(String(255), 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正常)')
|
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
|
||||||
is_superuser: Mapped[bool] = mapped_column(
|
is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
|
||||||
Boolean().with_variant(INTEGER, 'postgresql'),
|
is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
|
||||||
default=False,
|
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
|
||||||
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是)',
|
|
||||||
)
|
|
||||||
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
|
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
|
||||||
last_login_time: Mapped[datetime | None] = mapped_column(
|
last_login_time: Mapped[datetime | None] = mapped_column(
|
||||||
TimeZone,
|
TimeZone, init=False, onupdate=timezone.now, comment='上次登录'
|
||||||
init=False,
|
|
||||||
onupdate=timezone.now,
|
|
||||||
comment='上次登录',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 部门用户一对多
|
# 逻辑外键
|
||||||
dept_id: Mapped[int | None] = mapped_column(
|
dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='部门关联ID')
|
||||||
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')
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class DeleteDataRuleParam(SchemaBase):
|
|||||||
class GetDataRuleDetail(DataRuleSchemaBase):
|
class GetDataRuleDetail(DataRuleSchemaBase):
|
||||||
"""数据规则详情"""
|
"""数据规则详情"""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True, frozen=True)
|
||||||
|
|
||||||
id: int = Field(description='规则 ID')
|
id: int = Field(description='规则 ID')
|
||||||
created_time: datetime = Field(description='创建时间')
|
created_time: datetime = Field(description='创建时间')
|
||||||
|
|||||||
@@ -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):
|
class UpdateDataScopeRuleParam(SchemaBase):
|
||||||
"""更新数据范围规则参数"""
|
"""更新数据范围规则参数"""
|
||||||
|
|
||||||
@@ -47,4 +54,4 @@ class GetDataScopeDetail(DataScopeBase):
|
|||||||
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
|
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
|
||||||
"""数据范围关联详情"""
|
"""数据范围关联详情"""
|
||||||
|
|
||||||
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
|
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pydantic import ConfigDict, Field
|
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.app.admin.schema.menu import GetMenuDetail
|
||||||
from backend.common.enums import StatusType
|
from backend.common.enums import StatusType
|
||||||
from backend.common.schema import SchemaBase
|
from backend.common.schema import SchemaBase
|
||||||
@@ -31,12 +31,26 @@ class DeleteRoleParam(SchemaBase):
|
|||||||
pks: list[int] = Field(description='角色 ID 列表')
|
pks: list[int] = Field(description='角色 ID 列表')
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoleMenuParam(SchemaBase):
|
||||||
|
"""创建角色菜单参数"""
|
||||||
|
|
||||||
|
role_id: int = Field(description='角色 ID')
|
||||||
|
menu_id: int = Field(description='菜单 ID')
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoleMenuParam(SchemaBase):
|
class UpdateRoleMenuParam(SchemaBase):
|
||||||
"""更新角色菜单参数"""
|
"""更新角色菜单参数"""
|
||||||
|
|
||||||
menus: list[int] = Field(description='菜单 ID 列表')
|
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):
|
class UpdateRoleScopeParam(SchemaBase):
|
||||||
"""更新角色数据范围参数"""
|
"""更新角色数据范围参数"""
|
||||||
|
|
||||||
@@ -57,4 +71,4 @@ class GetRoleWithRelationDetail(GetRoleDetail):
|
|||||||
"""角色关联详情"""
|
"""角色关联详情"""
|
||||||
|
|
||||||
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
|
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
|
||||||
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
|
scopes: list[GetDataScopeWithRelationDetail | None] = Field([], description='数据范围列表')
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
from datetime import datetime
|
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 typing_extensions import Self
|
||||||
|
|
||||||
from backend.app.admin.schema.dept import GetDeptDetail
|
from backend.app.admin.schema.dept import GetDeptDetail
|
||||||
from backend.app.admin.schema.role import GetRoleWithRelationDetail
|
from backend.app.admin.schema.role import GetRoleWithRelationDetail
|
||||||
from backend.common.enums import StatusType
|
from backend.common.enums import StatusType
|
||||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
|
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
|
||||||
|
|
||||||
|
|
||||||
class AuthSchemaBase(SchemaBase):
|
class AuthSchemaBase(SchemaBase):
|
||||||
@@ -34,13 +34,20 @@ class AddUserParam(AuthSchemaBase):
|
|||||||
roles: list[int] = Field(description='角色 ID 列表')
|
roles: list[int] = Field(description='角色 ID 列表')
|
||||||
|
|
||||||
|
|
||||||
|
class AddUserRoleParam(SchemaBase):
|
||||||
|
"""添加用户角色"""
|
||||||
|
|
||||||
|
user_id: int = Field(description='用户 ID')
|
||||||
|
role_id: int = Field(description='角色 ID')
|
||||||
|
|
||||||
|
|
||||||
class AddOAuth2UserParam(AuthSchemaBase):
|
class AddOAuth2UserParam(AuthSchemaBase):
|
||||||
"""添加 OAuth2 用户参数"""
|
"""添加 OAuth2 用户参数"""
|
||||||
|
|
||||||
password: str | None = Field(None, description='密码')
|
password: str | None = Field(None, description='密码')
|
||||||
nickname: str | None = Field(None, description='昵称')
|
nickname: str | None = Field(None, description='昵称')
|
||||||
email: CustomEmailStr | 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):
|
class ResetPasswordParam(SchemaBase):
|
||||||
@@ -57,7 +64,7 @@ class UserInfoSchemaBase(SchemaBase):
|
|||||||
dept_id: int | None = Field(None, description='部门 ID')
|
dept_id: int | None = Field(None, description='部门 ID')
|
||||||
username: str = Field(description='用户名')
|
username: str = Field(description='用户名')
|
||||||
nickname: str = Field(description='昵称')
|
nickname: str = Field(description='昵称')
|
||||||
avatar: HttpUrl | None = Field(None, description='头像地址')
|
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
|
||||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||||
phone: CustomPhoneNumber | None = Field(None, description='手机号')
|
phone: CustomPhoneNumber | None = Field(None, description='手机号')
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ class AuthService:
|
|||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
if not user.status:
|
if not user.status:
|
||||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
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='此用户已在异地登录,请重新登录并及时修改密码')
|
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||||
new_token = await create_new_token(
|
new_token = await create_new_token(
|
||||||
refresh_token,
|
refresh_token,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from backend.app.admin.schema.data_rule import (
|
|||||||
GetDataRuleColumnDetail,
|
GetDataRuleColumnDetail,
|
||||||
UpdateDataRuleParam,
|
UpdateDataRuleParam,
|
||||||
)
|
)
|
||||||
|
from backend.app.admin.utils.cache import user_cache_manager
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.pagination import paging_data
|
from backend.common.pagination import paging_data
|
||||||
from backend.core.conf import settings
|
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):
|
if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
|
||||||
raise errors.ConflictError(msg='数据规则已存在')
|
raise errors.ConflictError(msg='数据规则已存在')
|
||||||
count = await data_rule_dao.update(db, pk, obj)
|
count = await data_rule_dao.update(db, pk, obj)
|
||||||
|
await user_cache_manager.clear_by_data_rule_id(db, [pk])
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -125,6 +127,7 @@ class DataRuleService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
count = await data_rule_dao.delete(db, obj.pks)
|
count = await data_rule_dao.delete(db, obj.pks)
|
||||||
|
await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ from backend.app.admin.schema.data_scope import (
|
|||||||
UpdateDataScopeParam,
|
UpdateDataScopeParam,
|
||||||
UpdateDataScopeRuleParam,
|
UpdateDataScopeRuleParam,
|
||||||
)
|
)
|
||||||
|
from backend.app.admin.utils.cache import user_cache_manager
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.pagination import paging_data
|
from backend.common.pagination import paging_data
|
||||||
from backend.core.conf import settings
|
|
||||||
from backend.database.redis import redis_client
|
|
||||||
|
|
||||||
|
|
||||||
class DataScopeService:
|
class DataScopeService:
|
||||||
@@ -57,7 +56,7 @@ class DataScopeService:
|
|||||||
:return:
|
: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:
|
if not data_scope:
|
||||||
raise errors.NotFoundError(msg='数据范围不存在')
|
raise errors.NotFoundError(msg='数据范围不存在')
|
||||||
return data_scope
|
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):
|
if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
|
||||||
raise errors.ConflictError(msg='数据范围已存在')
|
raise errors.ConflictError(msg='数据范围已存在')
|
||||||
count = await data_scope_dao.update(db, pk, obj)
|
count = await data_scope_dao.update(db, pk, obj)
|
||||||
for role in await data_scope.awaitable_attrs.roles:
|
await user_cache_manager.clear_by_data_scope_id(db, [pk])
|
||||||
for user in await role.awaitable_attrs.users:
|
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -115,11 +112,13 @@ class DataScopeService:
|
|||||||
"""
|
"""
|
||||||
更新数据范围规则
|
更新数据范围规则
|
||||||
|
|
||||||
|
:param db: 数据库会话
|
||||||
:param pk: 范围 ID
|
:param pk: 范围 ID
|
||||||
:param rule_ids: 规则 ID 列表
|
:param rule_ids: 规则 ID 列表
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
count = await data_scope_dao.update_rules(db, pk, rule_ids)
|
count = await data_scope_dao.update_rules(db, pk, rule_ids)
|
||||||
|
await user_cache_manager.clear_by_data_scope_id(db, [pk])
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -132,12 +131,7 @@ class DataScopeService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
count = await data_scope_dao.delete(db, obj.pks)
|
count = await data_scope_dao.delete(db, obj.pks)
|
||||||
for pk in obj.pks:
|
await user_cache_manager.clear_by_data_scope_id(db, 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}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.app.admin.crud.crud_dept import dept_dao
|
from backend.app.admin.crud.crud_dept import dept_dao
|
||||||
from backend.app.admin.model import Dept
|
from backend.app.admin.model import Dept
|
||||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||||
|
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
@@ -34,7 +34,7 @@ class DeptService:
|
|||||||
async def get_tree(
|
async def get_tree(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
request: Request,
|
request_user: GetUserInfoWithRelationDetail,
|
||||||
name: str | None,
|
name: str | None,
|
||||||
leader: str | None,
|
leader: str | None,
|
||||||
phone: str | None,
|
phone: str | None,
|
||||||
@@ -44,7 +44,7 @@ class DeptService:
|
|||||||
获取部门树形结构
|
获取部门树形结构
|
||||||
|
|
||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:param request: FastAPI 请求对象
|
:param request_user: 请求用户
|
||||||
:param name: 部门名称
|
:param name: 部门名称
|
||||||
:param leader: 部门负责人
|
:param leader: 部门负责人
|
||||||
:param phone: 联系电话
|
:param phone: 联系电话
|
||||||
@@ -52,7 +52,7 @@ class DeptService:
|
|||||||
:return:
|
: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)
|
tree_data = get_tree_data(dept_select)
|
||||||
return tree_data
|
return tree_data
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class DeptService:
|
|||||||
dept = await dept_dao.get_by_name(db, obj.name)
|
dept = await dept_dao.get_by_name(db, obj.name)
|
||||||
if dept:
|
if dept:
|
||||||
raise errors.ConflictError(msg='部门名称已存在')
|
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)
|
parent_dept = await dept_dao.get(db, obj.parent_id)
|
||||||
if not parent_dept:
|
if not parent_dept:
|
||||||
raise errors.NotFoundError(msg='父级部门不存在')
|
raise errors.NotFoundError(msg='父级部门不存在')
|
||||||
@@ -107,7 +107,9 @@ class DeptService:
|
|||||||
:param pk: 部门 ID
|
:param pk: 部门 ID
|
||||||
:return:
|
: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:
|
if dept.users:
|
||||||
raise errors.ConflictError(msg='部门下存在用户,无法删除')
|
raise errors.ConflictError(msg='部门下存在用户,无法删除')
|
||||||
children = await dept_dao.get_children(db, pk)
|
children = await dept_dao.get_children(db, pk)
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from backend.app.admin.crud.crud_menu import menu_dao
|
from backend.app.admin.crud.crud_menu import menu_dao
|
||||||
from backend.app.admin.model import Menu
|
from backend.app.admin.model import Menu
|
||||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||||
|
from backend.app.admin.utils.cache import user_cache_manager
|
||||||
from backend.common.exception import errors
|
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
|
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ class MenuService:
|
|||||||
:param request: FastAPI 请求对象
|
:param request: FastAPI 请求对象
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
menu_data = None
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
menu_data = await menu_dao.get_sidebar(db, None)
|
menu_data = await menu_dao.get_sidebar(db, None)
|
||||||
else:
|
else:
|
||||||
@@ -64,8 +63,11 @@ class MenuService:
|
|||||||
for role in roles:
|
for role in roles:
|
||||||
menu_ids.update(menu.id for menu in role.menus)
|
menu_ids.update(menu.id for menu in role.menus)
|
||||||
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
|
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
|
@staticmethod
|
||||||
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
|
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
|
||||||
@@ -109,9 +111,7 @@ class MenuService:
|
|||||||
if obj.parent_id == menu.id:
|
if obj.parent_id == menu.id:
|
||||||
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
||||||
count = await menu_dao.update(db, pk, obj)
|
count = await menu_dao.update(db, pk, obj)
|
||||||
for role in await menu.awaitable_attrs.roles:
|
await user_cache_manager.clear_by_menu_id(db, [pk])
|
||||||
for user in await role.awaitable_attrs.users:
|
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -127,12 +127,9 @@ class MenuService:
|
|||||||
children = await menu_dao.get_children(db, pk)
|
children = await menu_dao.get_children(db, pk)
|
||||||
if children:
|
if children:
|
||||||
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
|
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
|
||||||
menu = await menu_dao.get(db, pk)
|
|
||||||
count = await menu_dao.delete(db, pk)
|
count = await menu_dao.delete(db, pk)
|
||||||
if menu:
|
if count:
|
||||||
for role in await menu.awaitable_attrs.roles:
|
await user_cache_manager.clear_by_menu_id(db, [pk])
|
||||||
for user in await role.awaitable_attrs.users:
|
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ from backend.app.admin.schema.role import (
|
|||||||
UpdateRoleParam,
|
UpdateRoleParam,
|
||||||
UpdateRoleScopeParam,
|
UpdateRoleScopeParam,
|
||||||
)
|
)
|
||||||
|
from backend.app.admin.utils.cache import user_cache_manager
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.pagination import paging_data
|
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
|
from backend.utils.build_tree import get_tree_data
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ class RoleService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
role = await role_dao.get_with_relation(db, pk)
|
role = await role_dao.get_join(db, pk)
|
||||||
if not role:
|
if not role:
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
return role
|
return role
|
||||||
@@ -74,10 +73,11 @@ class RoleService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
role = await role_dao.get_with_relation(db, pk)
|
role = await role_dao.get(db, pk)
|
||||||
if not role:
|
if not role:
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
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
|
return menu_tree
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -90,7 +90,7 @@ class RoleService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
role = await role_dao.get_with_relation(db, pk)
|
role = await role_dao.get_join(db, pk)
|
||||||
if not role:
|
if not role:
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
scope_ids = [scope.id for scope in role.scopes]
|
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):
|
if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
|
||||||
raise errors.ConflictError(msg='角色已存在')
|
raise errors.ConflictError(msg='角色已存在')
|
||||||
count = await role_dao.update(db, pk, obj)
|
count = await role_dao.update(db, pk, obj)
|
||||||
for user in await role.awaitable_attrs.users:
|
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||||
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -151,8 +150,7 @@ class RoleService:
|
|||||||
if not menu:
|
if not menu:
|
||||||
raise errors.NotFoundError(msg='菜单不存在')
|
raise errors.NotFoundError(msg='菜单不存在')
|
||||||
count = await role_dao.update_menus(db, pk, menu_ids)
|
count = await role_dao.update_menus(db, pk, menu_ids)
|
||||||
for user in await role.awaitable_attrs.users:
|
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||||
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -174,8 +172,7 @@ class RoleService:
|
|||||||
if not scope:
|
if not scope:
|
||||||
raise errors.NotFoundError(msg='数据范围不存在')
|
raise errors.NotFoundError(msg='数据范围不存在')
|
||||||
count = await role_dao.update_scopes(db, pk, scope_ids)
|
count = await role_dao.update_scopes(db, pk, scope_ids)
|
||||||
for user in await role.awaitable_attrs.users:
|
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -189,11 +186,7 @@ class RoleService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
count = await role_dao.delete(db, obj.pks)
|
count = await role_dao.delete(db, obj.pks)
|
||||||
for pk in obj.pks:
|
await user_cache_manager.clear_by_role_id(db, 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}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.common.security.jwt import get_token, jwt_decode, password_verify
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
|
from backend.utils.serializers import select_join_serialize
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
class UserService:
|
||||||
@@ -38,7 +39,7 @@ class UserService:
|
|||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:return:
|
: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:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
return user
|
return user
|
||||||
@@ -52,7 +53,7 @@ class UserService:
|
|||||||
:param pk: 用户 ID
|
:param pk: 用户 ID
|
||||||
:return:
|
: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:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
return user.roles
|
return user.roles
|
||||||
@@ -70,7 +71,10 @@ class UserService:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
|
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
|
@staticmethod
|
||||||
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
|
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
|
||||||
@@ -103,11 +107,13 @@ class UserService:
|
|||||||
:param obj: 用户更新参数
|
:param obj: 用户更新参数
|
||||||
:return:
|
: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:
|
if not user:
|
||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
|
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
|
||||||
raise errors.ConflictError(msg='用户名已注册')
|
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:
|
for role_id in obj.roles:
|
||||||
if not await role_dao.get(db, role_id):
|
if not await role_dao.get(db, role_id):
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
@@ -200,93 +206,74 @@ class UserService:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@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 db: 数据库会话
|
||||||
:param request: FastAPI 请求对象
|
:param user_id: 用户 ID
|
||||||
:param nickname: 用户昵称
|
:param nickname: 用户昵称
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
token = get_token(request)
|
count = await user_dao.update_nickname(db, user_id, nickname)
|
||||||
token_payload = jwt_decode(token)
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||||
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}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@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 db: 数据库会话
|
||||||
:param request: FastAPI 请求对象
|
:param user_id: 用户 ID
|
||||||
:param avatar: 头像地址
|
:param avatar: 头像地址
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
token = get_token(request)
|
count = await user_dao.update_avatar(db, user_id, avatar)
|
||||||
token_payload = jwt_decode(token)
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||||
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}')
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@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 db: 数据库会话
|
||||||
:param request: FastAPI 请求对象
|
:param user_id: 用户 ID
|
||||||
:param captcha: 邮箱验证码
|
:param captcha: 邮箱验证码
|
||||||
:param email: 邮箱
|
:param email: 邮箱
|
||||||
:return:
|
: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}')
|
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
|
||||||
if not captcha_code:
|
if not captcha_code:
|
||||||
raise errors.RequestError(msg='验证码已失效,请重新获取')
|
raise errors.RequestError(msg='验证码已失效,请重新获取')
|
||||||
if captcha != captcha_code:
|
if captcha != captcha_code:
|
||||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||||
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
|
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
|
||||||
count = await user_dao.update_email(db, token_payload.id, email)
|
count = await user_dao.update_email(db, user_id, email)
|
||||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@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 db: 数据库会话
|
||||||
:param request: FastAPI 请求对象
|
:param user_id: 用户 ID
|
||||||
|
:param hash_password: 哈希密码
|
||||||
:param obj: 密码重置参数
|
:param obj: 密码重置参数
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
token = get_token(request)
|
if not password_verify(obj.old_password, hash_password):
|
||||||
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):
|
|
||||||
raise errors.RequestError(msg='原密码错误')
|
raise errors.RequestError(msg='原密码错误')
|
||||||
if obj.new_password != obj.confirm_password:
|
if obj.new_password != obj.confirm_password:
|
||||||
raise errors.RequestError(msg='密码输入不一致')
|
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 = [
|
key_prefix = [
|
||||||
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
|
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}',
|
||||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
|
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}',
|
||||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
|
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
||||||
]
|
]
|
||||||
for prefix in key_prefix:
|
for prefix in key_prefix:
|
||||||
await redis_client.delete_prefix(prefix)
|
await redis_client.delete_prefix(prefix)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -27,15 +27,20 @@ def init_celery() -> celery.Celery:
|
|||||||
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
|
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
|
||||||
celery.app.trace.reset_worker_optimizations()
|
celery.app.trace.reset_worker_optimizations()
|
||||||
|
|
||||||
|
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
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
|
||||||
app = celery.Celery(
|
app = celery.Celery(
|
||||||
'fba_celery',
|
'fba_celery',
|
||||||
broker_url=f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
|
broker_url=broker_url,
|
||||||
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_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
result_backend=f'db+{settings.DATABASE_TYPE}+{"pymysql" if settings.DATABASE_TYPE == "mysql" else "psycopg"}'
|
result_backend=result_backend,
|
||||||
f'://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}',
|
|
||||||
result_extended=True,
|
result_extended=True,
|
||||||
database_engine_options={'echo': settings.DATABASE_ECHO},
|
database_engine_options={'echo': settings.DATABASE_ECHO},
|
||||||
# result_expires=0,
|
# result_expires=0,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from celery import states
|
from celery import states
|
||||||
from sqlalchemy.types import PickleType
|
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 迁移
|
重写 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)
|
id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True)
|
||||||
task_id = sa.Column(sa.String(155), unique=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)
|
result = sa.Column(PickleType, nullable=True)
|
||||||
date_done = sa.Column(
|
date_done = sa.Column(
|
||||||
sa.DateTime,
|
TimeZone,
|
||||||
default=datetime.now(timezone.utc),
|
default=timezone.now,
|
||||||
onupdate=datetime.now(timezone.utc),
|
onupdate=timezone.now,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
traceback = sa.Column(sa.Text, 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)
|
id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True)
|
||||||
taskset_id = sa.Column(sa.String(155), unique=True)
|
taskset_id = sa.Column(sa.String(155), unique=True)
|
||||||
result = sa.Column(PickleType, nullable=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
|
def __init__(self, taskset_id, result) -> None: # noqa: ANN001
|
||||||
self.taskset_id = taskset_id
|
self.taskset_id = taskset_id
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ import asyncio
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
import sqlalchemy as sa
|
||||||
JSON,
|
|
||||||
Boolean,
|
from sqlalchemy import event
|
||||||
String,
|
|
||||||
event,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
|
||||||
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from backend.common.exception import errors
|
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.core.conf import settings
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
@@ -25,37 +20,25 @@ class TaskScheduler(Base):
|
|||||||
__tablename__ = 'task_scheduler'
|
__tablename__ = 'task_scheduler'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
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='任务名称')
|
||||||
task: Mapped[str] = mapped_column(String(255), comment='要运行的 Celery 任务')
|
task: Mapped[str] = mapped_column(sa.String(256), comment='要运行的 Celery 任务')
|
||||||
args: Mapped[str | None] = mapped_column(JSON(), comment='任务可接收的位置参数')
|
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的位置参数')
|
||||||
kwargs: Mapped[str | None] = mapped_column(JSON(), comment='任务可接收的关键字参数')
|
kwargs: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的关键字参数')
|
||||||
queue: Mapped[str | None] = mapped_column(String(255), comment='CELERY_TASK_QUEUES 中定义的队列')
|
queue: Mapped[str | None] = mapped_column(sa.String(256), comment='CELERY_TASK_QUEUES 中定义的队列')
|
||||||
exchange: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的交换机')
|
exchange: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的交换机')
|
||||||
routing_key: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的路由密钥')
|
routing_key: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的路由密钥')
|
||||||
start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
|
start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
|
||||||
expire_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='任务不再触发的秒数时间差')
|
expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差')
|
||||||
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
|
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
|
||||||
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
|
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
|
||||||
interval_period: Mapped[str | None] = mapped_column(String(255), comment='任务运行之间的周期类型')
|
interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型')
|
||||||
crontab: Mapped[str | None] = mapped_column(String(50), default='* * * * *', comment='任务运行的 Crontab 计划')
|
crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='任务运行的 Crontab 计划')
|
||||||
one_off: Mapped[bool] = mapped_column(
|
one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次')
|
||||||
Boolean().with_variant(INTEGER, 'postgresql'),
|
enabled: Mapped[bool] = mapped_column(default=True, comment='是否启用任务')
|
||||||
default=False,
|
|
||||||
comment='是否仅运行一次',
|
|
||||||
)
|
|
||||||
enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean().with_variant(INTEGER, 'postgresql'),
|
|
||||||
default=True,
|
|
||||||
comment='是否启用任务',
|
|
||||||
)
|
|
||||||
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
|
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
|
||||||
last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
|
last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
|
||||||
remark: Mapped[str | None] = mapped_column(
|
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
|
||||||
default=None,
|
|
||||||
comment='备注',
|
|
||||||
)
|
|
||||||
|
|
||||||
no_changes: bool = False
|
no_changes: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -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.login_log_service import login_log_service
|
||||||
from backend.app.admin.service.opera_log_service import opera_log_service
|
from backend.app.admin.service.opera_log_service import opera_log_service
|
||||||
|
from backend.database.db import async_db_session
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
async def delete_db_opera_log() -> str:
|
async def delete_db_opera_log() -> str:
|
||||||
"""自动删除数据库操作日志"""
|
"""自动删除数据库操作日志"""
|
||||||
await opera_log_service.delete_all()
|
async with async_db_session.begin() as db:
|
||||||
return 'Success'
|
await opera_log_service.delete_all(db=db)
|
||||||
|
return 'Success'
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
async def delete_db_login_log() -> str:
|
async def delete_db_login_log() -> str:
|
||||||
"""自动删除数据库登录日志"""
|
"""自动删除数据库登录日志"""
|
||||||
await login_log_service.delete_all()
|
async with async_db_session.begin() as db:
|
||||||
return 'Success'
|
await login_log_service.delete_all(db=db)
|
||||||
|
return 'Success'
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ class DatabaseScheduler(Scheduler):
|
|||||||
"""获取所有任务调度"""
|
"""获取所有任务调度"""
|
||||||
async with async_db_session() as db:
|
async with async_db_session() as db:
|
||||||
logger.debug('DatabaseScheduler: Fetching database schedule')
|
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)
|
query = await db.execute(stmt)
|
||||||
schedulers = query.scalars().all()
|
schedulers = query.scalars().all()
|
||||||
s = {}
|
s = {}
|
||||||
|
|||||||
@@ -103,14 +103,6 @@ class StatusType(IntEnum):
|
|||||||
enable = 1
|
enable = 1
|
||||||
|
|
||||||
|
|
||||||
class UserSocialType(StrEnum):
|
|
||||||
"""用户社交类型"""
|
|
||||||
|
|
||||||
github = 'GitHub'
|
|
||||||
google = 'Google'
|
|
||||||
linux_do = 'LinuxDo'
|
|
||||||
|
|
||||||
|
|
||||||
class FileType(StrEnum):
|
class FileType(StrEnum):
|
||||||
"""文件类型"""
|
"""文件类型"""
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -1,10 +1,12 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
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.ext.asyncio import AsyncAttrs
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
|
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.snowflake import snowflake
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
@@ -41,16 +43,21 @@ snowflake_id_key = Annotated[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
|
class UniversalText(TypeDecorator[str]):
|
||||||
class UserMixin(MappedAsDataclass):
|
"""PostgreSQL、MySQL 兼容性(长)文本类型"""
|
||||||
"""用户 Mixin 数据类"""
|
|
||||||
|
|
||||||
created_by: Mapped[int] = mapped_column(sort_order=998, comment='创建者')
|
impl = LONGTEXT if settings.DATABASE_TYPE == 'mysql' else Text
|
||||||
updated_by: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者')
|
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]):
|
class TimeZone(TypeDecorator[datetime]):
|
||||||
"""时区感知 DateTime"""
|
"""PostgreSQL、MySQL 兼容性时区感知类型"""
|
||||||
|
|
||||||
impl = DateTime(timezone=True)
|
impl = DateTime(timezone=True)
|
||||||
cache_ok = True
|
cache_ok = True
|
||||||
@@ -71,6 +78,14 @@ class TimeZone(TypeDecorator[datetime]):
|
|||||||
return value
|
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):
|
class DateTimeMixin(MappedAsDataclass):
|
||||||
"""日期时间 Mixin 数据类"""
|
"""日期时间 Mixin 数据类"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
|
||||||
|
|
||||||
@@ -27,3 +27,9 @@ class SchemaBase(BaseModel):
|
|||||||
else timezone.to_str(x),
|
else timezone.to_str(x),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ser_string(value: Any) -> str | None:
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return value
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from backend.common.exception.errors import TokenError
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import async_db_session
|
from backend.database.db import async_db_session
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.utils.serializers import select_as_dict
|
|
||||||
from backend.utils.timezone import timezone
|
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
|
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:
|
if not user:
|
||||||
raise errors.TokenError(msg='Token 无效')
|
raise errors.TokenError(msg='Token 无效')
|
||||||
if not user.status:
|
if not user.status:
|
||||||
@@ -297,7 +296,7 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
|
|||||||
if not cache_user:
|
if not cache_user:
|
||||||
async with async_db_session() as db:
|
async with async_db_session() as db:
|
||||||
current_user = await get_current_user(db, user_id)
|
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(
|
await redis_client.setex(
|
||||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
||||||
settings.TOKEN_EXPIRE_SECONDS,
|
settings.TOKEN_EXPIRE_SECONDS,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from sqlalchemy import ColumnElement, and_, or_
|
from sqlalchemy import ColumnElement, and_, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from backend.app.admin.crud.crud_data_scope import data_scope_dao
|
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||||
from backend.common.context import ctx
|
from backend.common.context import ctx
|
||||||
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
@@ -42,54 +41,43 @@ class RequestPermission:
|
|||||||
ctx.permission = self.value
|
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_user: 请求用户
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
# 是否过滤数据权限
|
# 是否过滤数据权限
|
||||||
if request.user.is_superuser:
|
if request_user.is_superuser:
|
||||||
return or_(1 == 1)
|
return or_(1 == 1)
|
||||||
|
|
||||||
for role in request.user.roles:
|
for role in request_user.roles:
|
||||||
if not role.is_filter_scopes:
|
if not role.is_filter_scopes:
|
||||||
return or_(1 == 1)
|
return or_(1 == 1)
|
||||||
|
|
||||||
# 获取数据范围
|
# 获取数据规则
|
||||||
data_scope_ids = set()
|
data_rules = set()
|
||||||
for role in request.user.roles:
|
for role in request_user.roles:
|
||||||
for scope in role.scopes:
|
for scope in role.scopes:
|
||||||
if scope.status:
|
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)
|
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_and_list = []
|
||||||
where_or_list = []
|
where_or_list = []
|
||||||
|
|
||||||
for data_rule in data_rule_list:
|
for data_rule in list(data_rules):
|
||||||
# 验证规则模型
|
# 验证规则模型
|
||||||
rule_model = data_rule.model
|
rule_model = data_rule.model
|
||||||
if rule_model not in settings.DATA_PERMISSION_MODELS:
|
if rule_model not in settings.DATA_PERMISSION_MODELS:
|
||||||
raise errors.NotFoundError(msg='数据规则模型不存在')
|
raise errors.NotFoundError(msg='数据规则可用模型不存在')
|
||||||
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
|
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
|
||||||
|
|
||||||
# 验证规则列
|
# 验证规则列
|
||||||
@@ -98,7 +86,7 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl
|
|||||||
]
|
]
|
||||||
column = data_rule.column
|
column = data_rule.column
|
||||||
if column not in model_columns:
|
if column not in model_columns:
|
||||||
raise errors.NotFoundError(msg='数据规则模型列不存在')
|
raise errors.NotFoundError(msg='数据规则可用模型列不存在')
|
||||||
|
|
||||||
# 构建过滤条件
|
# 构建过滤条件
|
||||||
column_obj = getattr(model_ins, column)
|
column_obj = getattr(model_ins, column)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# 数据权限
|
# 数据权限
|
||||||
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
|
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 模型列
|
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
|
||||||
'id',
|
'id',
|
||||||
@@ -173,6 +173,7 @@ class Settings(BaseSettings):
|
|||||||
'/openapi',
|
'/openapi',
|
||||||
f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
|
f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
|
||||||
f'{FASTAPI_API_V1_PATH}/oauth2/github/callback',
|
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',
|
f'{FASTAPI_API_V1_PATH}/oauth2/linux-do/callback',
|
||||||
]
|
]
|
||||||
OPERA_LOG_ENCRYPT_TYPE: int = 1 # 0: AES (性能损耗); 1: md5; 2: ItsDangerous; 3: 不加密, others: 替换为 ******
|
OPERA_LOG_ENCRYPT_TYPE: int = 1 # 0: AES (性能损耗); 1: md5; 2: ItsDangerous; 3: 不加密, others: 替换为 ******
|
||||||
@@ -188,6 +189,7 @@ class Settings(BaseSettings):
|
|||||||
# Plugin 配置
|
# Plugin 配置
|
||||||
PLUGIN_PIP_CHINA: bool = True
|
PLUGIN_PIP_CHINA: bool = True
|
||||||
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
|
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
|
||||||
|
PLUGIN_PIP_MAX_RETRY: int = 3
|
||||||
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
|
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
|
||||||
|
|
||||||
# I18n 配置
|
# I18n 配置
|
||||||
@@ -208,6 +210,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# 基础配置
|
# 基础配置
|
||||||
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
|
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
|
||||||
|
CELERY_RABBITMQ_VHOST: str = ''
|
||||||
CELERY_REDIS_PREFIX: str = 'fba:celery'
|
CELERY_REDIS_PREFIX: str = 'fba:celery'
|
||||||
CELERY_TASK_MAX_RETRIES: int = 5
|
CELERY_TASK_MAX_RETRIES: int = 5
|
||||||
|
|
||||||
@@ -228,6 +231,9 @@ class Settings(BaseSettings):
|
|||||||
OAUTH2_LINUX_DO_CLIENT_SECRET: str
|
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'
|
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
|
||||||
|
|
||||||
##################################################
|
##################################################
|
||||||
|
|||||||
+26
-14
@@ -38,26 +38,38 @@ class RedisCli(Redis):
|
|||||||
log.error('❌ 数据库 redis 连接异常 {}', e)
|
log.error('❌ 数据库 redis 连接异常 {}', e)
|
||||||
sys.exit()
|
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
|
删除指定前缀的所有 key
|
||||||
|
|
||||||
:param prefix: 前缀
|
:param prefix: 要删除的键前缀
|
||||||
:param exclude: 排除的 key
|
:param exclude: 要排除的键或键列表
|
||||||
|
:param batch_size: 批量删除的大小,避免一次性删除过多键导致 Redis 阻塞
|
||||||
:return:
|
: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}*'):
|
async for key in self.scan_iter(match=f'{prefix}*'):
|
||||||
if isinstance(exclude, str):
|
if key not in exclude_set:
|
||||||
if key != exclude:
|
batch_keys.append(key)
|
||||||
keys.append(key)
|
|
||||||
elif isinstance(exclude, list):
|
if len(batch_keys) >= batch_size:
|
||||||
if key not in exclude:
|
await self.delete(*batch_keys)
|
||||||
keys.append(key)
|
batch_keys.clear()
|
||||||
else:
|
|
||||||
keys.append(key)
|
if batch_keys:
|
||||||
if keys:
|
await self.delete(*batch_keys)
|
||||||
await self.delete(*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 客户端单例
|
# 创建 redis 客户端单例
|
||||||
|
|||||||
@@ -32,12 +32,4 @@ class AccessMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
response = await call_next(request)
|
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
|
return response
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
error = None
|
error = None
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
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 [
|
for e in [
|
||||||
'__request_http_exception__',
|
'__request_http_exception__',
|
||||||
'__request_validation_exception__',
|
'__request_validation_exception__',
|
||||||
@@ -65,7 +65,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
log.error(f'请求异常: {msg}')
|
log.error(f'请求异常: {msg}')
|
||||||
break
|
break
|
||||||
except Exception as e:
|
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 异常用法
|
code = getattr(e, 'code', StandardResponseCode.HTTP_500) # 兼容 SQLAlchemy 异常用法
|
||||||
msg = getattr(e, 'msg', str(e)) # 不建议使用 traceback 模块获取错误信息,会暴漏代码信息
|
msg = getattr(e, 'msg', str(e)) # 不建议使用 traceback 模块获取错误信息,会暴漏代码信息
|
||||||
status = StatusType.disable
|
status = StatusType.disable
|
||||||
@@ -86,6 +86,9 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
log.debug(f'接口摘要:[{summary}]')
|
log.debug(f'接口摘要:[{summary}]')
|
||||||
log.debug(f'请求地址:[{ctx.ip}]')
|
log.debug(f'请求地址:[{ctx.ip}]')
|
||||||
log.debug(f'请求参数:{args}')
|
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(
|
opera_log_in = CreateOperaLogParam(
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
|||||||
if table_name is not None:
|
if table_name is not None:
|
||||||
filters['table_name__like'] = f'%{table_name}%'
|
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:
|
async def create(self, db: AsyncSession, obj: CreateGenBusinessParam) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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 backend.common.model import Base, UniversalText, id_key
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class GenBusiness(Base):
|
class GenBusiness(Base):
|
||||||
@@ -19,21 +11,19 @@ class GenBusiness(Base):
|
|||||||
__tablename__ = 'gen_business'
|
__tablename__ = 'gen_business'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
app_name: Mapped[str] = mapped_column(String(50), comment='应用名称(英文)')
|
app_name: Mapped[str] = mapped_column(sa.String(64), comment='应用名称(英文)')
|
||||||
table_name: Mapped[str] = mapped_column(String(255), unique=True, comment='表名称(英文)')
|
table_name: Mapped[str] = mapped_column(sa.String(256), unique=True, comment='表名称(英文)')
|
||||||
doc_comment: Mapped[str] = mapped_column(String(255), comment='文档注释(用于函数/参数文档)')
|
doc_comment: Mapped[str] = mapped_column(sa.String(256), comment='文档注释(用于函数/参数文档)')
|
||||||
table_comment: Mapped[str | None] = mapped_column(String(255), default=None, 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='关联表外键')
|
# relate_model_fk: Mapped[int | None] = mapped_column(default=None, comment='关联表外键')
|
||||||
class_name: Mapped[str | None] = mapped_column(String(50), default=None, comment='基础类名(默认为英文表名称)')
|
class_name: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='基础类名(默认为英文表名称)')
|
||||||
schema_name: Mapped[str | None] = mapped_column(String(50), default=None, comment='Schema 名称 (默认为英文表名称)')
|
schema_name: Mapped[str | None] = mapped_column(
|
||||||
filename: Mapped[str | None] = mapped_column(String(50), default=None, comment='基础文件名(默认为英文表名称)')
|
sa.String(64), default=None, comment='Schema 名称 (默认为英文表名称)'
|
||||||
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='备注',
|
|
||||||
)
|
)
|
||||||
# 代码生成业务模型列一对多
|
filename: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='基础文件名(默认为英文表名称)')
|
||||||
gen_column: Mapped[list[GenColumn]] = relationship(init=False, back_populates='gen_business')
|
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='备注')
|
||||||
|
|||||||
@@ -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 backend.common.model import DataClassBase, UniversalText, id_key
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class GenColumn(DataClassBase):
|
class GenColumn(DataClassBase):
|
||||||
@@ -19,25 +11,15 @@ class GenColumn(DataClassBase):
|
|||||||
__tablename__ = 'gen_column'
|
__tablename__ = 'gen_column'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
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='列名称')
|
||||||
comment: Mapped[str | None] = mapped_column(String(255), default=None, comment='列描述')
|
comment: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='列描述')
|
||||||
type: Mapped[str] = mapped_column(String(20), default='String', comment='SQLA 模型列类型')
|
type: Mapped[str] = mapped_column(sa.String(32), default='String', comment='SQLA 模型列类型')
|
||||||
pd_type: Mapped[str] = mapped_column(String(20), default='str', comment='列类型对应的 pydantic 类型')
|
pd_type: Mapped[str] = mapped_column(sa.String(32), default='str', comment='列类型对应的 pydantic 类型')
|
||||||
default: Mapped[str | None] = mapped_column(
|
default: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='列默认值')
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
|
||||||
default=None,
|
|
||||||
comment='列默认值',
|
|
||||||
)
|
|
||||||
sort: Mapped[int | None] = mapped_column(default=1, comment='列排序')
|
sort: Mapped[int | None] = mapped_column(default=1, comment='列排序')
|
||||||
length: Mapped[int] = mapped_column(default=0, comment='列长度')
|
length: Mapped[int] = mapped_column(default=0, comment='列长度')
|
||||||
is_pk: Mapped[bool] = mapped_column(default=False, comment='是否主键')
|
is_pk: Mapped[bool] = mapped_column(default=False, comment='是否主键')
|
||||||
is_nullable: Mapped[bool] = mapped_column(default=False, comment='是否可为空')
|
is_nullable: Mapped[bool] = mapped_column(default=False, comment='是否可为空')
|
||||||
|
|
||||||
# 代码生成业务模型列一对多
|
# 逻辑外键
|
||||||
gen_business_id: Mapped[int] = mapped_column(
|
gen_business_id: Mapped[int] = mapped_column(sa.BigInteger, default=0, comment='代码生成业务ID')
|
||||||
BigInteger,
|
|
||||||
ForeignKey('gen_business.id', ondelete='CASCADE'),
|
|
||||||
default=0,
|
|
||||||
comment='代码生成业务ID',
|
|
||||||
)
|
|
||||||
gen_business: Mapped[GenBusiness | None] = relationship(init=False, back_populates='gen_column')
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[plugin]
|
[plugin]
|
||||||
summary = '代码生成'
|
summary = '代码生成'
|
||||||
version = '0.0.5'
|
version = '0.0.6'
|
||||||
description = '生成通用业务代码'
|
description = '生成通用业务代码'
|
||||||
author = 'wu-clan'
|
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.permission import RequestPermission
|
||||||
from backend.common.security.rbac import DependsRBAC
|
from backend.common.security.rbac import DependsRBAC
|
||||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||||
from backend.database.db import CurrentSession
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from sqlalchemy import Boolean, String
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
|
||||||
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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):
|
class Config(Base):
|
||||||
@@ -12,17 +11,9 @@ class Config(Base):
|
|||||||
__tablename__ = 'sys_config'
|
__tablename__ = 'sys_config'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
name: Mapped[str] = mapped_column(String(20), comment='名称')
|
name: Mapped[str] = mapped_column(sa.String(32), comment='名称')
|
||||||
type: Mapped[str | None] = mapped_column(String(20), server_default=None, comment='类型')
|
type: Mapped[str | None] = mapped_column(sa.String(32), server_default=None, comment='类型')
|
||||||
key: Mapped[str] = mapped_column(String(50), unique=True, comment='键名')
|
key: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='键名')
|
||||||
value: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='键值')
|
value: Mapped[str] = mapped_column(UniversalText, comment='键值')
|
||||||
is_frontend: Mapped[bool] = mapped_column(
|
is_frontend: Mapped[bool] = mapped_column(default=False, comment='是否前端')
|
||||||
Boolean().with_variant(INTEGER, 'postgresql'),
|
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||||
default=False,
|
|
||||||
comment='是否前端',
|
|
||||||
)
|
|
||||||
remark: Mapped[str | None] = mapped_column(
|
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
|
||||||
default=None,
|
|
||||||
comment='备注',
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
|
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
values
|
||||||
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
|
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
|
||||||
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
|
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
|
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
|
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
|
||||||
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
|
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
|
||||||
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, 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)
|
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
values
|
||||||
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
|
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
|
||||||
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
|
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
|
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
|
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
|
||||||
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
|
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
|
||||||
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, 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)
|
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
values
|
||||||
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
|
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
|
||||||
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
|
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
|
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
|
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
|
||||||
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
|
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
|
||||||
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, 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;
|
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)
|
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
values
|
||||||
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 0, null, now(), null),
|
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
|
||||||
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', 0, null, now(), null),
|
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', 0, null, now(), null),
|
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 0, null, now(), null),
|
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
|
||||||
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', 0, null, now(), null),
|
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
|
||||||
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', 0, null, now(), null);
|
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
|||||||
:param pk: 字典数据 ID
|
:param pk: 字典数据 ID
|
||||||
:return:
|
: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]:
|
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_columns='sort',
|
||||||
sort_orders='desc',
|
sort_orders='desc',
|
||||||
type_code=type_code,
|
type_code=type_code,
|
||||||
load_strategies={'type': 'noload'},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_all(self, db: AsyncSession) -> Sequence[DictData]:
|
async def get_all(self, db: AsyncSession) -> Sequence[DictData]:
|
||||||
@@ -44,7 +43,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
|||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
return await self.select_models(db, load_strategies={'type': 'noload'})
|
return await self.select_models(db)
|
||||||
|
|
||||||
async def get_select(
|
async def get_select(
|
||||||
self,
|
self,
|
||||||
@@ -77,7 +76,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
|||||||
if type_id is not None:
|
if type_id is not None:
|
||||||
filters['type_id'] = type_id
|
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:
|
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)
|
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 db: 数据库会话
|
||||||
:param pk: 字典数据 ID
|
:param type_ids: 字典类型 ID 列表
|
||||||
:return:
|
: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)
|
dict_data_dao: CRUDDictData = CRUDDictData(DictData)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import Select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy_crud_plus import CRUDPlus
|
from sqlalchemy_crud_plus import CRUDPlus
|
||||||
|
|
||||||
|
from backend.plugin.dict.crud.crud_dict_data import dict_data_dao
|
||||||
from backend.plugin.dict.model import DictType
|
from backend.plugin.dict.model import DictType
|
||||||
from backend.plugin.dict.schema.dict_type import CreateDictTypeParam, UpdateDictTypeParam
|
from backend.plugin.dict.schema.dict_type import CreateDictTypeParam, UpdateDictTypeParam
|
||||||
|
|
||||||
@@ -28,9 +29,9 @@ class CRUDDictType(CRUDPlus[DictType]):
|
|||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:return:
|
: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:
|
if code is not None:
|
||||||
filters['code__like'] = f'%{code}%'
|
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:
|
async def get_by_code(self, db: AsyncSession, code: str) -> DictType | None:
|
||||||
"""
|
"""
|
||||||
@@ -86,6 +87,7 @@ class CRUDDictType(CRUDPlus[DictType]):
|
|||||||
:param pks: 字典类型 ID 列表
|
:param pks: 字典类型 ID 列表
|
||||||
:return:
|
: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)
|
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 backend.common.model import Base, UniversalText, id_key
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class DictData(Base):
|
class DictData(Base):
|
||||||
@@ -19,22 +11,13 @@ class DictData(Base):
|
|||||||
__tablename__ = 'sys_dict_data'
|
__tablename__ = 'sys_dict_data'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
type_code: Mapped[str] = mapped_column(String(32), comment='对应的字典类型编码')
|
type_code: Mapped[str] = mapped_column(sa.String(32), comment='对应的字典类型编码')
|
||||||
label: Mapped[str] = mapped_column(String(32), comment='字典标签')
|
label: Mapped[str] = mapped_column(sa.String(32), comment='字典标签')
|
||||||
value: Mapped[str] = mapped_column(String(32), comment='字典值')
|
value: Mapped[str] = mapped_column(sa.String(32), comment='字典值')
|
||||||
color: Mapped[str | None] = mapped_column(String(32), default=None, comment='标签颜色')
|
color: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='标签颜色')
|
||||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||||
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
|
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
|
||||||
remark: Mapped[str | None] = mapped_column(
|
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
|
||||||
default=None,
|
|
||||||
comment='备注',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 字典类型一对多
|
# 逻辑外键
|
||||||
type_id: Mapped[int] = mapped_column(
|
type_id: Mapped[int] = mapped_column(sa.BigInteger, default=0, comment='字典类型关联ID')
|
||||||
ForeignKey('sys_dict_type.id', ondelete='CASCADE'),
|
|
||||||
default=0,
|
|
||||||
comment='字典类型关联ID',
|
|
||||||
)
|
|
||||||
type: Mapped[DictType] = relationship(init=False, back_populates='datas')
|
|
||||||
|
|||||||
@@ -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 backend.common.model import Base, UniversalText, id_key
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class DictType(Base):
|
class DictType(Base):
|
||||||
@@ -19,13 +11,6 @@ class DictType(Base):
|
|||||||
__tablename__ = 'sys_dict_type'
|
__tablename__ = 'sys_dict_type'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
name: Mapped[str] = mapped_column(String(32), comment='字典类型名称')
|
name: Mapped[str] = mapped_column(sa.String(32), comment='字典类型名称')
|
||||||
code: Mapped[str] = mapped_column(String(32), unique=True, comment='字典类型编码')
|
code: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='字典类型编码')
|
||||||
remark: Mapped[str | None] = mapped_column(
|
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||||
LONGTEXT().with_variant(TEXT, 'postgresql'),
|
|
||||||
default=None,
|
|
||||||
comment='备注',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 字典类型一对多
|
|
||||||
datas: Mapped[list[DictData]] = relationship(init=False, back_populates='type')
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[plugin]
|
[plugin]
|
||||||
summary = '数据字典'
|
summary = '数据字典'
|
||||||
version = '0.0.7'
|
version = '0.0.8'
|
||||||
description = '通常用于约束前端工程数据展示'
|
description = '通常用于约束前端工程数据展示'
|
||||||
author = 'wu-clan'
|
author = 'wu-clan'
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,5 @@ values
|
|||||||
(35, 'sys_plugin_type', '压缩包', '0', 'gold', 1, 1, '插件类型-压缩包', 12, now(), null),
|
(35, 'sys_plugin_type', '压缩包', '0', 'gold', 1, 1, '插件类型-压缩包', 12, now(), null),
|
||||||
(36, 'sys_plugin_type', 'GIT', '1', 'processing', 2, 1, '插件类型-GIT', 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_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;
|
select setval(pg_get_serial_sequence('sys_dict_data', 'id'),coalesce(max(id), 0) + 1, true) from sys_dict_data;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import TEXT, String
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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):
|
class Notice(Base):
|
||||||
@@ -11,7 +11,7 @@ class Notice(Base):
|
|||||||
__tablename__ = 'sys_notice'
|
__tablename__ = 'sys_notice'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
title: Mapped[str] = mapped_column(String(50), comment='标题')
|
title: Mapped[str] = mapped_column(sa.String(64), comment='标题')
|
||||||
type: Mapped[int] = mapped_column(comment='类型(0:通知、1:公告)')
|
type: Mapped[int] = mapped_column(comment='类型(0:通知、1:公告)')
|
||||||
status: 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='内容')
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from typing import Annotated
|
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_limiter.depends import RateLimiter
|
||||||
from fastapi_oauth20 import FastAPIOAuth20, GitHubOAuth20
|
from fastapi_oauth20 import FastAPIOAuth20, GitHubOAuth20
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from backend.common.enums import UserSocialType
|
|
||||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import CurrentSessionTransaction
|
from backend.database.db import CurrentSessionTransaction
|
||||||
|
from backend.plugin.oauth2.enums import UserSocialType
|
||||||
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
|
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -17,8 +17,8 @@ github_client = GitHubOAuth20(settings.OAUTH2_GITHUB_CLIENT_ID, settings.OAUTH2_
|
|||||||
|
|
||||||
|
|
||||||
@router.get('', summary='获取 Github 授权链接')
|
@router.get('', summary='获取 Github 授权链接')
|
||||||
async def get_github_oauth2_url(request: Request) -> ResponseSchemaModel[str]:
|
async def get_github_oauth2_url() -> ResponseSchemaModel[str]:
|
||||||
auth_url = await github_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
|
auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)
|
||||||
return response_base.success(data=auth_url)
|
return response_base.success(data=auth_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ async def github_oauth2_callback( # noqa: ANN201
|
|||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
oauth2: Annotated[
|
oauth2: Annotated[
|
||||||
FastAPIOAuth20,
|
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
|
token_data, _state = oauth2
|
||||||
access_token = token['access_token']
|
access_token = token_data['access_token']
|
||||||
user = await github_client.get_userinfo(access_token)
|
user = await github_client.get_userinfo(access_token)
|
||||||
data = await oauth2_service.create_with_login(
|
data = await oauth2_service.create_with_login(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from typing import Annotated
|
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_limiter.depends import RateLimiter
|
||||||
from fastapi_oauth20 import FastAPIOAuth20, GoogleOAuth20
|
from fastapi_oauth20 import FastAPIOAuth20, GoogleOAuth20
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from backend.common.enums import UserSocialType
|
|
||||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import CurrentSessionTransaction
|
from backend.database.db import CurrentSessionTransaction
|
||||||
|
from backend.plugin.oauth2.enums import UserSocialType
|
||||||
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
|
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -17,8 +17,8 @@ google_client = GoogleOAuth20(settings.OAUTH2_GOOGLE_CLIENT_ID, settings.OAUTH2_
|
|||||||
|
|
||||||
|
|
||||||
@router.get('', summary='获取 google 授权链接')
|
@router.get('', summary='获取 google 授权链接')
|
||||||
async def get_google_oauth2_url(request: Request) -> ResponseSchemaModel[str]:
|
async def get_google_oauth2_url() -> ResponseSchemaModel[str]:
|
||||||
auth_url = await google_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
|
auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI)
|
||||||
return response_base.success(data=auth_url)
|
return response_base.success(data=auth_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ async def google_oauth2_callback( # noqa: ANN201
|
|||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
oauth2: Annotated[
|
oauth2: Annotated[
|
||||||
FastAPIOAuth20,
|
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
|
token_data, _state = oauth2
|
||||||
access_token = token['access_token']
|
access_token = token_data['access_token']
|
||||||
user = await google_client.get_userinfo(access_token)
|
user = await google_client.get_userinfo(access_token)
|
||||||
data = await oauth2_service.create_with_login(
|
data = await oauth2_service.create_with_login(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from typing import Annotated
|
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_limiter.depends import RateLimiter
|
||||||
from fastapi_oauth20 import FastAPIOAuth20, LinuxDoOAuth20
|
from fastapi_oauth20 import FastAPIOAuth20, LinuxDoOAuth20
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from backend.common.enums import UserSocialType
|
|
||||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import CurrentSessionTransaction
|
from backend.database.db import CurrentSessionTransaction
|
||||||
|
from backend.plugin.oauth2.enums import UserSocialType
|
||||||
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
|
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -17,8 +17,8 @@ linux_do_client = LinuxDoOAuth20(settings.OAUTH2_LINUX_DO_CLIENT_ID, settings.OA
|
|||||||
|
|
||||||
|
|
||||||
@router.get('', summary='获取 LinuxDo 授权链接')
|
@router.get('', summary='获取 LinuxDo 授权链接')
|
||||||
async def get_linux_do_oauth2_url(request: Request) -> ResponseSchemaModel[str]:
|
async def get_linux_do_oauth2_url() -> ResponseSchemaModel[str]:
|
||||||
auth_url = await linux_do_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
|
auth_url = await linux_do_client.get_authorization_url(redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)
|
||||||
return response_base.success(data=auth_url)
|
return response_base.success(data=auth_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ async def linux_do_oauth2_callback( # noqa: ANN201
|
|||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
oauth2: Annotated[
|
oauth2: Annotated[
|
||||||
FastAPIOAuth20,
|
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
|
token_data, _state = oauth2
|
||||||
access_token = token['access_token']
|
access_token = token_data['access_token']
|
||||||
user = await linux_do_client.get_userinfo(access_token)
|
user = await linux_do_client.get_userinfo(access_token)
|
||||||
data = await oauth2_service.create_with_login(
|
data = await oauth2_service.create_with_login(
|
||||||
db=db,
|
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()
|
||||||
@@ -8,23 +8,23 @@ from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
|
|||||||
class CRUDUserSocial(CRUDPlus[UserSocial]):
|
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 db: 数据库会话
|
||||||
:param pk: 用户 ID
|
:param user_id: 用户 ID
|
||||||
:param source: 社交账号类型
|
:param source: 社交账号类型
|
||||||
:return:
|
: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:
|
async def get_by_sid(self, db: AsyncSession, sid: str, source: str) -> UserSocial | None:
|
||||||
"""
|
"""
|
||||||
通过 UUID 获取社交用户
|
通过 sid 获取社交用户
|
||||||
|
|
||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:param sid: 第三方 UUID
|
:param sid: 第三方用户唯一编码
|
||||||
:param source: 社交账号类型
|
:param source: 社交账号类型
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
@@ -40,15 +40,26 @@ class CRUDUserSocial(CRUDPlus[UserSocial]):
|
|||||||
"""
|
"""
|
||||||
await self.create_model(db, obj)
|
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 db: 数据库会话
|
||||||
:param social_id: 社交账号绑定 ID
|
:param user_id: 用户 ID
|
||||||
|
:param source: 社交账号类型
|
||||||
:return:
|
: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)
|
user_social_dao: CRUDUserSocial = CRUDUserSocial(UserSocial)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from backend.common.enums import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class UserSocialType(StrEnum):
|
||||||
|
"""用户社交类型"""
|
||||||
|
|
||||||
|
github = 'GitHub'
|
||||||
|
google = 'Google'
|
||||||
|
linux_do = 'LinuxDo'
|
||||||
@@ -1,15 +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 BigInteger, ForeignKey, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from backend.common.model import Base, id_key
|
from backend.common.model import Base, id_key
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.app.admin.model import User
|
|
||||||
|
|
||||||
|
|
||||||
class UserSocial(Base):
|
class UserSocial(Base):
|
||||||
"""用户社交表(OAuth2)"""
|
"""用户社交表(OAuth2)"""
|
||||||
@@ -17,13 +11,8 @@ class UserSocial(Base):
|
|||||||
__tablename__ = 'sys_user_social'
|
__tablename__ = 'sys_user_social'
|
||||||
|
|
||||||
id: Mapped[id_key] = mapped_column(init=False)
|
id: Mapped[id_key] = mapped_column(init=False)
|
||||||
sid: Mapped[str] = mapped_column(String(255), comment='第三方用户 ID')
|
sid: Mapped[str] = mapped_column(sa.String(256), comment='第三方用户 ID')
|
||||||
source: Mapped[str] = mapped_column(String(20), comment='第三方用户来源')
|
source: Mapped[str] = mapped_column(sa.String(32), comment='第三方用户来源')
|
||||||
|
|
||||||
# 用户社交信息一对多
|
# 逻辑外键
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(sa.BigInteger, comment='用户关联ID')
|
||||||
BigInteger,
|
|
||||||
ForeignKey('sys_user.id', ondelete='CASCADE'),
|
|
||||||
comment='用户关联ID',
|
|
||||||
)
|
|
||||||
user: Mapped[User | None] = relationship(init=False, backref='socials')
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[plugin]
|
[plugin]
|
||||||
summary = 'OAuth 2.0'
|
summary = 'OAuth 2.0'
|
||||||
version = '0.0.5'
|
version = '0.0.9'
|
||||||
description = '通过 OAuth 2.0 的方式登录系统'
|
description = '通过 OAuth 2.0 的方式登录系统'
|
||||||
author = 'wu-clan'
|
author = 'wu-clan'
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fastapi-oauth20>=0.0.1
|
fastapi-oauth20>=0.0.2
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from backend.common.enums import UserSocialType
|
|
||||||
from backend.common.schema import SchemaBase
|
from backend.common.schema import SchemaBase
|
||||||
|
from backend.plugin.oauth2.enums import UserSocialType
|
||||||
|
|
||||||
|
|
||||||
class UserSocialSchemaBase(SchemaBase):
|
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.schema.user import AddOAuth2UserParam
|
||||||
from backend.app.admin.service.login_log_service import login_log_service
|
from backend.app.admin.service.login_log_service import login_log_service
|
||||||
from backend.common.context import ctx
|
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.i18n import t
|
||||||
from backend.common.security import jwt
|
from backend.common.security import jwt
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
|
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.plugin.oauth2.schema.user_social import CreateUserSocialParam
|
||||||
from backend.utils.timezone import timezone
|
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
@@ -142,14 +142,7 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|||||||
raise PluginConfigError(f'插件 {plugin} 配置文件缺少必要字段: {", ".join(missing_fields)}')
|
raise PluginConfigError(f'插件 {plugin} 配置文件缺少必要字段: {", ".join(missing_fields)}')
|
||||||
|
|
||||||
if data.get('api'):
|
if data.get('api'):
|
||||||
# TODO: 删除过时的 include 配置
|
if not data.get('app', {}).get('extend'):
|
||||||
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'):
|
|
||||||
raise PluginConfigError(f'扩展级插件 {plugin} 配置文件缺少 app.extend 配置')
|
raise PluginConfigError(f'扩展级插件 {plugin} 配置文件缺少 app.extend 配置')
|
||||||
extend_plugins.append(data)
|
extend_plugins.append(data)
|
||||||
else:
|
else:
|
||||||
@@ -219,8 +212,7 @@ def inject_extend_router(plugin: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
# 获取目标 app 路由
|
# 获取目标 app 路由
|
||||||
relative_path = os.path.relpath(root, plugin_api_path)
|
relative_path = os.path.relpath(root, plugin_api_path)
|
||||||
# TODO: 删除过时的 include 配置
|
app_name = plugin.get('app', {}).get('extend')
|
||||||
app_name = plugin.get('app', {}).get('include') or plugin.get('app', {}).get('extend')
|
|
||||||
target_module_path = f'backend.app.{app_name}.api.{relative_path.replace(os.sep, ".")}'
|
target_module_path = f'backend.app.{app_name}.api.{relative_path.replace(os.sep, ".")}'
|
||||||
target_module = import_module_cached(target_module_path)
|
target_module = import_module_cached(target_module_path)
|
||||||
target_router = getattr(target_module, 'router', None)
|
target_router = getattr(target_module, 'router', None)
|
||||||
@@ -286,7 +278,62 @@ def build_final_router() -> APIRouter:
|
|||||||
return main_router
|
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:
|
if missing_dependencies:
|
||||||
try:
|
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]
|
pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file]
|
||||||
if settings.PLUGIN_PIP_CHINA:
|
if settings.PLUGIN_PIP_CHINA:
|
||||||
pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
|
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:
|
except subprocess.CalledProcessError as e:
|
||||||
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
|
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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)
|
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)
|
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
|
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);
|
(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)
|
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)
|
insert into sys_role_menu (id, role_id, menu_id)
|
||||||
values
|
values
|
||||||
@@ -91,10 +91,10 @@ values
|
|||||||
(2048601263708438528, 2048601263515500544, 2049629108245233666),
|
(2048601263708438528, 2048601263515500544, 2049629108245233666),
|
||||||
(2048601263775547392, 2048601263515500544, 2049629108253622282);
|
(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
|
values
|
||||||
(2048601263834267648, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, 1, 1, 1, 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', 0, 0, 1, 0, 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)
|
insert into sys_user_role (id, user_id, role_id)
|
||||||
values
|
values
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time)
|
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)
|
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
|
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);
|
(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)
|
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)
|
insert into sys_role_menu (id, role_id, menu_id)
|
||||||
values
|
values
|
||||||
@@ -91,10 +91,10 @@ values
|
|||||||
(3, 1, 3),
|
(3, 1, 3),
|
||||||
(4, 1, 53);
|
(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
|
values
|
||||||
(1, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, 1, 1, 1, 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', 0, 0, 1, 0, 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)
|
insert into sys_user_role (id, user_id, role_id)
|
||||||
values
|
values
|
||||||
@@ -111,6 +111,11 @@ values
|
|||||||
(1, '部门名称等于测试', '部门', 'name', 1, 0, '测试', now(), null),
|
(1, '部门名称等于测试', '部门', 'name', 1, 0, '测试', now(), null),
|
||||||
(2, '父部门 ID 等于 1', '部门', 'parent_id', 0, 0, '1', 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)
|
insert into sys_data_scope_rule (id, data_scope_id, data_rule_id)
|
||||||
values
|
values
|
||||||
(1, 1, 1),
|
(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)
|
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)
|
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
|
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);
|
(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)
|
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)
|
insert into sys_role_menu (id, role_id, menu_id)
|
||||||
values
|
values
|
||||||
@@ -91,10 +91,10 @@ values
|
|||||||
(2048601269546909696, 2048601269345583104, 2049629108245233666),
|
(2048601269546909696, 2048601269345583104, 2049629108245233666),
|
||||||
(2048601269609824256, 2048601269345583104, 2049629108253622282);
|
(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
|
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),
|
(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', 0, 0, 1, 0, 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)
|
insert into sys_user_role (id, user_id, role_id)
|
||||||
values
|
values
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time)
|
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)
|
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
|
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);
|
(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)
|
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)
|
insert into sys_role_menu (id, role_id, menu_id)
|
||||||
values
|
values
|
||||||
@@ -91,10 +91,10 @@ values
|
|||||||
(3, 1, 3),
|
(3, 1, 3),
|
||||||
(4, 1, 53);
|
(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
|
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),
|
(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', 0, 0, 1, 0, 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)
|
insert into sys_user_role (id, user_id, role_id)
|
||||||
values
|
values
|
||||||
@@ -111,13 +111,17 @@ values
|
|||||||
(1, '部门名称等于测试', '部门', 'name', 1, 0, '测试', now(), null),
|
(1, '部门名称等于测试', '部门', 'name', 1, 0, '测试', now(), null),
|
||||||
(2, '父部门 ID 等于 1', '部门', 'parent_id', 0, 0, '1', 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)
|
insert into sys_data_scope_rule (id, data_scope_id, data_rule_id)
|
||||||
values
|
values
|
||||||
(1, 1, 1),
|
(1, 1, 1),
|
||||||
(2, 2, 1),
|
(2, 2, 1),
|
||||||
(3, 2, 2);
|
(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_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_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;
|
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_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_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_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;
|
select setval(pg_get_serial_sequence('sys_data_scope_rule', 'id'),coalesce(max(id), 0) + 1, true) from sys_data_scope_rule;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from collections import defaultdict, namedtuple
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
@@ -8,11 +9,20 @@ from sqlalchemy import Row, RowMapping
|
|||||||
from sqlalchemy.orm import ColumnProperty, SynonymProperty, class_mapper
|
from sqlalchemy.orm import ColumnProperty, SynonymProperty, class_mapper
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
RowData = Row | RowMapping | Any
|
RowData = Row[Any] | RowMapping | Any
|
||||||
|
|
||||||
R = TypeVar('R', bound=RowData)
|
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]:
|
def select_columns_serialize(row: R) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
序列化 SQLAlchemy 查询表的列,不包含关联列
|
序列化 SQLAlchemy 查询表的列,不包含关联列
|
||||||
@@ -62,10 +72,305 @@ def select_as_dict(row: R, *, use_alias: bool = False) -> dict[str, Any]:
|
|||||||
return result
|
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 返回 namedtuple,True 返回 dict
|
||||||
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def render(self, content: Any) -> bytes:
|
def get_relation_key(model_name: str, rel_type: str, custom_field: str | None = None) -> str:
|
||||||
return json.encode(content)
|
"""获取关系键名"""
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Env
|
# Env
|
||||||
ENVIRONMENT='prod'
|
ENVIRONMENT='prod'
|
||||||
# Database
|
# Database
|
||||||
DATABASE_TYPE='mysql'
|
DATABASE_TYPE='postgresql'
|
||||||
DATABASE_HOST='fba_mysql'
|
DATABASE_HOST='fba_postgres'
|
||||||
DATABASE_PORT=3306
|
DATABASE_PORT=5432
|
||||||
DATABASE_USER='root'
|
DATABASE_USER='postgres'
|
||||||
DATABASE_PASSWORD='123456'
|
DATABASE_PASSWORD='123456'
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_HOST='fba_redis'
|
REDIS_HOST='fba_redis'
|
||||||
|
|||||||
+37
-35
@@ -8,9 +8,9 @@ networks:
|
|||||||
- subnet: 172.10.10.0/24
|
- subnet: 172.10.10.0/24
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# 如果你是 postgres 用户,应将 fba_mysql 修改为 fba_postgres
|
# 如果你是 mysql 用户,应将 fba_postgres 修改为 fba_mysql
|
||||||
fba_mysql:
|
fba_postgres:
|
||||||
name: fba_mysql
|
name: fba_postgres
|
||||||
fba_redis:
|
fba_redis:
|
||||||
name: fba_redis
|
name: fba_redis
|
||||||
fba_static:
|
fba_static:
|
||||||
@@ -26,11 +26,13 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: fba_server:latest
|
image: fba_server:latest
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
container_name: fba_server
|
container_name: fba_server
|
||||||
restart: always
|
restart: always
|
||||||
# 如果你是 postgres 用户,应将 fba_mysql 修改为 fba_postgres
|
# 如果你是 mysql 用户,应将 fba_postgres 修改为 fba_mysql
|
||||||
depends_on:
|
depends_on:
|
||||||
- fba_mysql
|
- fba_postgres
|
||||||
- fba_redis
|
- fba_redis
|
||||||
- fba_celery
|
- fba_celery
|
||||||
volumes:
|
volumes:
|
||||||
@@ -39,50 +41,50 @@ services:
|
|||||||
- fba_static_upload:/fba/backend/static/upload
|
- fba_static_upload:/fba/backend/static/upload
|
||||||
networks:
|
networks:
|
||||||
- fba_network
|
- fba_network
|
||||||
# 如果你是 postgres 用户,应将 fba_mysql:3306 修改为 fba_postgres:5432
|
# 如果你是 mysql 用户,应将 fba_postgres:5432 修改为 fba_mysql:3306
|
||||||
command:
|
command:
|
||||||
- bash
|
- bash
|
||||||
- -c
|
- -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
|
supervisord -c /etc/supervisor/supervisord.conf
|
||||||
supervisorctl restart
|
supervisorctl restart
|
||||||
|
|
||||||
fba_mysql:
|
fba_postgres:
|
||||||
image: mysql:8.0.41
|
image: postgres:16
|
||||||
ports:
|
ports:
|
||||||
- "${DOCKER_MYSQL_MAP_PORT:-3306}:3306"
|
- "${DOCKER_POSTGRES_MAP_PORT:-5432}:5432"
|
||||||
container_name: fba_mysql
|
container_name: fba_postgres
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: fba
|
POSTGRES_DB: fba
|
||||||
MYSQL_ROOT_PASSWORD: 123456
|
POSTGRES_PASSWORD: 123456
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- fba_mysql:/var/lib/mysql
|
- fba_postgres:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- fba_network
|
- 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 容器脚本
|
# # 如果你是 mysql 用户,应保留 fba_mysql 容器脚本并删除 fba_postgres 容器脚本
|
||||||
# fba_postgres:
|
# fba_mysql:
|
||||||
# image: postgres:16
|
# image: mysql:8.0.41
|
||||||
# ports:
|
# ports:
|
||||||
# - "${DOCKER_POSTGRES_MAP_PORT:-5432}:5432"
|
# - "${DOCKER_MYSQL_MAP_PORT:-3306}:3306"
|
||||||
# container_name: fba_postgres
|
# container_name: fba_mysql
|
||||||
# restart: always
|
# restart: always
|
||||||
# environment:
|
# environment:
|
||||||
# POSTGRES_DB: fba
|
# MYSQL_DATABASE: fba
|
||||||
# POSTGRES_PASSWORD: 123456
|
# MYSQL_ROOT_PASSWORD: 123456
|
||||||
# TZ: Asia/Shanghai
|
# TZ: Asia/Shanghai
|
||||||
# volumes:
|
# volumes:
|
||||||
# - fba_postgres:/var/lib/postgresql/data
|
# - fba_mysql:/var/lib/mysql
|
||||||
# networks:
|
# networks:
|
||||||
# - fba_network
|
# - 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:
|
fba_redis:
|
||||||
image: redis
|
image: redis
|
||||||
@@ -170,7 +172,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- SERVER_TYPE=celery
|
- SERVER_TYPE=fba_celery
|
||||||
image: fba_celery:latest
|
image: fba_celery:latest
|
||||||
ports:
|
ports:
|
||||||
- "8555:8555"
|
- "8555:8555"
|
||||||
|
|||||||
+17
-16
@@ -12,41 +12,42 @@ license = { text = "MIT" }
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dynamic = ['version']
|
dynamic = ['version']
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.16.5",
|
"alembic>=1.17.1",
|
||||||
"asgiref>=3.10.0",
|
"asgiref>=3.10.0",
|
||||||
"asyncmy>=0.2.10",
|
"asyncmy>=0.2.10",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"bcrypt>=5.0.0",
|
"bcrypt>=5.0.0",
|
||||||
"cappa>=0.30.2",
|
"cappa>=0.30.4",
|
||||||
"celery>=5.5.3",
|
"celery>=5.5.3",
|
||||||
# When celery version < 6.0.0
|
# When celery version < 6.0.0
|
||||||
# https://github.com/celery/celery/issues/7874
|
# https://github.com/celery/celery/issues/7874
|
||||||
"celery-aio-pool>=0.1.0rc8",
|
"celery-aio-pool>=0.1.0rc8",
|
||||||
"cryptography>=46.0.2",
|
"cryptography>=46.0.3",
|
||||||
"dulwich>=0.24.2",
|
"dulwich>=0.24.7",
|
||||||
"fast-captcha>=0.3.2",
|
"fast-captcha>=0.3.2",
|
||||||
"fastapi-limiter>=0.1.6",
|
"fastapi-limiter>=0.1.6",
|
||||||
"fastapi-pagination>=0.14.3",
|
"fastapi-pagination>=0.15.0",
|
||||||
"fastapi[standard-no-fastapi-cloud-cli]>=0.119.0",
|
"fastapi[standard-no-fastapi-cloud-cli]>=0.121.1",
|
||||||
"flower>=2.0.1",
|
"flower>=2.0.1",
|
||||||
"gevent>=25.9.1",
|
"gevent>=25.9.1",
|
||||||
"granian>=2.5.5",
|
"granian>=2.5.7",
|
||||||
"ip2loc>=1.0.0",
|
"ip2loc>=1.0.0",
|
||||||
"itsdangerous>=2.2.0",
|
"itsdangerous>=2.2.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
"msgspec>=0.19.0",
|
"msgspec>=0.19.0",
|
||||||
"psutil>=7.1.0",
|
"psutil>=7.1.2",
|
||||||
"psycopg[binary]>=3.2.10",
|
# https://github.com/fastapi-practices/fastapi_best_architecture/issues/887
|
||||||
|
"psycopg[binary]==3.2.10",
|
||||||
"pwdlib>=0.2.1",
|
"pwdlib>=0.2.1",
|
||||||
"pydantic>=2.12.1",
|
"pydantic>=2.12.3",
|
||||||
"pydantic-settings>=2.11.0",
|
"pydantic-settings>=2.11.0",
|
||||||
"pymysql>=1.1.2",
|
"pymysql>=1.1.2",
|
||||||
"python-jose>=3.5.0",
|
"python-jose>=3.5.0",
|
||||||
"python-socketio>=5.14.1",
|
"python-socketio>=5.14.3",
|
||||||
"redis[hiredis]>=6.4.0",
|
"redis[hiredis]>=7.0.1",
|
||||||
"rtoml>=0.12.0",
|
"rtoml>=0.13.0",
|
||||||
"sqlalchemy-crud-plus>=1.12.1",
|
"sqlalchemy-crud-plus>=1.13.1",
|
||||||
"sqlalchemy[asyncio]>=2.0.44",
|
"sqlalchemy[asyncio]>=2.0.44",
|
||||||
"sqlparse>=0.5.3",
|
"sqlparse>=0.5.3",
|
||||||
"starlette-context>=0.4.0",
|
"starlette-context>=0.4.0",
|
||||||
@@ -55,11 +56,11 @@ dependencies = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.4.0",
|
"pytest>=9.0.0",
|
||||||
"pytest-sugar>=1.1.1",
|
"pytest-sugar>=1.1.1",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"pre-commit>=4.3.0",
|
"prek>=0.2.13",
|
||||||
]
|
]
|
||||||
server = [
|
server = [
|
||||||
"aio-pika>=9.5.7",
|
"aio-pika>=9.5.7",
|
||||||
|
|||||||
+34
-51
@@ -1,10 +1,12 @@
|
|||||||
# This file was autogenerated by uv via the following command:
|
# This file was autogenerated by uv via the following command:
|
||||||
# uv export -o requirements.txt --no-hashes
|
# uv export -o requirements.txt --no-hashes
|
||||||
-e .
|
-e .
|
||||||
alembic==1.17.0
|
alembic==1.17.1
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
amqp==5.3.1
|
amqp==5.3.1
|
||||||
# via kombu
|
# via kombu
|
||||||
|
annotated-doc==0.0.3
|
||||||
|
# via fastapi
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
# via pydantic
|
# via pydantic
|
||||||
anyio==4.11.0
|
anyio==4.11.0
|
||||||
@@ -28,7 +30,7 @@ bidict==0.23.1
|
|||||||
# via python-socketio
|
# via python-socketio
|
||||||
billiard==4.2.2
|
billiard==4.2.2
|
||||||
# via celery
|
# via celery
|
||||||
cappa==0.30.2
|
cappa==0.30.4
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
celery==5.5.3
|
celery==5.5.3
|
||||||
# via
|
# via
|
||||||
@@ -45,8 +47,6 @@ cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
|||||||
# via
|
# via
|
||||||
# cryptography
|
# cryptography
|
||||||
# gevent
|
# gevent
|
||||||
cfgv==3.4.0
|
|
||||||
# via pre-commit
|
|
||||||
click==8.3.0
|
click==8.3.0
|
||||||
# via
|
# via
|
||||||
# celery
|
# celery
|
||||||
@@ -69,13 +69,11 @@ colorama==0.4.6 ; sys_platform == 'win32'
|
|||||||
# loguru
|
# loguru
|
||||||
# pytest
|
# pytest
|
||||||
# uvicorn
|
# uvicorn
|
||||||
cryptography==46.0.2
|
cryptography==46.0.3
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
distlib==0.4.0
|
|
||||||
# via virtualenv
|
|
||||||
dnspython==2.8.0
|
dnspython==2.8.0
|
||||||
# via email-validator
|
# via email-validator
|
||||||
dulwich==0.24.3
|
dulwich==0.24.8
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
ecdsa==0.19.1
|
ecdsa==0.19.1
|
||||||
# via python-jose
|
# via python-jose
|
||||||
@@ -87,24 +85,22 @@ exceptiongroup==1.3.0 ; python_full_version < '3.11'
|
|||||||
# pytest
|
# pytest
|
||||||
fast-captcha==0.3.2
|
fast-captcha==0.3.2
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
fastapi==0.119.0
|
fastapi==0.121.1
|
||||||
# via
|
# via
|
||||||
# fastapi-best-architecture
|
# fastapi-best-architecture
|
||||||
# fastapi-limiter
|
# fastapi-limiter
|
||||||
# fastapi-pagination
|
# fastapi-pagination
|
||||||
fastapi-cli==0.0.13
|
fastapi-cli==0.0.14
|
||||||
# via fastapi
|
# via fastapi
|
||||||
fastapi-limiter==0.1.6
|
fastapi-limiter==0.1.6
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
fastapi-pagination==0.14.3
|
fastapi-pagination==0.15.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
filelock==3.20.0
|
|
||||||
# via virtualenv
|
|
||||||
flower==2.0.1
|
flower==2.0.1
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
gevent==25.9.1
|
gevent==25.9.1
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
granian==2.5.5
|
granian==2.5.7
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
greenlet==3.2.4
|
greenlet==3.2.4
|
||||||
# via
|
# via
|
||||||
@@ -115,7 +111,7 @@ h11==0.16.0
|
|||||||
# httpcore
|
# httpcore
|
||||||
# uvicorn
|
# uvicorn
|
||||||
# wsproto
|
# wsproto
|
||||||
hiredis==3.2.1
|
hiredis==3.3.0
|
||||||
# via redis
|
# via redis
|
||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
# via httpx
|
# via httpx
|
||||||
@@ -123,16 +119,14 @@ httptools==0.7.1
|
|||||||
# via uvicorn
|
# via uvicorn
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# via fastapi
|
# via fastapi
|
||||||
humanize==4.13.0
|
humanize==4.14.0
|
||||||
# via flower
|
# via flower
|
||||||
identify==2.6.15
|
|
||||||
# via pre-commit
|
|
||||||
idna==3.11
|
idna==3.11
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
# email-validator
|
# email-validator
|
||||||
# httpx
|
# httpx
|
||||||
iniconfig==2.1.0
|
iniconfig==2.3.0
|
||||||
# via pytest
|
# via pytest
|
||||||
ip2loc==1.0.0
|
ip2loc==1.0.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
@@ -158,30 +152,26 @@ mdurl==0.1.2
|
|||||||
# via markdown-it-py
|
# via markdown-it-py
|
||||||
msgspec==0.19.0
|
msgspec==0.19.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
nodeenv==1.9.1
|
|
||||||
# via pre-commit
|
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
# via
|
# via
|
||||||
# kombu
|
# kombu
|
||||||
# pytest
|
# pytest
|
||||||
pillow==11.3.0
|
pillow==12.0.0
|
||||||
# via fast-captcha
|
# via fast-captcha
|
||||||
platformdirs==4.5.0
|
|
||||||
# via virtualenv
|
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
# via pytest
|
# via pytest
|
||||||
pre-commit==4.3.0
|
prek==0.2.13
|
||||||
prometheus-client==0.23.1
|
prometheus-client==0.23.1
|
||||||
# via flower
|
# via flower
|
||||||
prompt-toolkit==3.0.52
|
prompt-toolkit==3.0.52
|
||||||
# via click-repl
|
# via click-repl
|
||||||
psutil==7.1.0
|
psutil==7.1.3
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
psycopg==3.2.10
|
psycopg==3.2.10
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
psycopg-binary==3.2.10 ; implementation_name != 'pypy'
|
psycopg-binary==3.2.10 ; implementation_name != 'pypy'
|
||||||
# via psycopg
|
# via psycopg
|
||||||
pwdlib==0.2.1
|
pwdlib==0.3.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
pyasn1==0.6.1
|
pyasn1==0.6.1
|
||||||
# via
|
# via
|
||||||
@@ -189,14 +179,14 @@ pyasn1==0.6.1
|
|||||||
# rsa
|
# rsa
|
||||||
pycparser==2.23 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
pycparser==2.23 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||||
# via cffi
|
# via cffi
|
||||||
pydantic==2.12.1
|
pydantic==2.12.4
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
# fastapi-best-architecture
|
# fastapi-best-architecture
|
||||||
# fastapi-pagination
|
# fastapi-pagination
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
# sqlalchemy-crud-plus
|
# sqlalchemy-crud-plus
|
||||||
pydantic-core==2.41.3
|
pydantic-core==2.41.5
|
||||||
# via pydantic
|
# via pydantic
|
||||||
pydantic-settings==2.11.0
|
pydantic-settings==2.11.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
@@ -206,12 +196,12 @@ pygments==2.19.2
|
|||||||
# rich
|
# rich
|
||||||
pymysql==1.1.2
|
pymysql==1.1.2
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
pytest==8.4.2
|
pytest==9.0.0
|
||||||
# via pytest-sugar
|
# via pytest-sugar
|
||||||
pytest-sugar==1.1.1
|
pytest-sugar==1.1.1
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
# via celery
|
# via celery
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.2.1
|
||||||
# via
|
# via
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
# uvicorn
|
# uvicorn
|
||||||
@@ -221,15 +211,13 @@ python-jose==3.5.0
|
|||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
# via fastapi
|
# via fastapi
|
||||||
python-socketio==5.14.1
|
python-socketio==5.14.3
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
# via flower
|
# via flower
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
# via
|
# via uvicorn
|
||||||
# pre-commit
|
redis==7.0.1
|
||||||
# uvicorn
|
|
||||||
redis==6.4.0
|
|
||||||
# via
|
# via
|
||||||
# fastapi-best-architecture
|
# fastapi-best-architecture
|
||||||
# fastapi-limiter
|
# fastapi-limiter
|
||||||
@@ -242,10 +230,8 @@ rich-toolkit==0.15.1
|
|||||||
# via fastapi-cli
|
# via fastapi-cli
|
||||||
rsa==4.9.1
|
rsa==4.9.1
|
||||||
# via python-jose
|
# via python-jose
|
||||||
rtoml==0.12.0
|
rtoml==0.13.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
setuptools==80.9.0
|
|
||||||
# via zope-event
|
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
# via typer
|
# via typer
|
||||||
simple-websocket==1.1.0
|
simple-websocket==1.1.0
|
||||||
@@ -261,17 +247,17 @@ sqlalchemy==2.0.44
|
|||||||
# alembic
|
# alembic
|
||||||
# fastapi-best-architecture
|
# fastapi-best-architecture
|
||||||
# sqlalchemy-crud-plus
|
# sqlalchemy-crud-plus
|
||||||
sqlalchemy-crud-plus==1.12.1
|
sqlalchemy-crud-plus==1.13.1
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
sqlparse==0.5.3
|
sqlparse==0.5.3
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
starlette==0.48.0
|
starlette==0.49.3
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
# starlette-context
|
# starlette-context
|
||||||
starlette-context==0.4.0
|
starlette-context==0.4.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
termcolor==3.1.0
|
termcolor==3.2.0
|
||||||
# via pytest-sugar
|
# via pytest-sugar
|
||||||
tomli==2.3.0 ; python_full_version < '3.11'
|
tomli==2.3.0 ; python_full_version < '3.11'
|
||||||
# via
|
# via
|
||||||
@@ -279,9 +265,9 @@ tomli==2.3.0 ; python_full_version < '3.11'
|
|||||||
# pytest
|
# pytest
|
||||||
tornado==6.5.2
|
tornado==6.5.2
|
||||||
# via flower
|
# via flower
|
||||||
type-lens==0.2.5
|
type-lens==0.2.6
|
||||||
# via cappa
|
# via cappa
|
||||||
typer==0.19.2
|
typer==0.20.0
|
||||||
# via fastapi-cli
|
# via fastapi-cli
|
||||||
typing-extensions==4.15.0
|
typing-extensions==4.15.0
|
||||||
# via
|
# via
|
||||||
@@ -304,7 +290,6 @@ typing-extensions==4.15.0
|
|||||||
# typer
|
# typer
|
||||||
# typing-inspection
|
# typing-inspection
|
||||||
# uvicorn
|
# uvicorn
|
||||||
# virtualenv
|
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
# via
|
# via
|
||||||
# pydantic
|
# pydantic
|
||||||
@@ -321,20 +306,18 @@ urllib3==2.5.0
|
|||||||
# via dulwich
|
# via dulwich
|
||||||
user-agents==2.2.0
|
user-agents==2.2.0
|
||||||
# via fastapi-best-architecture
|
# via fastapi-best-architecture
|
||||||
uvicorn==0.37.0
|
uvicorn==0.38.0
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
# fastapi-cli
|
# 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
|
# via uvicorn
|
||||||
vine==5.1.0
|
vine==5.1.0
|
||||||
# via
|
# via
|
||||||
# amqp
|
# amqp
|
||||||
# celery
|
# celery
|
||||||
# kombu
|
# kombu
|
||||||
virtualenv==20.35.3
|
watchfiles==1.1.1
|
||||||
# via pre-commit
|
|
||||||
watchfiles==1.1.0
|
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
wcwidth==0.2.14
|
wcwidth==0.2.14
|
||||||
# via prompt-toolkit
|
# via prompt-toolkit
|
||||||
@@ -344,7 +327,7 @@ win32-setctime==1.2.0 ; sys_platform == 'win32'
|
|||||||
# via loguru
|
# via loguru
|
||||||
wsproto==1.2.0
|
wsproto==1.2.0
|
||||||
# via simple-websocket
|
# via simple-websocket
|
||||||
zope-event==6.0
|
zope-event==6.1
|
||||||
# via gevent
|
# via gevent
|
||||||
zope-interface==8.0.1
|
zope-interface==8.0.1
|
||||||
# via gevent
|
# via gevent
|
||||||
|
|||||||
Reference in New Issue
Block a user