Compare commits

..
47 Commits
Author SHA1 Message Date
Wu Clan 8ae1a43581 Update i18n language storage and loading (#1008) 2026-01-13 19:10:41 +08:00
Wu Clan ee849f0854 Update the plugin dependency install method (#1007) 2026-01-13 16:14:41 +08:00
Wu Clan 5c9a27cc16 Update nickname generation when create user (#1004)
* Update nickname generation when create user

* Fix lint

* Update code style
2026-01-12 19:03:02 +08:00
Wu Clan 1b68854b84 Update code generation part file naming (#1002) 2026-01-11 18:03:20 +08:00
Wu Clan b73585ebd2 Optimize definitions of multiple utility functions (#1001)
* Optimize definitions of multiple utility functions

* Update plugin tools
2026-01-11 16:57:52 +08:00
Wu Clan 28a6228556 Update redis and server monitor implementations (#1000)
* Update redis and server monitor implementations

* Fix server disk information
2026-01-11 13:21:37 +08:00
Wu Clan dfce2ca094 Remove the opera log desensitization asynchronous (#999) 2026-01-10 18:30:16 +08:00
shj366 326bdf9a17 Fix opera log non-json data overload (#998)
* fix: skip non-json body logging for multipart uploads

* Update truncation implementation
2026-01-10 18:23:02 +08:00
Wu Clan cbe4e5ebe0 Update login log request header column length (#996) 2026-01-08 12:23:37 +08:00
Wu Clan 2d666e375f Update the changelog for v1.12.2 (#995) 2026-01-07 12:23:47 +08:00
Wu Clan ff40c41549 Remove Linux Do OAuth2 login (#994) 2026-01-07 12:21:21 +08:00
Wu Clan 7619670521 Fix the key of the refresh token removed (#993) 2026-01-07 12:09:10 +08:00
Wu Clan 85ba942f35 Fix SQL scripts error in config plugin (#991) 2026-01-06 14:30:49 +08:00
Wu Clan 88f3173f8f Fix operation log queue status management (#989)
* Fix operation log queue status management

* Fix lint
2026-01-06 10:53:23 +08:00
Wu Clan fd87dfb25a Remove invalid configs of operation log (#988) 2026-01-05 14:09:24 +08:00
Wu Clan ad7f725e4c Simplify the desensitization of operation log data (#987) 2026-01-05 12:16:51 +08:00
Wu Clan 2ef8810376 Fix environment variable file auto init (#985) 2026-01-04 12:20:23 +08:00
Wu Clan f0e6ed9985 Update the changelog for v1.12.1 (#983) 2025-12-31 15:36:25 +08:00
Wu Clan 809879997c Update git and docker ignore files (#981) 2025-12-30 12:20:33 +08:00
Wu Clan b153b7ac18 Add observability instrument for httpx request (#980) 2025-12-30 12:16:59 +08:00
Wu Clan 308ccf71ae Bump dependencies and pre-commits (#979) 2025-12-29 15:47:46 +08:00
Wu Clan c8f6d9da72 Add the auto init project CLI command (#978) 2025-12-28 14:12:31 +08:00
Wu Clan 3f2c3e44ba Optimize login log database session calls (#977) 2025-12-25 18:12:43 +08:00
Wu Clan b5e2a9e591 Fix case where the user agent was empty (#976) 2025-12-25 12:03:57 +08:00
Wu Clan cdabe91092 Add OTEL semantic specification metrics config (#973) 2025-12-24 10:49:25 +08:00
Wu Clan 69a59993e3 Add observability instrument for redis client (#972) 2025-12-20 19:25:34 +08:00
Wu Clan b6defc671e Fix i18n target language error when concurrent (#970) 2025-12-20 19:25:05 +08:00
Wu Clan 8cbea57a8a Add an independent contribution document (#971) 2025-12-19 15:20:34 +08:00
Wu Clan 7845e1ff49 Fix support for special character passwords (#968)
* Fix special character password support in celery

* Fix celery amqp and socketio password

* Remove non-essential changes
2025-12-19 14:47:26 +08:00
Wu Clan 2b76b64e85 Add code generation and notice SQL scripts (#966) 2025-12-16 19:55:01 +08:00
Wu Clan dd3165a082 Rename the pre start script to migrate (#965) 2025-12-16 19:54:12 +08:00
Wu Clan 407d12760b Update Grafana security and user default config (#964) 2025-12-16 14:39:21 +08:00
Wu Clan f8b244c3a9 Update the changelog for v1.12.0 (#963) 2025-12-15 17:11:29 +08:00
Wu Clan dfc715aad8 Update the version number to 1.12.0 (#962) 2025-12-15 17:07:06 +08:00
Wu Clan 4bc5ba53e6 Add the Grafana observability suite (#961)
* Add the Grafana observability suite

* Update configs

* Update docker script directory structure

* Fix the otel trace id

* Add grafana ini

* Fix some configs and loguru integration

* Add the celery grafana

* Update Grafana dashboards

* Update configs

* Fix issues with the panel

* Update grafana configs

* Update grafana dashboards

* Optimized panel styles

* Add sqlalchemy traces

* Fix the CORS

* Update the grafana query and config

* Update grafana status is off by default
2025-12-15 17:06:43 +08:00
曾果 6b4fd93e5f Fix the IP address in the request log (#959) 2025-12-15 10:07:43 +08:00
Wu Clan 0c5956f828 Fix CLI command for code generation (#956) 2025-12-07 15:14:52 +08:00
Wu Clan 122d17ab57 Update the init project database CLI to subcommand (#954) 2025-12-05 17:23:44 +08:00
Wu Clan 4fb14e480a Add CLI init project database support (#952)
* Add CLI init project database support

* Update get sql script

* Update init CLI contexts
2025-12-05 17:03:01 +08:00
Wu Clan ea4ac6ff41 Optimize the coupling of code generation CLI (#951) 2025-12-05 14:52:58 +08:00
Wu Clan bb5bcdf430 Add the database primary key mode config (#953)
* Add the database primary key mode config

* Update auto to autoincrement
2025-12-05 14:52:12 +08:00
Wu Clan aad9afa9bb Optimize the coupling of user social plugin (#950) 2025-12-04 11:56:37 +08:00
Wu Clan a0e6e12d85 Update pre-commit to prek in pre-commit script (#949) 2025-12-04 11:56:00 +08:00
Wu Clan 5d680ff93f Optimize data permission logic and usage (#947)
* Optimize data permission rules and usage

* Update get data permission models

* Update date permission filter

* Optimize the target model logic

* Upgrade dependencies to use latest features

* Remove model warnings

* Fix the latest feature issues

* Fix the sqlalchemy Table class import

* Fix the sqlalchemy Table class compatibility
2025-12-03 18:44:18 +08:00
Wu Clan 866b0e6ba4 Fix super value in update user permissions (#948) 2025-12-03 18:41:26 +08:00
Wu Clan 5a49d20c9b Update celery related docker container independence (#943)
* Update celery related docker container independence

* Fix lint

* Fix nginx conf
2025-11-28 18:33:32 +08:00
Wu Clan 7641d5993a Update the changelog for v1.11.2 (#942) 2025-11-28 12:31:05 +08:00
122 changed files with 7455 additions and 1395 deletions
+3
View File
@@ -3,7 +3,10 @@ __pycache__/
.github/
.idea/
.vscode/
.cursor/
venv/
.venv/
.ruff_cache/
.pytest_cache/
.claude/
.serena/
+1
View File
@@ -8,3 +8,4 @@ venv/
.ruff_cache/
.pytest_cache/
.claude/
.serena/
+2 -2
View File
@@ -11,7 +11,7 @@ repos:
- id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.14.6
rev: v0.14.10
hooks:
- id: ruff-check
args:
@@ -21,7 +21,7 @@ repos:
- id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.9.12
rev: 0.9.18
hooks:
- id: uv-lock
- id: uv-export
+119
View File
@@ -1,3 +1,118 @@
<a id="v1.12.2"></a>
# [v1.12.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.2) - 2026-01-07
## What's Changed
* Update changelog for v1.12.1 by [@wu-clan](https://github.com/wu-clan) in [#983](https://github.com/fastapi-practices/fastapi_best_architecture/pull/983)
* Fix environment variable file auto init by [@wu-clan](https://github.com/wu-clan) in [#985](https://github.com/fastapi-practices/fastapi_best_architecture/pull/985)
* Simplify the desensitization of operation log data by [@wu-clan](https://github.com/wu-clan) in [#987](https://github.com/fastapi-practices/fastapi_best_architecture/pull/987)
* Remove invalid configs of operation log by [@wu-clan](https://github.com/wu-clan) in [#988](https://github.com/fastapi-practices/fastapi_best_architecture/pull/988)
* Fix operation log queue status management by [@wu-clan](https://github.com/wu-clan) in [#989](https://github.com/fastapi-practices/fastapi_best_architecture/pull/989)
* Fix SQL scripts error in config plugin by [@wu-clan](https://github.com/wu-clan) in [#991](https://github.com/fastapi-practices/fastapi_best_architecture/pull/991)
* Fix the key of the refresh token removed by [@wu-clan](https://github.com/wu-clan) in [#993](https://github.com/fastapi-practices/fastapi_best_architecture/pull/993)
* Remove Linux Do OAuth2 login by [@wu-clan](https://github.com/wu-clan) in [#994](https://github.com/fastapi-practices/fastapi_best_architecture/pull/994)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.1...v1.12.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.12.2]
<a id="v1.12.1"></a>
# [v1.12.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.1) - 2025-12-31
## What's Changed
* Update changelog for v1.12.0 by [@wu-clan](https://github.com/wu-clan) in [#963](https://github.com/fastapi-practices/fastapi_best_architecture/pull/963)
* Update Grafana security and user default config by [@wu-clan](https://github.com/wu-clan) in [#964](https://github.com/fastapi-practices/fastapi_best_architecture/pull/964)
* Rename the pre start script to migrate by [@wu-clan](https://github.com/wu-clan) in [#965](https://github.com/fastapi-practices/fastapi_best_architecture/pull/965)
* Add code generation and notice SQL scripts by [@wu-clan](https://github.com/wu-clan) in [#966](https://github.com/fastapi-practices/fastapi_best_architecture/pull/966)
* Fix support for special character passwords by [@wu-clan](https://github.com/wu-clan) in [#968](https://github.com/fastapi-practices/fastapi_best_architecture/pull/968)
* Add an independent contribution document by [@wu-clan](https://github.com/wu-clan) in [#971](https://github.com/fastapi-practices/fastapi_best_architecture/pull/971)
* Fix i18n target language error when concurrent by [@wu-clan](https://github.com/wu-clan) in [#970](https://github.com/fastapi-practices/fastapi_best_architecture/pull/970)
* Add observability instrument for redis client by [@wu-clan](https://github.com/wu-clan) in [#972](https://github.com/fastapi-practices/fastapi_best_architecture/pull/972)
* Add OTEL semantic specification metrics config by [@wu-clan](https://github.com/wu-clan) in [#973](https://github.com/fastapi-practices/fastapi_best_architecture/pull/973)
* Fix case where the user agent was empty by [@wu-clan](https://github.com/wu-clan) in [#976](https://github.com/fastapi-practices/fastapi_best_architecture/pull/976)
* Optimize login log database session calls by [@wu-clan](https://github.com/wu-clan) in [#977](https://github.com/fastapi-practices/fastapi_best_architecture/pull/977)
* Add the auto init project CLI command by [@wu-clan](https://github.com/wu-clan) in [#978](https://github.com/fastapi-practices/fastapi_best_architecture/pull/978)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#979](https://github.com/fastapi-practices/fastapi_best_architecture/pull/979)
* Add observability instrument for httpx request by [@wu-clan](https://github.com/wu-clan) in [#980](https://github.com/fastapi-practices/fastapi_best_architecture/pull/980)
* Update git and docker ignore files by [@wu-clan](https://github.com/wu-clan) in [#981](https://github.com/fastapi-practices/fastapi_best_architecture/pull/981)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.0...v1.12.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.12.1]
<a id="v1.12.0"></a>
# [v1.12.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.0) - 2025-12-15
## What's Changed
* Update changelog for v1.11.2 by [@wu-clan](https://github.com/wu-clan) in [#942](https://github.com/fastapi-practices/fastapi_best_architecture/pull/942)
* Update celery related docker container independence by [@wu-clan](https://github.com/wu-clan) in [#943](https://github.com/fastapi-practices/fastapi_best_architecture/pull/943)
* Fix super value in update user permissions by [@wu-clan](https://github.com/wu-clan) in [#948](https://github.com/fastapi-practices/fastapi_best_architecture/pull/948)
* Optimize data permission logic and usage by [@wu-clan](https://github.com/wu-clan) in [#947](https://github.com/fastapi-practices/fastapi_best_architecture/pull/947)
* Update pre-commit to prek in pre-commit script by [@wu-clan](https://github.com/wu-clan) in [#949](https://github.com/fastapi-practices/fastapi_best_architecture/pull/949)
* Optimize the coupling of user social plugin by [@wu-clan](https://github.com/wu-clan) in [#950](https://github.com/fastapi-practices/fastapi_best_architecture/pull/950)
* Add the database primary key mode config by [@wu-clan](https://github.com/wu-clan) in [#953](https://github.com/fastapi-practices/fastapi_best_architecture/pull/953)
* Optimize the coupling of code generation CLI by [@wu-clan](https://github.com/wu-clan) in [#951](https://github.com/fastapi-practices/fastapi_best_architecture/pull/951)
* Add CLI init project database support by [@wu-clan](https://github.com/wu-clan) in [#952](https://github.com/fastapi-practices/fastapi_best_architecture/pull/952)
* Update the init project database CLI to subcommand by [@wu-clan](https://github.com/wu-clan) in [#954](https://github.com/fastapi-practices/fastapi_best_architecture/pull/954)
* Fix CLI command for code generation by [@wu-clan](https://github.com/wu-clan) in [#956](https://github.com/fastapi-practices/fastapi_best_architecture/pull/956)
* Fix the IP address in the request log by [@wuyuemushi](https://github.com/wuyuemushi) in [#959](https://github.com/fastapi-practices/fastapi_best_architecture/pull/959)
* Add the Grafana observability suite by [@wu-clan](https://github.com/wu-clan) in [#961](https://github.com/fastapi-practices/fastapi_best_architecture/pull/961)
* Update the version number to 1.12.0 by [@wu-clan](https://github.com/wu-clan) in [#962](https://github.com/fastapi-practices/fastapi_best_architecture/pull/962)
## New Contributors
* [@wuyuemushi](https://github.com/wuyuemushi) made their first contribution in [#959](https://github.com/fastapi-practices/fastapi_best_architecture/pull/959)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.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>
<a href="https://github.com/wuyuemushi"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwuyuemushi.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wuyuemushi"></a>
[Changes][v1.12.0]
<a id="v1.11.2"></a>
# [v1.11.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.2) - 2025-11-28
## What's Changed
* Update changelog for v1.11.1 by [@wu-clan](https://github.com/wu-clan) in [#923](https://github.com/fastapi-practices/fastapi_best_architecture/pull/923)
* Fix typos in data rule sql scripts by [@wu-clan](https://github.com/wu-clan) in [#926](https://github.com/fastapi-practices/fastapi_best_architecture/pull/926)
* Restore captcha uuid naming in the login params by [@wu-clan](https://github.com/wu-clan) in [#928](https://github.com/fastapi-practices/fastapi_best_architecture/pull/928)
* Add distributed deployment support for snowflake ID by [@downdawn](https://github.com/downdawn) in [#927](https://github.com/fastapi-practices/fastapi_best_architecture/pull/927)
* Add env reqs for plugin install and uninstall by [@wu-clan](https://github.com/wu-clan) in [#929](https://github.com/fastapi-practices/fastapi_best_architecture/pull/929)
* Optimize the use of some LRU caches by [@wu-clan](https://github.com/wu-clan) in [#932](https://github.com/fastapi-practices/fastapi_best_architecture/pull/932)
* Update the i18n language file init location by [@wu-clan](https://github.com/wu-clan) in [#934](https://github.com/fastapi-practices/fastapi_best_architecture/pull/934)
* Fix get column types in code generation by [@wu-clan](https://github.com/wu-clan) in [#935](https://github.com/fastapi-practices/fastapi_best_architecture/pull/935)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#936](https://github.com/fastapi-practices/fastapi_best_architecture/pull/936)
* Update the files interface filename to file by [@wu-clan](https://github.com/wu-clan) in [#937](https://github.com/fastapi-practices/fastapi_best_architecture/pull/937)
* Update task application interface definitions by [@wu-clan](https://github.com/wu-clan) in [#938](https://github.com/fastapi-practices/fastapi_best_architecture/pull/938)
* Update code generation interface definitions by [@wu-clan](https://github.com/wu-clan) in [#939](https://github.com/fastapi-practices/fastapi_best_architecture/pull/939)
* Update the fba run CLI command output by [@wu-clan](https://github.com/wu-clan) in [#941](https://github.com/fastapi-practices/fastapi_best_architecture/pull/941)
* Update the version number to 1.11.2 by [@wu-clan](https://github.com/wu-clan) in [#940](https://github.com/fastapi-practices/fastapi_best_architecture/pull/940)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.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.11.2]
<a id="v1.11.1"></a>
# [v1.11.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.1) - 2025-11-16
@@ -1161,6 +1276,10 @@
[Changes][v1.0.0]
[v1.12.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.1...v1.12.2
[v1.12.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.0...v1.12.1
[v1.12.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
[v1.11.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
[v1.11.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.0...v1.11.1
[v1.11.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.4...v1.11.0
[v1.10.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
+62
View File
@@ -0,0 +1,62 @@
## Backend
1. Prerequisites
- Python >= 3.10
- Git
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Fork this repository to your GitHub account
2. Installation and setup
Clone your forked repository:
```shell
git clone https://github.com/<your account>/fastapi_best_architecture.git
```
Go to the root directory of the project, open the terminal, and run the following command:
```sh
uv sync
```
3. Checkout
Checkout a new branch and make your changes
```shell
git checkout -b your-new-feature-branch
```
4. Format and Lint
Auto-formatting and lint via `prek`
```shell
prek run --all-files
```
5. Commit and push
Commit your changes and push your branch to the GitHub.
6. PR
Create a PR via GitHub
## Scripts
> [!WARNING]
>
> The following script may not apply to the Windows platform
>
> It is recommended to execute under the backend directory, and chmod authorization may be required
- `migrate.sh`: Perform automatic database migration
- `scripts/format.sh`: Perform ruff format check
- `scripts/lint.sh`: Perform prek formatting
- `scripts/export.sh`: Execute uv export dependency package
+24 -8
View File
@@ -36,33 +36,49 @@ COPY --from=builder /fba /fba
COPY --from=builder /usr/local /usr/local
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf
COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
WORKDIR /fba/backend
# === FastAPI server image ===
FROM base_server AS fba_server
COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/
COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
EXPOSE 8001
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery server image ===
FROM base_server AS fba_celery
# === Celery Worker image ===
FROM base_server AS fba_celery_worker
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
COPY deploy/backend/supervisor/fba_celery_worker.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
RUN chmod +x celery-start.sh
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Beat image ===
FROM base_server AS fba_celery_beat
COPY deploy/backend/supervisor/fba_celery_beat.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Flower image ===
FROM base_server AS fba_celery_flower
COPY deploy/backend/supervisor/fba_celery_flower.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
EXPOSE 8555
CMD ["./celery-start.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# Build image
FROM ${SERVER_TYPE}
-4
View File
@@ -13,8 +13,6 @@ REDIS_PASSWORD=''
REDIS_DATABASE=0
# Token
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# [ App ] task
# Celery
CELERY_BROKER_REDIS_DATABASE=1
@@ -28,8 +26,6 @@ OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_GOOGLE_CLIENT_ID='test'
OAUTH2_GOOGLE_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# [ Plugin ] email
EMAIL_USERNAME=''
EMAIL_PASSWORD=''
-65
View File
@@ -16,68 +16,3 @@
```shell
docker run -d -p 8000:8000 --name fba_server fba_backend_independent
```
## Contributing
1. Prerequisites
- Python >= 3.10
- Git
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Fork this repository to your GitHub account
2. Installation and setup
Clone your forked repository:
```shell
git clone https://github.com/<your account>/fastapi_best_architecture.git
```
Go to the root directory of the project, open the terminal, and run the following command:
```sh
uv sync --frozen
```
3. Checkout
Checkout a new branch and make your changes
```shell
git checkout -b your-new-feature-branch
```
4. Format and Lint
Auto-formatting and lint via `prek`
```shell
prek run --all-files
```
5. Commit and push
Commit your changes and push your branch to the GitHub.
6. PR
Create a PR via GitHub
## Scripts
> [!WARNING]
>
> The following script may not apply to the Windows platform
>
> It is recommended to execute under the backend directory, and chmod authorization may be required
- `pre_start.sh`: Perform automatic database migration
- `celery-start.sh`: For celery docker script, implementation is not recommended
- `scripts/format.sh`: Perform ruff format check
- `scripts/lint.sh`: Perform prek formatting
- `scripts/export.sh`: Execute uv export dependency package
+17 -1
View File
@@ -1 +1,17 @@
__version__ = '1.11.2'
import sqlalchemy as sa
from backend.utils.dynamic_import import get_all_models
# import all models for auto create db tables
for cls in get_all_models():
if isinstance(cls, sa.Table):
table_name = cls.name
if table_name not in globals():
globals()[table_name] = cls
else:
class_name = cls.__name__
if class_name not in globals():
globals()[class_name] = cls
__version__ = '1.12.0'
-8
View File
@@ -8,17 +8,9 @@ from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from backend.app import get_app_models
from backend.common.model import MappedBase
from backend.core import path_conf
from backend.database.db import SQLALCHEMY_DATABASE_URL
from backend.plugin.tools import get_plugin_models
# import models
for cls in get_app_models() + get_plugin_models():
class_name = cls.__name__
if class_name not in globals():
globals()[class_name] = cls
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
-28
View File
@@ -1,28 +0,0 @@
import os.path
from backend.core.path_conf import BASE_PATH
from backend.utils.import_parse import get_model_objects
def get_app_models() -> list[type]:
"""获取 app 所有模型类"""
app_path = BASE_PATH / 'app'
list_dirs = os.listdir(app_path)
apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
objs = []
for app in apps:
module_path = f'backend.app.{app}.model'
obj = get_model_objects(module_path)
if obj:
objs.extend(obj)
return objs
# import all app models for auto create db tables
for cls in get_app_models():
class_name = cls.__name__
if class_name not in globals():
globals()[class_name] = cls
+31 -7
View File
@@ -1,16 +1,40 @@
from fastapi import APIRouter
from backend.common.response.response_schema import ResponseModel, response_base
from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.redis_info import redis_info
from backend.database.redis import redis_client
from backend.utils.format import fmt_seconds
router = APIRouter()
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
async def get_redis_info() -> ResponseModel:
data = {
'info': await redis_info.get_info(),
'stats': await redis_info.get_stats(),
}
async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
info = await redis_client.info()
db_size = await redis_client.dbsize()
uptime_formatted = fmt_seconds(int(info.get('uptime_in_seconds', 0)))
server_info = RedisServerInfo(
redis_version=str(info.get('redis_version', '')),
redis_mode=str(info.get('redis_mode', '')),
os=str(info.get('os', '')),
arch_bits=str(info.get('arch_bits', '')),
tcp_port=str(info.get('tcp_port', '')),
uptime_in_seconds=uptime_formatted,
connected_clients=str(info.get('connected_clients', '')),
used_memory_human=str(info.get('used_memory_human', '')),
used_memory_peak_human=str(info.get('used_memory_peak_human', '')),
maxmemory_human=str(info.get('maxmemory_human', '0B')),
keys_num=str(db_size),
)
command_stats = await redis_client.info('commandstats')
stats_list = []
for key, value in command_stats.items():
if isinstance(value, dict):
stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0'))))
data = RedisMonitorInfo(info=server_info, stats=stats_list)
return response_base.success(data=data)
+124 -11
View File
@@ -1,21 +1,134 @@
import os
import platform
import socket
import sys
from datetime import datetime
from datetime import timezone as tz
import psutil
from fastapi import APIRouter
from starlette.concurrency import run_in_threadpool
from backend.common.response.response_schema import ResponseModel, response_base
from backend.app.admin.schema.monitor import (
CpuInfo,
DiskInfo,
MemInfo,
ServerMonitorInfo,
ServiceInfo,
SysInfo,
)
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.server_info import server_info
from backend.utils.format import fmt_bytes, fmt_seconds
from backend.utils.timezone import timezone
router = APIRouter()
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
async def get_server_info() -> ResponseModel:
data = {
# 扔到线程池,避免阻塞
'cpu': await run_in_threadpool(server_info.get_cpu_info),
'mem': await run_in_threadpool(server_info.get_mem_info),
'sys': await run_in_threadpool(server_info.get_sys_info),
'disk': await run_in_threadpool(server_info.get_disk_info),
'service': await run_in_threadpool(server_info.get_service_info),
}
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901
def get_all_info() -> ServerMonitorInfo: # noqa: C901
# CPU 信息
cpu_data = {
'usage': round(psutil.cpu_percent(interval=0.1), 2),
'logical_num': psutil.cpu_count(logical=True) or 0,
'physical_num': psutil.cpu_count(logical=False) or 0,
'max_freq': 0.0,
'min_freq': 0.0,
'current_freq': 0.0,
}
try:
if hasattr(psutil, 'cpu_freq'):
cpu_freq = psutil.cpu_freq()
if cpu_freq:
cpu_data.update({
'max_freq': round(cpu_freq.max, 2),
'min_freq': round(cpu_freq.min, 2),
'current_freq': round(cpu_freq.current, 2),
})
except Exception:
pass
cpu = CpuInfo(**cpu_data)
# 内存信息
mem = psutil.virtual_memory()
gb_factor = 1024**3
mem_info = MemInfo(
total=round(mem.total / gb_factor, 2),
used=round(mem.used / gb_factor, 2),
free=round(mem.available / gb_factor, 2),
usage=round(mem.percent, 2),
)
# 系统信息
hostname = socket.gethostname()
ip = '127.0.0.1'
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(0.5)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except (TimeoutError, socket.gaierror, OSError):
pass
sys_info = SysInfo(name=hostname, ip=ip, os=platform.system(), arch=platform.machine())
# 磁盘信息
disk_list = []
exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'}
seen_devices = set()
for partition in psutil.disk_partitions(all=False):
# 跳过虚拟文件系统
if partition.fstype.lower() in exclude_fstypes:
continue
# 跳过重复设备(同一设备的不同挂载点)
if partition.device in seen_devices:
continue
try:
usage = psutil.disk_usage(partition.mountpoint)
if usage:
seen_devices.add(partition.device)
disk_list.append(
DiskInfo(
dir=partition.mountpoint,
type=partition.fstype,
device=partition.device,
total=fmt_bytes(usage.total),
free=fmt_bytes(usage.free),
used=fmt_bytes(usage.used),
usage=f'{usage.percent:.2f}%',
)
)
except (PermissionError, OSError):
continue
# 服务信息
process = psutil.Process(os.getpid())
proc_mem = process.memory_info()
try:
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
start_time = timezone.from_datetime(create_time)
except (psutil.NoSuchProcess, OSError):
start_time = timezone.now()
elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds()))
service = ServiceInfo(
name='Python3',
version=platform.python_version(),
home=sys.executable,
cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%',
mem_vms=fmt_bytes(proc_mem.vms),
mem_rss=fmt_bytes(proc_mem.rss),
mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss),
startup=timezone.to_str(start_time),
elapsed=elapsed,
)
return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service)
data = await run_in_threadpool(get_all_info)
return response_base.success(data=data)
+6 -4
View File
@@ -1,12 +1,14 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import ColumnElement
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.permission import DataPermissionFilter, RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
@@ -24,14 +26,14 @@ async def get_dept(
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
async def get_dept_tree(
db: CurrentSession,
request: Request,
data_filter: Annotated[ColumnElement[bool], Depends(DataPermissionFilter(Dept))],
name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_tree(
db=db, request_user=request.user, name=name, leader=leader, phone=phone, status=status
db=db, data_filter=data_filter, name=name, leader=leader, phone=phone, status=status
)
return response_base.success(data=dept)
+3 -5
View File
@@ -1,13 +1,12 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import Dept, User
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.security.permission import filter_data_permission
from backend.utils.serializers import select_join_serialize
@@ -37,7 +36,7 @@ class CRUDDept(CRUDPlus[Dept]):
async def get_all(
self,
db: AsyncSession,
request_user: GetUserInfoWithRelationDetail,
data_filter: ColumnElement[bool],
name: str | None,
leader: str | None,
phone: str | None,
@@ -47,7 +46,7 @@ class CRUDDept(CRUDPlus[Dept]):
获取所有部门
:param db: 数据库会话
:param request_user: 请求用户
:param data_filter: 请求用户
:param name: 部门名称
:param leader: 负责人
:param phone: 联系电话
@@ -65,7 +64,6 @@ class CRUDDept(CRUDPlus[Dept]):
if status is not None:
filters['status'] = status
data_filter = filter_data_permission(request_user)
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
+8 -2
View File
@@ -25,7 +25,7 @@ from backend.app.admin.schema.user import (
UpdateUserParam,
)
from backend.app.admin.utils.password_security import get_hash_password
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.utils.dynamic_import import import_module_cached
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
@@ -299,7 +299,13 @@ class CRUDUser(CRUDPlus[User]):
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)
try:
user_social = import_module_cached('backend.plugin.oauth2.crud.crud_user_social')
user_social_dao = user_social.user_social_dao
except (ImportError, AttributeError):
pass
else:
await user_social_dao.delete_by_user_id(db, user_id)
return await self.delete_model(db, user_id)
+1 -1
View File
@@ -21,7 +21,7 @@ class LoginLog(DataClassBase):
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str] = mapped_column(sa.String(256), comment='请求头')
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
+1 -1
View File
@@ -23,7 +23,7 @@ class OperaLog(DataClassBase):
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str] = mapped_column(sa.String(512), comment='请求头')
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
+1 -1
View File
@@ -45,4 +45,4 @@ class GetDataRuleColumnDetail(SchemaBase):
"""数据规则可用模型字段详情"""
key: str = Field(description='字段名')
comment: str = Field(description='字段评论')
comment: str | None = Field(description='字段评论')
+1 -1
View File
@@ -15,7 +15,7 @@ class LoginLogSchemaBase(SchemaBase):
country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市')
user_agent: str = Field(description='用户代理')
user_agent: str | None = Field(description='用户代理')
browser: str | None = Field(None, description='浏览器')
os: str | None = Field(None, description='操作系统')
device: str | None = Field(None, description='设备')
+98
View File
@@ -0,0 +1,98 @@
from pydantic import Field
from backend.common.schema import SchemaBase
class CpuInfo(SchemaBase):
"""CPU 信息"""
usage: float = Field(description='CPU 使用率 (%)')
logical_num: int = Field(description='逻辑核心数')
physical_num: int = Field(description='物理核心数')
max_freq: float = Field(description='最大频率 (MHz)')
min_freq: float = Field(description='最小频率 (MHz)')
current_freq: float = Field(description='当前频率 (MHz)')
class MemInfo(SchemaBase):
"""内存信息"""
total: float = Field(description='总内存 (GB)')
used: float = Field(description='已使用内存 (GB)')
free: float = Field(description='可用内存 (GB)')
usage: float = Field(description='内存使用率 (%)')
class SysInfo(SchemaBase):
"""系统信息"""
name: str = Field(description='主机名')
ip: str = Field(description='IP 地址')
os: str = Field(description='操作系统')
arch: str = Field(description='系统架构')
class DiskInfo(SchemaBase):
"""磁盘信息"""
dir: str = Field(description='挂载点')
type: str = Field(description='文件系统类型')
device: str = Field(description='设备名称')
total: str = Field(description='总容量')
free: str = Field(description='可用容量')
used: str = Field(description='已使用容量')
usage: str = Field(description='使用率')
class ServiceInfo(SchemaBase):
"""服务信息"""
name: str = Field(description='服务名称')
version: str = Field(description='版本')
home: str = Field(description='安装路径')
cpu_usage: str = Field(description='CPU 使用率')
mem_vms: str = Field(description='虚拟内存')
mem_rss: str = Field(description='物理内存')
mem_free: str = Field(description='可用内存')
startup: str = Field(description='启动时间')
elapsed: str = Field(description='运行时长')
class ServerMonitorInfo(SchemaBase):
"""服务器监控信息"""
cpu: CpuInfo = Field(description='CPU 信息')
mem: MemInfo = Field(description='内存信息')
sys: SysInfo = Field(description='系统信息')
disk: list[DiskInfo] = Field(description='磁盘信息列表')
service: ServiceInfo = Field(description='服务信息')
class RedisServerInfo(SchemaBase):
"""Redis 服务器信息"""
redis_version: str = Field(description='Redis 版本')
redis_mode: str = Field(description='运行模式')
os: str = Field(description='操作系统')
arch_bits: str = Field(description='架构位数')
tcp_port: str = Field(description='TCP 端口')
uptime_in_seconds: str = Field(description='运行时长')
connected_clients: str = Field(description='已连接客户端数')
used_memory_human: str = Field(description='已使用内存')
used_memory_peak_human: str = Field(description='内存使用峰值')
maxmemory_human: str = Field(description='最大内存限制')
keys_num: str = Field(description='键总数')
class RedisCommandStat(SchemaBase):
"""Redis 命令统计"""
name: str = Field(description='命令名称')
value: str = Field(description='调用次数')
class RedisMonitorInfo(SchemaBase):
"""Redis 监控信息"""
info: RedisServerInfo = Field(description='服务器信息')
stats: list[RedisCommandStat] = Field(description='命令统计列表')
+1 -1
View File
@@ -19,7 +19,7 @@ class OperaLogSchemaBase(SchemaBase):
country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市')
user_agent: str = Field(description='用户代理')
user_agent: str | None = Field(description='用户代理')
os: str | None = Field(None, description='操作系统')
browser: str | None = Field(None, description='浏览器')
device: str | None = Field(None, description='设备')
+1 -3
View File
@@ -146,7 +146,6 @@ class AuthService:
log.error('登陆错误: 用户密码有误')
task = BackgroundTask(
login_log_service.create,
db=db,
user_uuid=user.uuid if user else uuid4_str(),
username=obj.username,
login_time=timezone.now(),
@@ -160,7 +159,6 @@ class AuthService:
else:
background_tasks.add_task(
login_log_service.create,
db=db,
user_uuid=user.uuid,
username=obj.username,
login_time=timezone.now(),
@@ -266,7 +264,7 @@ class AuthService:
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if refresh_token:
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
auth_service: AuthService = AuthService()
@@ -1,6 +1,7 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Table
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_rule import data_rule_dao
@@ -14,8 +15,8 @@ from backend.app.admin.schema.data_rule import (
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.common.security.permission import get_data_permission_models
from backend.core.conf import settings
from backend.utils.import_parse import dynamic_import_data_model
class DataRuleService:
@@ -39,7 +40,8 @@ class DataRuleService:
@staticmethod
async def get_models() -> list[str]:
"""获取所有数据规则可用模型"""
return list(settings.DATA_PERMISSION_MODELS.keys())
model_exclude = ['DataScope', 'DataRule', 'sys_role_data_scope', 'sys_data_scope_rule']
return [m for m in list(get_data_permission_models().keys()) if m not in model_exclude]
@staticmethod
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
@@ -49,13 +51,15 @@ class DataRuleService:
:param model: 模型名称
:return:
"""
if model not in settings.DATA_PERMISSION_MODELS:
available_models = get_data_permission_models()
if model not in available_models:
raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
model_ins = available_models[model]
table = model_ins if isinstance(model_ins, Table) else model_ins.__table__
model_columns = [
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
for column in model_ins.__table__.columns
for column in table.columns
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
]
return model_columns
+4 -5
View File
@@ -1,11 +1,11 @@
from typing import Any
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
@@ -34,7 +34,7 @@ class DeptService:
async def get_tree(
*,
db: AsyncSession,
request_user: GetUserInfoWithRelationDetail,
data_filter: ColumnElement[bool],
name: str | None,
leader: str | None,
phone: str | None,
@@ -44,15 +44,14 @@ class DeptService:
获取部门树形结构
:param db: 数据库会话
:param request_user: 请求用户
:param data_filter: 请求用户
:param name: 部门名称
:param leader: 部门负责人
:param phone: 联系电话
:param status: 状态
:return:
"""
dept_select = await dept_dao.get_all(db, request_user, name, leader, phone, status)
dept_select = await dept_dao.get_all(db, data_filter, name, leader, phone, status)
tree_data = get_tree_data(dept_select)
return tree_data
@@ -8,6 +8,7 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginL
from backend.common.context import ctx
from backend.common.log import log
from backend.common.pagination import paging_data
from backend.database.db import async_db_session
class LoginLogService:
@@ -30,7 +31,6 @@ class LoginLogService:
@staticmethod
async def create(
*,
db: AsyncSession,
user_uuid: str,
username: str,
login_time: datetime,
@@ -40,7 +40,6 @@ class LoginLogService:
"""
创建登录日志
:param db: 数据库会话
:param user_uuid: 用户 UUID
:param username: 用户名
:param login_time: 登录时间
@@ -64,7 +63,9 @@ class LoginLogService:
msg=msg,
login_time=login_time,
)
await login_log_dao.create(db, obj)
# 为后台任务创建独立数据库会话
async with async_db_session.begin() as db:
await login_log_dao.create(db, obj)
except Exception as e:
log.error(f'登录日志创建失败: {e}')
+2 -2
View File
@@ -15,8 +15,8 @@ from backend.common.exception import errors
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client
from backend.plugin.tools import uninstall_requirements_async
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
from backend.plugin.installer import install_git_plugin, install_zip_plugin
from backend.plugin.requirements import uninstall_requirements_async
from backend.utils.timezone import timezone
+2 -4
View File
@@ -1,5 +1,3 @@
import random
from collections.abc import Sequence
from typing import Any
@@ -92,7 +90,6 @@ class UserService:
"""
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
if not obj.password:
raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id):
@@ -100,6 +97,7 @@ class UserService:
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
obj.nickname = obj.nickname or obj.username
await user_dao.add(db, obj)
@staticmethod
@@ -144,7 +142,7 @@ class UserService:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_super(db, pk, is_super=not user.status)
count = await user_dao.set_super(db, pk, is_super=not user.is_superuser)
case UserPermissionType.staff:
user = await user_dao.get(db, pk)
if not user:
+1 -1
View File
@@ -6,7 +6,7 @@ from backend.app.admin.crud.crud_user_password_history import user_password_hist
from backend.common.exception import errors
from backend.core.conf import settings
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.re_verify import is_has_letter, is_has_number, is_has_special_char
from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char
password_hash = PasswordHash((BcryptHasher(),))
+1 -2
View File
@@ -16,8 +16,7 @@
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
2. 新建目录,务必更新 `conf.py` 配置中的 `CELERY_TASKS_PACKAGES`,将新建目录模块路径添加到此列表
3. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
2. 新建目录,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
## 消息代理
+8 -4
View File
@@ -1,9 +1,11 @@
import os
import urllib.parse
import celery
import celery_aio_pool
from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE
from backend.common.enums import DataBaseType
from backend.core.conf import settings
from backend.core.path_conf import BASE_PATH
@@ -27,12 +29,12 @@ def init_celery() -> celery.Celery:
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations()
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}/{settings.CELERY_RABBITMQ_VHOST}'
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{urllib.parse.quote(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}'
broker_url = f'redis://:{urllib.parse.quote(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 = f'db+postgresql+psycopg://{settings.DATABASE_USER}:{urllib.parse.quote(settings.DATABASE_PASSWORD)}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}'
if DataBaseType.mysql == settings.DATABASE_TYPE:
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
@@ -51,6 +53,8 @@ def init_celery() -> celery.Celery:
task_track_started=True,
enable_utc=False,
timezone=settings.DATETIME_TIMEZONE,
worker_send_task_events=True,
task_send_sent_event=True,
)
# 在 Celery 中设置此参数无效
+1 -1
View File
@@ -23,7 +23,7 @@ from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils._await import run_await
from backend.utils.async_helper import run_await
from backend.utils.serializers import select_as_dict
from backend.utils.timezone import timezone
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# work && beat
celery -A backend.app.task.celery worker -l info -P gevent -c 100 &
# beat
celery -A backend.app.task.celery beat -l info &
# flower
celery -A backend.app.task.celery flower --port=8555 --basic-auth=admin:123456
+301 -24
View File
@@ -1,33 +1,43 @@
import asyncio
import re
import secrets
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Literal
import anyio
import cappa
import granian
from cappa.output import error_format
from rich.panel import Panel
from rich.prompt import IntPrompt
from rich.prompt import IntPrompt, Prompt
from rich.table import Table
from rich.text import Text
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from watchfiles import PythonFilter
from backend import __version__
from backend.common.enums import DataBaseType, PrimaryKeyType
from backend.common.exception.errors import BaseExceptionError
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.plugin.code_generator.schema.code import ImportParam
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.code_service import gen_service
from backend.plugin.tools import get_plugin_sql, get_plugins
from backend.utils._await import run_await
from backend.core.path_conf import (
ENV_EXAMPLE_FILE_PATH,
ENV_FILE_PATH,
MYSQL_SCRIPT_DIR,
POSTGRESQL_SCRIPT_DIR,
)
from backend.database.db import async_db_session, create_tables, drop_tables
from backend.database.redis import redis_client
from backend.plugin.core import get_plugin_sql, get_plugins
from backend.plugin.installer import install_git_plugin, install_zip_plugin
from backend.utils.console import console
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script
from backend.utils.dynamic_import import import_module_cached
from backend.utils.sql_parser import parse_sql_script
output_help = '\n更多信息,尝试 "[cyan]--help[/]"'
@@ -39,6 +49,213 @@ class CustomReloadFilter(PythonFilter):
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])
def setup_env_file() -> bool:
if not ENV_EXAMPLE_FILE_PATH.exists():
console.print('.env.example 文件不存在', style='red')
return False
try:
env_content = Path(ENV_EXAMPLE_FILE_PATH).read_text(encoding='utf-8')
console.print('配置数据库连接信息...', style='white')
db_type = Prompt.ask('数据库类型', choices=['mysql', 'postgresql'], default='postgresql')
db_host = Prompt.ask('数据库主机', default='127.0.0.1')
db_port = Prompt.ask('数据库端口', default='5432' if db_type == 'postgresql' else '3306')
db_user = Prompt.ask('数据库用户名', default='postgres' if db_type == 'postgresql' else 'root')
db_password = Prompt.ask('数据库密码', password=True, default='123456')
console.print('配置 Redis 连接信息...', style='white')
redis_host = Prompt.ask('Redis 主机', default='127.0.0.1')
redis_port = Prompt.ask('Redis 端口', default='6379')
redis_password = Prompt.ask('Redis 密码(留空表示无密码)', password=True, default='')
redis_db = Prompt.ask('Redis 数据库编号', default='0')
console.print('生成 Token 密钥...', style='white')
token_secret = secrets.token_urlsafe(32)
console.print('写入 .env 文件...', style='white')
env_content = env_content.replace("DATABASE_TYPE='postgresql'", f"DATABASE_TYPE='{db_type}'")
settings.DATABASE_TYPE = db_type
env_content = env_content.replace("DATABASE_HOST='127.0.0.1'", f"DATABASE_HOST='{db_host}'")
settings.DATABASE_HOST = db_host
env_content = env_content.replace('DATABASE_PORT=5432', f'DATABASE_PORT={db_port}')
settings.DATABASE_PORT = db_port
env_content = env_content.replace("DATABASE_USER='postgres'", f"DATABASE_USER='{db_user}'")
settings.DATABASE_USER = db_user
env_content = env_content.replace("DATABASE_PASSWORD='123456'", f"DATABASE_PASSWORD='{db_password}'")
settings.DATABASE_PASSWORD = db_password
env_content = env_content.replace("REDIS_HOST='127.0.0.1'", f"REDIS_HOST='{redis_host}'")
settings.REDIS_HOST = redis_host
env_content = env_content.replace('REDIS_PORT=6379', f'REDIS_PORT={redis_port}')
settings.REDIS_PORT = redis_port
env_content = env_content.replace("REDIS_PASSWORD=''", f"REDIS_PASSWORD='{redis_password}'")
settings.REDIS_PASSWORD = redis_password
env_content = env_content.replace('REDIS_DATABASE=0', f'REDIS_DATABASE={redis_db}')
settings.REDIS_DATABASE = redis_db
env_content = re.sub(r"TOKEN_SECRET_KEY='[^']*'", f"TOKEN_SECRET_KEY='{token_secret}'", env_content)
settings.TOKEN_SECRET_KEY = token_secret
Path(ENV_FILE_PATH).write_text(env_content, encoding='utf-8')
console.print('.env 文件创建成功', style='green')
except Exception as e:
console.print(f'.env 文件创建失败: {e}', style='red')
return False
else:
return True
async def create_database_if_not_exists() -> bool:
from sqlalchemy import URL
try:
terminate_sql = None
if DataBaseType.mysql == settings.DATABASE_TYPE:
url = URL.create(
drivername='mysql+asyncmy',
username=settings.DATABASE_USER,
password=settings.DATABASE_PASSWORD,
host=settings.DATABASE_HOST,
port=settings.DATABASE_PORT,
)
check_sql = f"SHOW DATABASES LIKE '{settings.DATABASE_SCHEMA}'"
drop_sql = f'DROP DATABASE IF EXISTS `{settings.DATABASE_SCHEMA}`'
create_sql = (
f'CREATE DATABASE `{settings.DATABASE_SCHEMA}` CHARACTER SET {settings.DATABASE_CHARSET} '
f'COLLATE {settings.DATABASE_CHARSET}_unicode_ci'
)
else:
url = URL.create(
drivername='postgresql+asyncpg',
username=settings.DATABASE_USER,
password=settings.DATABASE_PASSWORD,
host=settings.DATABASE_HOST,
port=settings.DATABASE_PORT,
database='postgres',
)
check_sql = f"SELECT 1 FROM pg_database WHERE datname = '{settings.DATABASE_SCHEMA}'"
terminate_sql = (
f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
f"WHERE datname = '{settings.DATABASE_SCHEMA}' AND pid <> pg_backend_pid()"
)
drop_sql = f'DROP DATABASE IF EXISTS {settings.DATABASE_SCHEMA}'
create_sql = f'CREATE DATABASE {settings.DATABASE_SCHEMA}'
engine = create_async_engine(url, isolation_level='AUTOCOMMIT')
try:
async with engine.connect() as conn:
result = await conn.execute(text(check_sql))
exists = result.fetchone() is not None
console.print(f'重建 {settings.DATABASE_SCHEMA} 数据库...', style='white')
if exists:
if terminate_sql:
await conn.execute(text(terminate_sql))
await conn.execute(text(drop_sql))
await conn.execute(text(create_sql))
console.print('数据库创建成功', style='green')
finally:
await engine.dispose()
except Exception as e:
console.print(f'数据库创建失败: {e}', style='red')
return False
else:
return True
async def auto_init() -> None:
"""自动化初始化流程"""
console.print('\n[bold cyan]步骤 1/3:[/] 配置环境变量', style='bold')
panel_content = Text()
panel_content.append('【环境变量配置】', style='bold green')
panel_content.append('\n\n • 数据库连接信息')
panel_content.append('\n • Redis 连接信息')
panel_content.append('\n • Token 密钥(自动生成)')
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 环境变量', border_style='cyan', padding=(1, 2)))
if not setup_env_file():
raise cappa.Exit('.env 文件配置失败', code=1)
console.print('\n[bold cyan]步骤 2/3:[/] 数据库创建', style='bold')
panel_content = Text()
panel_content.append('【数据库配置】', style='bold green')
panel_content.append('\n\n • 类型: ')
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
panel_content.append('\n • 数据库:')
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
panel_content.append('\n • 主机:')
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 数据库', border_style='cyan', padding=(1, 2)))
ok = Prompt.ask('即将[red]新建/重建数据库[/red],确认继续吗?', choices=['y', 'n'], default='n')
if ok.lower() == 'y':
if not await create_database_if_not_exists():
raise cappa.Exit('数据库创建失败', code=1)
else:
console.print('已取消数据库操作', style='yellow')
console.print('\n[bold cyan]步骤 3/3:[/] 初始化数据库表和数据', style='bold')
await init()
async def init() -> None:
panel_content = Text()
panel_content.append('【数据库配置】', style='bold green')
panel_content.append('\n\n • 类型: ')
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
panel_content.append('\n • 数据库:')
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
panel_content.append('\n • 主机:')
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
panel_content.append('\n • 主键模式:')
panel_content.append(
f'{settings.DATABASE_PK_MODE}',
style='yellow',
)
pk_details = panel_content.from_markup(
'[link=https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html](了解详情)[/]'
)
panel_content.append(pk_details)
panel_content.append('\n\n【Redis 配置】', style='bold green')
panel_content.append('\n\n • 数据库:')
panel_content.append(f'{settings.REDIS_DATABASE}', style='yellow')
plugins = get_plugins()
panel_content.append('\n\n【已安装插件】', style='bold green')
panel_content.append('\n\n')
if plugins:
panel_content.append(f'{", ".join(plugins)}', style='yellow')
else:
panel_content.append('', style='dim')
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 初始化', border_style='cyan', padding=(1, 2)))
ok = Prompt.ask(
'即将[red]新建/重建数据库表[/red]并[red]执行所有数据库脚本[/red],确认继续吗?', choices=['y', 'n'], default='n'
)
if ok.lower() == 'y':
console.print('开始初始化...', style='white')
try:
console.print('清理 Redis 缓存', style='white')
await redis_client.delete_prefix(settings.JWT_USER_REDIS_PREFIX)
await redis_client.delete_prefix(settings.TOKEN_EXTRA_INFO_REDIS_PREFIX)
await redis_client.delete_prefix(settings.TOKEN_REDIS_PREFIX)
await redis_client.delete_prefix(settings.TOKEN_REFRESH_REDIS_PREFIX)
console.print('重建数据库表', style='white')
await drop_tables()
await create_tables()
console.print('执行 SQL 脚本', style='white')
sql_scripts = await get_sql_scripts()
for sql_script in sql_scripts:
console.print(f'正在执行:{sql_script}', style='white')
await execute_sql_scripts(sql_script, is_init=True)
console.print('初始化成功', style='green')
console.print('\n快试试 [bold cyan]fba run[/bold cyan] 启动服务吧~')
except Exception as e:
raise cappa.Exit(f'初始化失败:{e}', code=1)
else:
console.print('已取消初始化操作', style='yellow')
def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001
url = f'http://{host}:{port}'
docs_url = url + settings.FASTAPI_DOCS_URL
@@ -71,7 +288,7 @@ def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT0
panel_content.append('\n🌐 架构官方文档: ', style='bold magenta')
panel_content.append('https://fastapi-practices.github.io/fastapi_best_architecture_docs/')
console.print(Panel(panel_content, title=f'fba v{__version__}', border_style='purple', padding=(1, 2)))
console.print(Panel(panel_content, title=f'fba (v{__version__})', border_style='purple', padding=(1, 2)))
granian.Granian(
target='backend.main:app',
interface='asgi',
@@ -124,7 +341,7 @@ async def install_plugin(
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
plugin_name = None
console.print(Text('开始安装插件...', style='bold cyan'))
console.print('开始安装插件...', style='bold cyan')
try:
if path:
@@ -132,18 +349,40 @@ async def install_plugin(
if repo_url:
plugin_name = await install_git_plugin(repo_url=repo_url)
console.print(Text(f'插件 {plugin_name} 安装成功', style='bold green'))
console.print(f'插件 {plugin_name} 安装成功', style='bold green')
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
if sql_file and not no_sql:
console.print(Text('开始自动执行插件 SQL 脚本...', style='bold cyan'))
console.print('开始自动执行插件 SQL 脚本...', style='bold cyan')
await execute_sql_scripts(sql_file)
except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
async def execute_sql_scripts(sql_scripts: str) -> None:
async def get_sql_scripts() -> list[str]:
sql_scripts = []
db_script_dir = MYSQL_SCRIPT_DIR if DataBaseType.mysql == settings.DATABASE_TYPE else POSTGRESQL_SCRIPT_DIR
main_sql_file = (
db_script_dir / 'init_test_data.sql'
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
else db_script_dir / 'init_snowflake_test_data.sql'
)
main_sql_path = anyio.Path(main_sql_file)
if await main_sql_path.exists():
sql_scripts.append(str(main_sql_file))
plugins = get_plugins()
for plugin in plugins:
plugin_sql = await get_plugin_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
if plugin_sql:
sql_scripts.append(str(plugin_sql))
return sql_scripts
async def execute_sql_scripts(sql_scripts: str, *, is_init: bool = False) -> None:
async with async_db_session.begin() as db:
try:
stmts = await parse_sql_script(sql_scripts)
@@ -152,7 +391,8 @@ async def execute_sql_scripts(sql_scripts: str) -> None:
except Exception as e:
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
console.print(Text('SQL 脚本已执行完成', style='bold green'))
if not is_init:
console.print('SQL 脚本已执行完成', style='bold green')
async def import_table(
@@ -160,18 +400,27 @@ async def import_table(
table_schema: str,
table_name: str,
) -> None:
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
try:
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
async with async_db_session.begin() as db:
await gen_service.import_business_and_model(db=db, obj=obj)
console.log('代码生成业务和模型列导入成功', style='bold green')
console.log('\n快试试 [bold cyan]fba codegen[/bold cyan] 生成代码吧~')
except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
def generate() -> None:
async def generate() -> None:
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.gen_service import gen_service
try:
ids = []
results = run_await(gen_business_service.get_all)()
async with async_db_session() as db:
results = await gen_business_service.get_all(db=db)
if not results:
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
@@ -192,14 +441,30 @@ def generate() -> None:
)
console.print(table)
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(_id) for _id in ids])
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(id_) for id_ in ids])
gen_path = run_await(gen_service.generate)(pk=business)
async with async_db_session.begin() as db:
gen_path = await gen_service.generate(db=db, pk=business)
except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
console.print(Text('\n代码已生成完', style='bold green'))
console.print(Text('\n详情请查看:'), Text(gen_path, style='bold magenta'))
console.print('\n代码已生成完', style='bold green')
console.print(Text('\n详情请查看:'), Text(str(gen_path), style='bold magenta'))
@cappa.command(help='初始化 fba 项目', default_long=True)
@dataclass
class Init:
auto: Annotated[
bool,
cappa.Arg(default=False, help='自动化初始化模式:自动创建 .env、安装依赖、创建数据库并初始化表结构'),
]
async def __call__(self) -> None:
if self.auto:
await auto_init()
else:
await init()
@cappa.command(help='运行 API 服务', default_long=True)
@@ -320,17 +585,29 @@ class Import:
cappa.Arg(short='tn', help='数据库表名'),
]
def __post_init__(self) -> None:
try:
import_module_cached('backend.plugin.code_generator')
except ImportError:
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
async def __call__(self) -> None:
await import_table(self.app, self.table_schema, self.table_name)
@cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True)
@dataclass
class CodeGenerate:
class CodeGenerator:
subcmd: cappa.Subcommands[Import | None] = None
def __call__(self) -> None:
generate()
def __post_init__(self) -> None:
try:
import_module_cached('backend.plugin.code_generator')
except ImportError:
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
async def __call__(self) -> None:
await generate()
@cappa.command(help='一个高效的 fba 命令行界面', default_long=True)
@@ -340,7 +617,7 @@ class FbaCli:
str,
cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'),
]
subcmd: cappa.Subcommands[Run | Celery | Add | CodeGenerate | None] = None
subcmd: cappa.Subcommands[Init | Run | Celery | Add | CodeGenerator | None] = None
async def __call__(self) -> None:
if self.sql:
+2 -1
View File
@@ -13,12 +13,13 @@ class TypedContextProtocol(Protocol):
region: str | None
city: str | None
user_agent: str
user_agent: str | None
os: str | None
browser: str | None
device: str | None
permission: str | None
language: str
class TypedContext(TypedContextProtocol, _Context):
+1 -1
View File
@@ -17,7 +17,7 @@ class IpInfo:
@dataclasses.dataclass
class UserAgentInfo:
user_agent: str
user_agent: str | None
os: str | None
browser: str | None
device: str | None
+54 -1
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
from backend.common.context import ctx
@@ -75,7 +76,7 @@ async def _validation_exception_handler(exc: RequestValidationError | Validation
return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_422, content=content)
def register_exception(app: FastAPI) -> None:
def register_exception(app: FastAPI) -> None: # noqa: C901
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
@@ -194,3 +195,55 @@ def register_exception(app: FastAPI) -> None:
status_code=StandardResponseCode.HTTP_500,
content=content,
)
if settings.MIDDLEWARE_CORS:
@app.exception_handler(StandardResponseCode.HTTP_500)
async def cors_custom_code_500_exception_handler(request: Request, exc: BaseExceptionError | Exception):
"""
跨域自定义 500 异常处理
:param request: FastAPI 请求对象
:param exc: 自定义异常
:return:
"""
if isinstance(exc, BaseExceptionError):
content = {
'code': exc.code,
'msg': exc.msg,
'data': exc.data,
}
else:
if settings.ENVIRONMENT == 'dev':
content = {
'code': StandardResponseCode.HTTP_500,
'msg': str(exc),
'data': None,
}
else:
res = response_base.fail(res=CustomResponseCode.HTTP_500)
content = res.model_dump()
content.update(trace_id=get_request_trace_id())
response = MsgSpecJSONResponse(
status_code=exc.code if isinstance(exc, BaseExceptionError) else StandardResponseCode.HTTP_500,
content=content,
background=exc.background if isinstance(exc, BaseExceptionError) else None,
)
origin = request.headers.get('origin')
if origin:
cors = CORSMiddleware(
app=app,
allow_origins=settings.CORS_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
expose_headers=settings.CORS_EXPOSE_HEADERS,
)
response.headers.update(cors.simple_headers)
has_cookie = 'cookie' in request.headers
if cors.allow_all_origins and has_cookie:
response.headers['Access-Control-Allow-Origin'] = origin
elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers.add_vary_header('Origin')
return response
+18 -35
View File
@@ -1,45 +1,27 @@
import glob
import json
from pathlib import Path
from typing import Any
import yaml
from starlette_context.errors import ContextDoesNotExistError
from backend.common.context import ctx
from backend.core.conf import settings
from backend.core.path_conf import LOCALE_DIR
from backend.locale.loader import locale_loader
class I18n:
"""国际化管理器"""
def __init__(self) -> None:
self.locales: dict[str, dict[str, Any]] = {}
self.current_language: str = settings.I18N_DEFAULT_LANGUAGE
self.load_locales()
@property
def current_language(self) -> str:
"""获取当前请求的语言"""
try:
return ctx.language
except (AttributeError, LookupError, ContextDoesNotExistError):
return settings.I18N_DEFAULT_LANGUAGE
def load_locales(self) -> None:
"""加载语言文本"""
patterns = [
LOCALE_DIR / '*.json',
LOCALE_DIR / '*.yaml',
LOCALE_DIR / '*.yml',
]
lang_files = []
for pattern in patterns:
lang_files.extend(glob.glob(str(pattern)))
for lang_file in lang_files:
with open(lang_file, encoding='utf-8') as f:
lang = Path(lang_file).stem
file_type = Path(lang_file).suffix[1:]
match file_type:
case 'json':
self.locales[lang] = json.loads(f.read())
case 'yaml' | 'yml':
self.locales[lang] = yaml.full_load(f.read())
@current_language.setter
def current_language(self, language: str) -> None:
"""设置当前请求的语言"""
ctx.language = language
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
"""
@@ -53,10 +35,10 @@ class I18n:
keys = key.split('.')
try:
translation = self.locales[self.current_language]
translation = locale_loader.locales[self.current_language]
except KeyError:
keys = 'error.language_not_found'
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]
keys = 'error.language_not_found'.split('.')
translation = locale_loader.locales[settings.I18N_DEFAULT_LANGUAGE]
for k in keys:
if isinstance(translation, dict) and k in list(translation.keys()):
@@ -64,6 +46,7 @@ class I18n:
else:
# Pydantic 兼容
translation = None if keys[0] == 'pydantic' else key
break
if translation and kwargs:
translation = translation.format(**kwargs)
+7 -6
View File
@@ -47,6 +47,13 @@ def default_formatter(record: logging.LogRecord) -> str:
return settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n'
def request_id_filter(record: logging.LogRecord) -> logging.LogRecord:
"""请求 ID 过滤器"""
rid = get_request_trace_id()
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
return record
def setup_logging() -> None:
"""
设置日志处理器
@@ -75,12 +82,6 @@ def setup_logging() -> None:
# 移除 loguru 默认处理器
logger.remove()
# request_id 过滤器
def request_id_filter(record: logging.LogRecord) -> logging.LogRecord:
rid = get_request_trace_id()
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
return record
# 配置 loguru 处理器
logger.configure(
handlers=[
+7 -10
View File
@@ -6,6 +6,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
from backend.common.enums import DataBaseType, PrimaryKeyType
from backend.core.conf import settings
from backend.utils.snowflake import snowflake
from backend.utils.timezone import timezone
@@ -23,15 +24,11 @@ id_key = Annotated[
autoincrement=True,
sort_order=-999,
comment='主键 ID',
),
]
# 雪花算法 Mapped 类型主键,使用方法与 id_key 相同
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
snowflake_id_key = Annotated[
int,
mapped_column(
)
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
# 雪花算法 Mapped 类型主键
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
else mapped_column(
BigInteger,
primary_key=True,
unique=True,
@@ -46,7 +43,7 @@ snowflake_id_key = Annotated[
class UniversalText(TypeDecorator[str]):
"""PostgreSQL、MySQL 兼容性(长)文本类型"""
impl = LONGTEXT if settings.DATABASE_TYPE == 'mysql' else Text
impl = LONGTEXT if DataBaseType.mysql == settings.DATABASE_TYPE else Text
cache_ok = True
def process_bind_param(self, value: str | None, dialect) -> str | None: # noqa: ANN001
+35
View File
@@ -0,0 +1,35 @@
from prometheus_client import Counter, Gauge, Histogram
from backend.core.conf import settings
PROMETHEUS_INFO_GAUGE = (
Gauge(name='fba_app_info', documentation='fba 应用信息', labelnames=['app_name'])
.labels(app_name=settings.GRAFANA_APP_NAME)
.inc()
)
PROMETHEUS_REQUEST_IN_PROGRESS_GAUGE = Gauge(
'fba_request_in_progress',
'按方法和路径统计请求的衡量',
['app_name', 'method', 'path'],
)
PROMETHEUS_REQUEST_COUNTER = Counter('fba_request_total', '按方法和路径统计请求总数', ['app_name', 'method', 'path'])
PROMETHEUS_RESPONSE_COUNTER = Counter(
'fba_response_total',
'按方法、路径和状态码统计响应总数',
['app_name', 'method', 'path', 'status_code'],
)
PROMETHEUS_EXCEPTION_COUNTER = Counter(
'fba_exception_total',
'按方法,路径和异常类型统计异常总数',
['app_name', 'method', 'path', 'exception_type'],
)
PROMETHEUS_REQUEST_COST_TIME_HISTOGRAM = Histogram(
'fba_request_cost_time',
'按方法和路径划分请求耗时的直方图(以 ms 为单位)',
['app_name', 'method', 'path'],
)
+4
View File
@@ -2,6 +2,8 @@ import asyncio
from asyncio import Queue
from backend.common.log import log
async def batch_dequeue(queue: Queue, max_items: int, timeout: float) -> list:
"""
@@ -23,5 +25,7 @@ async def batch_dequeue(queue: Queue, max_items: int, timeout: float) -> list:
await asyncio.wait_for(collector(), timeout=timeout)
except asyncio.TimeoutError:
pass
except Exception as e:
log.error(f'队列批量获取失败: {e}')
return items
+15 -3
View File
@@ -3,6 +3,8 @@ from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
from backend.common.enums import PrimaryKeyType
from backend.core.conf import settings
from backend.utils.timezone import timezone
CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
@@ -22,12 +24,22 @@ class SchemaBase(BaseModel):
model_config = ConfigDict(
use_enum_values=True,
json_encoders={
datetime: lambda x: timezone.to_str(timezone.from_datetime(x))
if x.tzinfo is not None and x.tzinfo != timezone.tz_info
else timezone.to_str(x),
datetime: lambda x: (
timezone.to_str(timezone.from_datetime(x))
if x.tzinfo is not None and x.tzinfo != timezone.tz_info
else timezone.to_str(x)
),
},
)
if PrimaryKeyType.snowflake == settings.DATABASE_PK_MODE:
from pydantic import field_serializer
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html#%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9
@field_serializer('id', check_fields=False)
def serialize_id(self, value: int) -> str:
return str(value)
def ser_string(value: Any) -> str | None:
if value:
+79 -35
View File
@@ -1,12 +1,15 @@
from typing import Any
from fastapi import Request
from sqlalchemy import ColumnElement, and_, or_
from sqlalchemy import Alias, ColumnElement, Table, and_, or_
from sqlalchemy.orm.util import AliasedClass
from sqlalchemy_crud_plus.types import Model
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.context import ctx
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.exception import errors
from backend.core.conf import settings
from backend.utils.import_parse import dynamic_import_data_model
from backend.utils.dynamic_import import get_all_models
class RequestPermission:
@@ -41,75 +44,95 @@ class RequestPermission:
ctx.permission = self.value
def filter_data_permission(request_user: GetUserInfoWithRelationDetail) -> ColumnElement[bool]: # noqa: C901
def get_data_permission_models() -> dict[str, object]:
"""获取所有可用于数据权限的模型"""
return {getattr(model, '__name__', str(model)): model for model in get_all_models()}
def filter_data_permission( # noqa: C901
request: Request, *models: type[Model] | AliasedClass | Alias | Table
) -> ColumnElement[bool]:
"""
过滤数据权限控制用户可见数据范围
使用场景
- 控制用户能看到哪些数据
:param request_user: 请求用户
:param request: FastAPI 请求对象
:param models: 需要应用数据权限的模型类
:return:
"""
# 是否过滤数据权限
if request_user.is_superuser:
# 超级管理员不过滤
if request.user.is_superuser:
return or_(1 == 1)
for role in request_user.roles:
# 角色未启用数据权限过滤
for role in request.user.roles:
if not role.is_filter_scopes:
return or_(1 == 1)
# 获取数据规则
data_rules = set()
for role in request_user.roles:
for role in request.user.roles:
for scope in role.scopes:
if scope.status:
data_rules.update(scope.rules)
# 无规则用户不做过滤
if not list(data_rules):
if not data_rules:
return or_(1 == 1)
# 获取目标模型
model_map = (
{getattr(model, '__name__', str(model)): model for model in models} if models else get_data_permission_models()
)
where_and_list = []
where_or_list = []
for data_rule in list(data_rules):
# 验证规则模型
rule_model = data_rule.model
if rule_model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
for data_rule in data_rules:
target_model = model_map.get(data_rule.model)
if target_model is None:
continue
# 验证规则列
model_columns = [
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
]
column = data_rule.column
if column not in model_columns:
raise errors.NotFoundError(msg='数据规则可用模型列不存在')
table = target_model if isinstance(target_model, Table) else target_model.__table__
rule_column = data_rule.column
if rule_column not in table.columns.keys():
continue
if rule_column in settings.DATA_PERMISSION_COLUMN_EXCLUDE:
continue
# 构建过滤条件
column_obj = getattr(model_ins, column)
rule_expression = data_rule.expression
column_obj = (
getattr(target_model, rule_column) if not isinstance(target_model, Table) else table.columns[rule_column]
)
column_type = table.columns[rule_column].type.python_type
def cast_value(value: Any) -> Any:
"""类型转换"""
try:
return column_type(value) if column_type is not str else value
except (ValueError, TypeError):
return value
condition = None
match rule_expression:
match data_rule.expression:
case RoleDataRuleExpressionType.eq:
condition = column_obj == data_rule.value
condition = column_obj == cast_value(data_rule.value)
case RoleDataRuleExpressionType.ne:
condition = column_obj != data_rule.value
condition = column_obj != cast_value(data_rule.value)
case RoleDataRuleExpressionType.gt:
condition = column_obj > data_rule.value
condition = column_obj > cast_value(data_rule.value)
case RoleDataRuleExpressionType.ge:
condition = column_obj >= data_rule.value
condition = column_obj >= cast_value(data_rule.value)
case RoleDataRuleExpressionType.lt:
condition = column_obj < data_rule.value
condition = column_obj < cast_value(data_rule.value)
case RoleDataRuleExpressionType.le:
condition = column_obj <= data_rule.value
condition = column_obj <= cast_value(data_rule.value)
case RoleDataRuleExpressionType.in_:
values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
values = [cast_value(v.strip()) for v in data_rule.value.split(',')]
condition = column_obj.in_(values)
case RoleDataRuleExpressionType.not_in:
values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
values = [cast_value(v.strip()) for v in data_rule.value.split(',')]
condition = column_obj.not_in(values)
# 根据运算符添加到对应列表
@@ -128,3 +151,24 @@ def filter_data_permission(request_user: GetUserInfoWithRelationDetail) -> Colum
where_list.append(or_(*where_or_list))
return or_(*where_list) if where_list else or_(1 == 1)
# 此函数是为了简化调用方式,但目前无法正常工作: https://github.com/fastapi/fastapi/discussions/14438
# def DataPermissionFilter(*models: type[Model] | AliasedClass | Alias | Table) -> type[ColumnElement[bool]]:
# """
# 指定模型的数据权限过滤器
#
# :param models: 模型类(可选,支持多个)
# :return:
# """
# return Annotated[ColumnElement[bool], Depends(partial(filter_data_permission, *models))]
class DataPermissionFilter:
"""指定模型的数据权限过滤器"""
def __init__(self, *models: type[Model] | AliasedClass | Alias | Table) -> None:
self.models = models
async def __call__(self, request: Request) -> ColumnElement[bool]:
return filter_data_permission(request, *self.models)
+1 -1
View File
@@ -6,7 +6,7 @@ from backend.common.exception import errors
from backend.common.log import log
from backend.common.security.jwt import DependsJwtAuth
from backend.core.conf import settings
from backend.utils.import_parse import import_module_cached
from backend.utils.dynamic_import import import_module_cached
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
+3 -1
View File
@@ -1,3 +1,5 @@
import urllib.parse
import socketio
from backend.common.log import log
@@ -8,7 +10,7 @@ from backend.database.redis import redis_client
# 创建 Socket.IO 服务器实例
sio = socketio.AsyncServer(
client_manager=socketio.AsyncRedisManager(
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DATABASE}',
f'redis://:{urllib.parse.quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DATABASE}',
),
async_mode='asgi',
cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS,
+14 -15
View File
@@ -1,3 +1,5 @@
import shutil
from functools import lru_cache
from re import Pattern
from typing import Any, Literal
@@ -5,14 +7,14 @@ from typing import Any, Literal
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BASE_PATH
from backend.core.path_conf import ENV_EXAMPLE_FILE_PATH, ENV_FILE_PATH
class Settings(BaseSettings):
"""全局配置"""
model_config = SettingsConfigDict(
env_file=f'{BASE_PATH}/.env',
env_file=ENV_FILE_PATH,
env_file_encoding='utf-8',
extra='ignore',
case_sensitive=True,
@@ -23,7 +25,7 @@ class Settings(BaseSettings):
# FastAPI
FASTAPI_API_V1_PATH: str = '/api/v1'
FASTAPI_TITLE: str = 'FastAPI'
FASTAPI_TITLE: str = 'fba'
FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture'
FASTAPI_DOCS_URL: str = '/docs'
FASTAPI_REDOC_URL: str = '/redoc'
@@ -42,6 +44,7 @@ class Settings(BaseSettings):
DATABASE_POOL_ECHO: bool | Literal['debug'] = False
DATABASE_SCHEMA: str = 'fba'
DATABASE_CHARSET: str = 'utf8mb4'
DATABASE_PK_MODE: Literal['autoincrement', 'snowflake'] = 'autoincrement'
# .env Redis
REDIS_HOST: str
@@ -111,9 +114,6 @@ class Settings(BaseSettings):
COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
# 数据权限
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
'Dept': 'backend.app.admin.model.Dept',
}
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
'id',
'sort',
@@ -184,9 +184,6 @@ class Settings(BaseSettings):
LOG_ACCESS_FILENAME: str = 'fba_access.log'
LOG_ERROR_FILENAME: str = 'fba_error.log'
# .env 操作日志
OPERA_LOG_ENCRYPT_SECRET_KEY: str # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str
# 操作日志
OPERA_LOG_PATH_EXCLUDE: list[str] = [
'/favicon.ico',
@@ -196,10 +193,8 @@ class Settings(BaseSettings):
f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
f'{FASTAPI_API_V1_PATH}/oauth2/github/callback',
f'{FASTAPI_API_V1_PATH}/oauth2/google/callback',
f'{FASTAPI_API_V1_PATH}/oauth2/linux-do/callback',
]
OPERA_LOG_ENCRYPT_TYPE: int = 1 # 0: AES (性能损耗); 1: md5; 2: ItsDangerous; 3: 不加密, others: 替换为 ******
OPERA_LOG_ENCRYPT_KEY_INCLUDE: list[str] = [ # 将加密接口入参参数对应的值
OPERA_LOG_REDACT_KEYS: list[str] = [
'password',
'old_password',
'new_password',
@@ -217,6 +212,11 @@ class Settings(BaseSettings):
# I18n 配置
I18N_DEFAULT_LANGUAGE: str = 'zh-CN'
# Grafana
GRAFANA_METRICS: bool = False
GRAFANA_APP_NAME: str = 'fba_server'
GRAFANA_OTLP_GRPC_ENDPOINT: str = 'fba_alloy:4317'
##################################################
# [ App ] task
##################################################
@@ -249,15 +249,12 @@ class Settings(BaseSettings):
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_GOOGLE_CLIENT_ID: str
OAUTH2_GOOGLE_CLIENT_SECRET: str
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# 基础配置
OAUTH2_STATE_REDIS_PREFIX: str = 'fba:oauth2:state'
OAUTH2_STATE_EXPIRE_SECONDS: int = 60 * 3 # 3 分钟
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_LOGIN_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str = 'http://localhost:5173/profile'
@@ -293,6 +290,8 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
"""获取全局配置单例"""
if not ENV_FILE_PATH.exists():
shutil.copy(ENV_EXAMPLE_FILE_PATH, ENV_FILE_PATH)
return Settings()
+13 -1
View File
@@ -3,6 +3,12 @@ from pathlib import Path
# 项目根目录
BASE_PATH = Path(__file__).resolve().parent.parent
# 环境变量文件
ENV_FILE_PATH = BASE_PATH / '.env'
# 环境变量示例文件
ENV_EXAMPLE_FILE_PATH = BASE_PATH / '.env.example'
# alembic 迁移文件存放路径
ALEMBIC_VERSION_DIR = BASE_PATH / 'alembic' / 'versions'
@@ -19,4 +25,10 @@ UPLOAD_DIR = STATIC_DIR / 'upload'
PLUGIN_DIR = BASE_PATH / 'plugin'
# 国际化文件目录
LOCALE_DIR = BASE_PATH / 'locale'
LOCALE_DIR = BASE_PATH / 'locale' / 'langs'
# MySQL 脚本目录
MYSQL_SCRIPT_DIR = BASE_PATH / 'sql' / 'mysql'
# PostgreSQL 脚本目录
POSTGRESQL_SCRIPT_DIR = BASE_PATH / 'sql' / 'postgresql'
+39 -22
View File
@@ -9,10 +9,10 @@ import socketio
from fastapi import Depends, FastAPI
from fastapi_limiter import FastAPILimiter
from fastapi_pagination import add_pagination
from prometheus_client import make_asgi_app
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.staticfiles import StaticFiles
from starlette.types import ASGIApp
from starlette_context.middleware import ContextMiddleware
from starlette_context.plugins import RequestIdPlugin
@@ -29,12 +29,14 @@ from backend.middleware.i18n_middleware import I18nMiddleware
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
from backend.middleware.opera_log_middleware import OperaLogMiddleware
from backend.middleware.state_middleware import StateMiddleware
from backend.plugin.tools import build_final_router
from backend.utils.demo_site import demo_site
from backend.utils.health_check import ensure_unique_route_names, http_limit_callback
from backend.utils.openapi import simplify_operation_ids
from backend.plugin.core import build_final_router
from backend.utils.demo_mode import demo_site
from backend.utils.limiter import http_limit_callback
from backend.utils.openapi import ensure_unique_route_names, simplify_operation_ids
from backend.utils.otel import init_otel
from backend.utils.serializers import MsgSpecJSONResponse
from backend.utils.snowflake import snowflake
from backend.utils.trace_id import OtelTraceIdPlugin
@asynccontextmanager
@@ -76,22 +78,7 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
def register_app() -> FastAPI:
"""注册 FastAPI 应用"""
class MyFastAPI(FastAPI):
if settings.MIDDLEWARE_CORS:
# Related issues
# https://github.com/fastapi/fastapi/discussions/7847
# https://github.com/fastapi/fastapi/discussions/8027
def build_middleware_stack(self) -> ASGIApp:
return CORSMiddleware(
super().build_middleware_stack(),
allow_origins=settings.CORS_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
expose_headers=settings.CORS_EXPOSE_HEADERS,
)
app = MyFastAPI(
app = FastAPI(
title=settings.FASTAPI_TITLE,
version=__version__,
description=settings.FASTAPI_DESCRIPTION,
@@ -111,6 +98,9 @@ def register_app() -> FastAPI:
register_page(app)
register_exception(app)
if settings.GRAFANA_METRICS:
register_metrics(app)
return app
@@ -164,15 +154,29 @@ def register_middleware(app: FastAPI) -> None:
app.add_middleware(AccessMiddleware)
# ContextVar
plugins = [OtelTraceIdPlugin()] if settings.GRAFANA_METRICS else [RequestIdPlugin(validate=True)]
app.add_middleware(
ContextMiddleware,
plugins=[RequestIdPlugin(validate=True)],
plugins=plugins,
default_error_response=MsgSpecJSONResponse(
content={'code': StandardResponseCode.HTTP_400, 'msg': 'BAD_REQUEST', 'data': None},
status_code=StandardResponseCode.HTTP_400,
),
)
# CORS
# https://github.com/fastapi-practices/fastapi_best_architecture/pull/789/changes
# https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4031
if settings.MIDDLEWARE_CORS:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
expose_headers=settings.CORS_EXPOSE_HEADERS,
)
def register_router(app: FastAPI) -> None:
"""
@@ -218,3 +222,16 @@ def register_socket_app(app: FastAPI) -> None:
socketio_path='/ws/socket.io',
)
app.mount('/ws', socket_app)
def register_metrics(app: FastAPI) -> None:
"""
注册指标
:param app: FastAPI 应用实例
:return:
"""
metrics_app = make_asgi_app()
app.mount('/metrics', metrics_app)
init_otel(app)
+9 -2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from backend.common.enums import DataBaseType
from backend.common.log import log
from backend.common.model import MappedBase
from backend.core.conf import settings
@@ -26,14 +27,14 @@ def create_database_url(*, unittest: bool = False) -> URL:
:return:
"""
url = URL.create(
drivername='mysql+asyncmy' if settings.DATABASE_TYPE == 'mysql' else 'postgresql+asyncpg',
drivername='mysql+asyncmy' if DataBaseType.mysql == settings.DATABASE_TYPE else 'postgresql+asyncpg',
username=settings.DATABASE_USER,
password=settings.DATABASE_PASSWORD,
host=settings.DATABASE_HOST,
port=settings.DATABASE_PORT,
database=settings.DATABASE_SCHEMA if not unittest else f'{settings.DATABASE_SCHEMA}_test',
)
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
url.update_query_dict({'charset': settings.DATABASE_CHARSET})
return url
@@ -91,6 +92,12 @@ async def create_tables() -> None:
await coon.run_sync(MappedBase.metadata.create_all)
async def drop_tables() -> None:
"""丢弃数据库表"""
async with async_engine.begin() as conn:
await conn.run_sync(MappedBase.metadata.drop_all)
def uuid4_str() -> str:
"""数据库引擎 UUID 类型兼容性解决方案"""
return str(uuid4())
View File
+44
View File
@@ -0,0 +1,44 @@
import glob
import json
from pathlib import Path
from typing import Any
import yaml
from backend.core.path_conf import LOCALE_DIR
class LocaleLoader:
"""语言文件加载器"""
def __init__(self) -> None:
self.locales: dict[str, dict[str, Any]] = {}
self.load_locales()
def load_locales(self) -> None:
"""加载语言文本"""
patterns = [
LOCALE_DIR / '*.json',
LOCALE_DIR / '*.yaml',
LOCALE_DIR / '*.yml',
]
lang_files = []
for pattern in patterns:
lang_files.extend(glob.glob(str(pattern)))
for lang_file in lang_files:
with open(lang_file, encoding='utf-8') as f:
lang = Path(lang_file).stem
file_type = Path(lang_file).suffix[1:]
match file_type:
case 'json':
self.locales[lang] = json.loads(f.read())
case 'yaml' | 'yml':
self.locales[lang] = yaml.full_load(f.read())
# 创建语言加载器单例
locale_loader = LocaleLoader()
+2 -1
View File
@@ -2,7 +2,8 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.text import Text
from backend.core.registrar import register_app
from backend.plugin.tools import get_plugins, install_requirements
from backend.plugin.core import get_plugins
from backend.plugin.requirements import install_requirements
from backend.utils.console import console
from backend.utils.timezone import timezone
+2 -1
View File
@@ -4,6 +4,7 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from backend.common.i18n import i18n
from backend.core.conf import settings
def get_current_language(request: Request) -> str | None:
@@ -15,7 +16,7 @@ def get_current_language(request: Request) -> str | None:
"""
accept_language = request.headers.get('Accept-Language', '')
if not accept_language:
return None
return settings.I18N_DEFAULT_LANGUAGE
languages = [lang.split(';')[0] for lang in accept_language.split(',')]
lang = languages[0].lower().strip()
+88 -29
View File
@@ -1,9 +1,9 @@
import json
import time
from asyncio import Queue
from typing import Any
from asgiref.sync import sync_to_async
from fastapi import Response
from starlette.datastructures import UploadFile
from starlette.middleware.base import BaseHTTPMiddleware
@@ -12,13 +12,19 @@ from starlette.requests import Request
from backend.app.admin.schema.opera_log import CreateOperaLogParam
from backend.app.admin.service.opera_log_service import opera_log_service
from backend.common.context import ctx
from backend.common.enums import OperaLogCipherType, StatusType
from backend.common.enums import StatusType
from backend.common.log import log
from backend.common.prometheus.instruments import (
PROMETHEUS_EXCEPTION_COUNTER,
PROMETHEUS_REQUEST_COST_TIME_HISTOGRAM,
PROMETHEUS_REQUEST_COUNTER,
PROMETHEUS_REQUEST_IN_PROGRESS_GAUGE,
PROMETHEUS_RESPONSE_COUNTER,
)
from backend.common.queue import batch_dequeue
from backend.common.response.response_code import StandardResponseCode
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.utils.encrypt import AESCipher, ItsDCipher, Md5Cipher
from backend.utils.trace_id import get_request_trace_id
@@ -43,6 +49,10 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
else:
method = request.method
args = await self.get_request_args(request)
PROMETHEUS_REQUEST_IN_PROGRESS_GAUGE.labels(
app_name=settings.GRAFANA_APP_NAME, method=method, path=path
).inc()
PROMETHEUS_REQUEST_COUNTER.labels(app_name=settings.GRAFANA_APP_NAME, method=method, path=path).inc()
# 执行请求
code = 200
@@ -63,6 +73,12 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
code = exception.get('code')
msg = exception.get('msg')
log.error(f'请求异常: {msg}')
PROMETHEUS_EXCEPTION_COUNTER.labels(
app_name=settings.GRAFANA_APP_NAME,
method=method,
path=path,
exception_type=type(e).__name__,
).inc()
break
except Exception as e:
elapsed = round((time.perf_counter() - ctx.perf_time) * 1000, 3)
@@ -71,6 +87,20 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
status = StatusType.disable
error = e
log.error(f'请求异常: {e!s}')
PROMETHEUS_EXCEPTION_COUNTER.labels(
app_name=settings.GRAFANA_APP_NAME, method=method, path=path, exception_type=type(e).__name__
).inc()
else:
PROMETHEUS_REQUEST_COST_TIME_HISTOGRAM.labels(
app_name=settings.GRAFANA_APP_NAME, method=method, path=path
).observe(elapsed, exemplar={'TraceID': get_request_trace_id()})
finally:
PROMETHEUS_RESPONSE_COUNTER.labels(
app_name=settings.GRAFANA_APP_NAME, method=method, path=path, status_code=code
).inc()
PROMETHEUS_REQUEST_IN_PROGRESS_GAUGE.labels(
app_name=settings.GRAFANA_APP_NAME, method=method, path=path
).dec()
# 此信息只能在请求后获取
route = request.scope.get('route')
@@ -86,7 +116,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
log.debug(f'接口摘要:[{summary}]')
log.debug(f'请求地址:[{ctx.ip}]')
log.debug(f'请求参数:{args}')
log.info(f'{request.client.host: <15} | {request.method: <8} | {code!s: <6} | {path} | {elapsed:.3f}ms')
log.info(f'{ctx.ip: <15} | {request.method: <8} | {code!s: <6} | {path} | {elapsed:.3f}ms')
if request.method != 'OPTIONS':
log.debug('<-- 请求结束')
@@ -120,7 +150,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
return response
async def get_request_args(self, request: Request) -> dict[str, Any] | None:
async def get_request_args(self, request: Request) -> dict[str, Any] | None: # noqa: C901
"""
获取请求参数
@@ -132,12 +162,12 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
# 查询参数
query_params = dict(request.query_params)
if query_params:
args['query_params'] = await self.desensitization(query_params)
args['query_params'] = self.desensitization(query_params)
# 路径参数
path_params = request.path_params
if path_params:
args['path_params'] = await self.desensitization(path_params)
args['path_params'] = self.desensitization(path_params)
# Tip: .body() 必须在 .form() 之前获取
# https://github.com/encode/starlette/discussions/1933
@@ -148,28 +178,66 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
if body_data:
# 注意:非 json 数据默认使用 data 作为键
if 'application/json' not in content_type:
args['data'] = str(body_data)
args['data'] = body_data.decode('utf-8', 'ignore') if isinstance(body_data, bytes) else str(body_data)
else:
json_data = await request.json()
if isinstance(json_data, dict):
args['json'] = await self.desensitization(json_data)
args['json'] = self.desensitization(json_data)
else:
args['data'] = str(body_data)
args['data'] = str(json_data)
# 表单参数
form_data = await request.form()
if len(form_data) > 0:
serialized_form = {}
for k, v in form_data.items():
form_data = {k: v.filename} if isinstance(v, UploadFile) else {k: v}
if isinstance(v, UploadFile):
serialized_form[k] = {
'filename': v.filename,
'content_type': v.content_type,
'size': v.size,
}
else:
serialized_form[k] = v
if 'multipart/form-data' not in content_type:
args['x-www-form-urlencoded'] = await self.desensitization(form_data)
args['x-www-form-urlencoded'] = self.desensitization(serialized_form)
else:
args['form-data'] = await self.desensitization(form_data)
args['form-data'] = self.desensitization(serialized_form)
if args:
args = self.truncate(args)
return args or None
@staticmethod
@sync_to_async
def truncate(args: dict[str, Any]) -> dict[str, Any]:
"""
截断处理
:param args: 需要截断的请求参数字典
:return:
"""
max_size = 10240 # 数据最大大小(字节)
try:
args_str = json.dumps(args, ensure_ascii=False)
args_size = len(args_str.encode('utf-8'))
if args_size > max_size:
truncated_str = args_str[:max_size]
return {
'_truncated': True,
'_original_size': args_size,
'_max_size': max_size,
'_message': f'数据过大已截断:原始大小 {args_size} 字节,限制 {max_size} 字节',
'data_preview': truncated_str,
}
except Exception as e:
log.error(f'请求参数截断处理失败:{e}')
return args
@staticmethod
def desensitization(args: dict[str, Any]) -> dict[str, Any]:
"""
脱敏处理
@@ -177,20 +245,9 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
:param args: 需要脱敏的参数字典
:return:
"""
for key, value in args.items():
if key in settings.OPERA_LOG_ENCRYPT_KEY_INCLUDE:
match settings.OPERA_LOG_ENCRYPT_TYPE:
case OperaLogCipherType.aes:
args[key] = (AESCipher(settings.OPERA_LOG_ENCRYPT_SECRET_KEY).encrypt(value)).hex()
case OperaLogCipherType.md5:
args[key] = Md5Cipher.encrypt(value)
case OperaLogCipherType.itsdangerous:
args[key] = ItsDCipher(settings.OPERA_LOG_ENCRYPT_SECRET_KEY).encrypt(value)
case OperaLogCipherType.plan:
pass
case _:
args[key] = '******'
for key in args:
if key in settings.OPERA_LOG_REDACT_KEYS:
args[key] = '[REDACTED]'
return args
@classmethod
@@ -208,6 +265,8 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
log.info('自动执行【操作日志批量创建】任务...')
async with async_db_session.begin() as db:
await opera_log_service.bulk_create(db=db, objs=logs)
except Exception as e:
log.error(f'操作日志入库失败,丢失 {len(logs)} 条日志: {e}')
finally:
if not cls.opera_log_queue.empty():
for _ in range(len(logs)):
cls.opera_log_queue.task_done()
View File
+2 -2
View File
@@ -9,8 +9,8 @@ from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.core.conf import settings
from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.plugin.code_generator.schema.code import ImportParam
from backend.plugin.code_generator.service.code_service import gen_service
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
router = APIRouter()
@@ -3,6 +3,7 @@ from collections.abc import Sequence
from sqlalchemy import Row, RowMapping, text
from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.enums import DataBaseType
from backend.core.conf import settings
@@ -18,7 +19,7 @@ class CRUDGen:
:param table_schema: 数据库 schema 名称
:return:
"""
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
sql = """
SELECT table_name AS table_name, table_comment AS table_comment
FROM information_schema.tables
@@ -48,7 +49,7 @@ class CRUDGen:
:param table_name: 表名
:return:
"""
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
sql = """
SELECT table_name AS table_name, table_comment AS table_comment
FROM information_schema.tables
@@ -79,7 +80,7 @@ class CRUDGen:
:param table_name: 表名
:return:
"""
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
sql = """
SELECT column_name AS column_name,
CASE WHEN column_key = 'PRI' THEN 1 ELSE 0 END AS is_pk,
@@ -2,6 +2,7 @@ from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.enums import DataBaseType
from backend.common.exception import errors
from backend.core.conf import settings
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
@@ -32,7 +33,7 @@ class GenColumnService:
@staticmethod
async def get_types() -> list[str]:
"""获取所有列类型"""
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
types = GenMySQLColumnType.get_member_keys()
else:
types = GenPostgreSQLColumnType.get_member_keys()
@@ -14,14 +14,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.exception import errors
from backend.core.path_conf import BASE_PATH
from backend.plugin.code_generator.crud.crud_business import gen_business_dao
from backend.plugin.code_generator.crud.crud_code import gen_dao
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
from backend.plugin.code_generator.crud.crud_gen import gen_dao
from backend.plugin.code_generator.model import GenBusiness
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam
from backend.plugin.code_generator.schema.code import ImportParam
from backend.plugin.code_generator.schema.column import CreateGenColumnParam
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.column_service import gen_column_service
from backend.plugin.code_generator.utils.code_template import gen_template
from backend.plugin.code_generator.utils.gen_template import gen_template
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
@@ -0,0 +1,24 @@
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, default_datetime_column, api_version, gen_path, remark, created_time, updated_time)
values (1, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, `default`, sort, `length`, is_pk, is_nullable, gen_business_id)
values
(1, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 1),
(2, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 1),
(3, 'method', '请求类型', 'String', 'str', null, 4, 32, false, false, 1),
(4, 'title', '操作模块', 'String', 'str', null, 5, 256, false, false, 1),
(5, 'path', '请求路径', 'String', 'str', null, 6, 512, false, false, 1),
(6, 'ip', 'IP地址', 'String', 'str', null, 7, 64, false, false, 1),
(7, 'country', '国家', 'String', 'str', null, 8, 64, false, true, 1),
(8, 'region', '地区', 'String', 'str', null, 9, 64, false, true, 1),
(9, 'city', '城市', 'String', 'str', null, 10, 64, false, true, 1),
(10, 'user_agent', '请求头', 'String', 'str', null, 11, 512, false, false, 1),
(11, 'os', '操作系统', 'String', 'str', null, 12, 64, false, true, 1),
(12, 'browser', '浏览器', 'String', 'str', null, 13, 64, false, true, 1),
(13, 'device', '设备', 'String', 'str', null, 14, 64, false, true, 1),
(14, 'args', '请求参数', 'JSON', 'dict', null, 15, 0, false, true, 1),
(15, 'status', '操作状态(0异常 1正常)', 'INTEGER', 'int', null, 16, 0, false, false, 1),
(16, 'code', '操作状态码', 'String', 'str', null, 17, 32, false, false, 1),
(17, 'msg', '提示消息', 'TEXT', 'str', null, 18, 0, false, true, 1),
(18, 'cost_time', '请求耗时(ms', 'String', 'str', null, 19, 0, false, false, 1),
(19, 'opera_time', '操作时间', 'String', 'str', null, 20, 0, false, false, 1);
@@ -0,0 +1,24 @@
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, default_datetime_column, api_version, gen_path, remark, created_time, updated_time)
values (2112248797819043840, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, `default`, sort, `length`, is_pk, is_nullable, gen_business_id)
values
(2112248797881958400, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 2112248797819043840),
(2112248797944872960, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 2112248797819043840),
(2112248798007787520, 'method', '请求类型', 'String', 'str', null, 4, 32, false, false, 2112248797819043840),
(2112248798070702080, 'title', '操作模块', 'String', 'str', null, 5, 256, false, false, 2112248797819043840),
(2112248798133616640, 'path', '请求路径', 'String', 'str', null, 6, 512, false, false, 2112248797819043840),
(2112248798196531200, 'ip', 'IP地址', 'String', 'str', null, 7, 64, false, false, 2112248797819043840),
(2112248798259445760, 'country', '国家', 'String', 'str', null, 8, 64, false, true, 2112248797819043840),
(2112248798322360320, 'region', '地区', 'String', 'str', null, 9, 64, false, true, 2112248797819043840),
(2112248798385274880, 'city', '城市', 'String', 'str', null, 10, 64, false, true, 2112248797819043840),
(2112248798448189440, 'user_agent', '请求头', 'String', 'str', null, 11, 512, false, false, 2112248797819043840),
(2112248798511104000, 'os', '操作系统', 'String', 'str', null, 12, 64, false, true, 2112248797819043840),
(2112248798574018560, 'browser', '浏览器', 'String', 'str', null, 13, 64, false, true, 2112248797819043840),
(2112248798636933120, 'device', '设备', 'String', 'str', null, 14, 64, false, true, 2112248797819043840),
(2112248798699847680, 'args', '请求参数', 'JSON', 'dict', null, 15, 0, false, true, 2112248797819043840),
(2112248798762762240, 'status', '操作状态(0异常 1正常)', 'INTEGER', 'int', null, 16, 0, false, false, 2112248797819043840),
(2112248798825676800, 'code', '操作状态码', 'String', 'str', null, 17, 32, false, false, 2112248797819043840),
(2112248798888591360, 'msg', '提示消息', 'TEXT', 'str', null, 18, 0, false, true, 2112248797819043840),
(2112248798951505920, 'cost_time', '请求耗时(ms', 'String', 'str', null, 19, 0, false, false, 2112248797819043840),
(2112248799014420480, 'opera_time', '操作时间', 'String', 'str', null, 20, 0, false, false, 2112248797819043840);
@@ -0,0 +1,27 @@
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, default_datetime_column, api_version, gen_path, remark, created_time, updated_time)
values (1, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, "default", sort, "length", is_pk, is_nullable, gen_business_id)
values
(1, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 1),
(2, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 1),
(3, 'method', '请求类型', 'String', 'str', null, 4, 32, false, false, 1),
(4, 'title', '操作模块', 'String', 'str', null, 5, 256, false, false, 1),
(5, 'path', '请求路径', 'String', 'str', null, 6, 512, false, false, 1),
(6, 'ip', 'IP地址', 'String', 'str', null, 7, 64, false, false, 1),
(7, 'country', '国家', 'String', 'str', null, 8, 64, false, true, 1),
(8, 'region', '地区', 'String', 'str', null, 9, 64, false, true, 1),
(9, 'city', '城市', 'String', 'str', null, 10, 64, false, true, 1),
(10, 'user_agent', '请求头', 'String', 'str', null, 11, 512, false, false, 1),
(11, 'os', '操作系统', 'String', 'str', null, 12, 64, false, true, 1),
(12, 'browser', '浏览器', 'String', 'str', null, 13, 64, false, true, 1),
(13, 'device', '设备', 'String', 'str', null, 14, 64, false, true, 1),
(14, 'args', '请求参数', 'JSON', 'dict', null, 15, 0, false, true, 1),
(15, 'status', '操作状态(0异常 1正常)', 'INTEGER', 'int', null, 16, 0, false, false, 1),
(16, 'code', '操作状态码', 'String', 'str', null, 17, 32, false, false, 1),
(17, 'msg', '提示消息', 'TEXT', 'str', null, 18, 0, false, true, 1),
(18, 'cost_time', '请求耗时(ms', 'String', 'str', null, 19, 0, false, false, 1),
(19, 'opera_time', '操作时间', 'String', 'str', null, 20, 0, false, false, 1);
select setval(pg_get_serial_sequence('gen_business', 'id'),coalesce(max(id), 0) + 1, true) from gen_business;
select setval(pg_get_serial_sequence('gen_column', 'id'),coalesce(max(id), 0) + 1, true) from gen_column;
@@ -0,0 +1,24 @@
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, default_datetime_column, api_version, gen_path, remark, created_time, updated_time)
values (2112248797819043840, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, "default", sort, "length", is_pk, is_nullable, gen_business_id)
values
(2112248797881958400, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 2112248797819043840),
(2112248797944872960, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 2112248797819043840),
(2112248798007787520, 'method', '请求类型', 'String', 'str', null, 4, 32, false, false, 2112248797819043840),
(2112248798070702080, 'title', '操作模块', 'String', 'str', null, 5, 256, false, false, 2112248797819043840),
(2112248798133616640, 'path', '请求路径', 'String', 'str', null, 6, 512, false, false, 2112248797819043840),
(2112248798196531200, 'ip', 'IP地址', 'String', 'str', null, 7, 64, false, false, 2112248797819043840),
(2112248798259445760, 'country', '国家', 'String', 'str', null, 8, 64, false, true, 2112248797819043840),
(2112248798322360320, 'region', '地区', 'String', 'str', null, 9, 64, false, true, 2112248797819043840),
(2112248798385274880, 'city', '城市', 'String', 'str', null, 10, 64, false, true, 2112248797819043840),
(2112248798448189440, 'user_agent', '请求头', 'String', 'str', null, 11, 512, false, false, 2112248797819043840),
(2112248798511104000, 'os', '操作系统', 'String', 'str', null, 12, 64, false, true, 2112248797819043840),
(2112248798574018560, 'browser', '浏览器', 'String', 'str', null, 13, 64, false, true, 2112248797819043840),
(2112248798636933120, 'device', '设备', 'String', 'str', null, 14, 64, false, true, 2112248797819043840),
(2112248798699847680, 'args', '请求参数', 'JSON', 'dict', null, 15, 0, false, true, 2112248797819043840),
(2112248798762762240, 'status', '操作状态(0异常 1正常)', 'INTEGER', 'int', null, 16, 0, false, false, 2112248797819043840),
(2112248798825676800, 'code', '操作状态码', 'String', 'str', null, 17, 32, false, false, 2112248797819043840),
(2112248798888591360, 'msg', '提示消息', 'TEXT', 'str', null, 18, 0, false, true, 2112248797819043840),
(2112248798951505920, 'cost_time', '请求耗时(ms', 'String', 'str', null, 19, 0, false, false, 2112248797819043840),
(2112248799014420480, 'opera_time', '操作时间', 'String', 'str', null, 20, 0, false, false, 2112248797819043840);
@@ -1,5 +1,6 @@
from functools import lru_cache
from backend.common.enums import DataBaseType
from backend.core.conf import settings
from backend.plugin.code_generator.enums import GenMySQLColumnType, GenPostgreSQLColumnType
@@ -12,7 +13,7 @@ def sql_type_to_sqlalchemy(typing: str) -> str:
:param typing: SQL 类型字符串
:return:
"""
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
if typing in GenMySQLColumnType.get_member_keys():
return typing
else:
@@ -30,7 +31,7 @@ def sql_type_to_pydantic(typing: str) -> str:
:return:
"""
try:
if settings.DATABASE_TYPE == 'mysql':
if DataBaseType.mysql == settings.DATABASE_TYPE:
return GenMySQLColumnType[typing].value
if typing == 'CHARACTER VARYING': # postgresql 中 DDL VARCHAR 的别名
return 'str'
+1 -1
View File
@@ -1,6 +1,6 @@
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
values
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(1, '状态', 'EMAIL', 'EMAIL_CONFIG_STATUS', '1', false, null, now(), null),
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
@@ -1,6 +1,6 @@
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
values
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_CONFIG_STATUS', '1', false, null, now(), null),
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
@@ -1,6 +1,6 @@
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
values
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(1, '状态', 'EMAIL', 'EMAIL_CONFIG_STATUS', '1', false, null, now(), null),
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
@@ -1,6 +1,6 @@
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
values
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_STATUS', '1', false, null, now(), null),
(2069061886627938304, '状态', 'EMAIL', 'EMAIL_CONFIG_STATUS', '1', false, null, now(), null),
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
@@ -1,19 +1,14 @@
import json
import os
import subprocess
import sys
import warnings
from functools import lru_cache
from importlib.metadata import PackageNotFoundError, distribution
from typing import Any
import anyio
import rtoml
from fastapi import APIRouter, Depends, Request
from packaging.requirements import Requirement
from starlette.concurrency import run_in_threadpool
from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType
from backend.common.exception import errors
@@ -21,8 +16,8 @@ from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import RedisCli, redis_client
from backend.utils._await import run_await
from backend.utils.import_parse import get_model_objects, import_module_cached
from backend.utils.async_helper import run_await
from backend.utils.dynamic_import import get_model_objects, import_module_cached
class PluginConfigError(Exception):
@@ -33,10 +28,6 @@ class PluginInjectError(Exception):
"""插件注入错误"""
class PluginInstallError(Exception):
"""插件安装错误"""
@lru_cache
def get_plugins() -> list[str]:
"""获取插件列表"""
@@ -55,15 +46,15 @@ def get_plugins() -> list[str]:
return plugin_packages
def get_plugin_models() -> list[type]:
def get_plugin_models() -> list[object]:
"""获取插件所有模型类"""
objs = []
for plugin in get_plugins():
module_path = f'backend.plugin.{plugin}.model'
obj = get_model_objects(module_path)
if obj:
objs.extend(obj)
model_objs = get_model_objects(module_path)
if model_objs:
objs.extend(model_objs)
return objs
@@ -79,16 +70,16 @@ async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKey
"""
if db_type == DataBaseType.mysql:
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
if pk_type == PrimaryKeyType.autoincrement:
sql_file = mysql_dir / 'init.sql'
else:
sql_file = mysql_dir / 'init_snowflake.sql'
sql_file = (
mysql_dir / 'init.sql' if pk_type == PrimaryKeyType.autoincrement else mysql_dir / 'init_snowflake.sql'
)
else:
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
if pk_type == PrimaryKeyType.autoincrement:
sql_file = postgresql_dir / 'init.sql'
else:
sql_file = postgresql_dir / 'init_snowflake.sql'
sql_file = (
postgresql_dir / 'init.sql'
if pk_type == PrimaryKeyType.autoincrement
else postgresql_dir / 'init_snowflake.sql'
)
path = anyio.Path(sql_file)
if not await path.exists():
@@ -278,155 +269,6 @@ def build_final_router() -> APIRouter:
return main_router
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
"""
安装插件依赖
:param plugin: 指定插件名否则检查所有插件
:return:
"""
plugins = [plugin] if plugin else get_plugins()
for plugin in plugins:
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
missing_dependencies = False
if os.path.exists(requirements_file):
with open(requirements_file, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
try:
req = Requirement(line)
dependency = req.name.lower()
except Exception as e:
raise PluginInstallError(f'插件 {plugin} 依赖 {line} 格式错误: {e!s}') from e
try:
distribution(dependency)
except PackageNotFoundError:
missing_dependencies = True
if missing_dependencies:
try:
if not _ensure_pip_available():
raise PluginInstallError(f'pip 安装失败,无法继续安装插件 {plugin} 依赖')
pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file]
if settings.PLUGIN_PIP_CHINA:
pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
max_retries = settings.PLUGIN_PIP_MAX_RETRY
for attempt in range(max_retries):
try:
subprocess.check_call(
pip_install,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
break
except subprocess.TimeoutExpired:
if attempt == max_retries - 1:
raise PluginInstallError(f'插件 {plugin} 依赖安装超时')
continue
except subprocess.CalledProcessError as e:
if attempt == max_retries - 1:
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
continue
except subprocess.CalledProcessError as e:
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
def uninstall_requirements(plugin: str) -> None:
"""
卸载插件依赖
:param plugin: 插件名称
:return:
"""
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
if os.path.exists(requirements_file):
try:
pip_uninstall = [sys.executable, '-m', 'pip', 'uninstall', '-r', requirements_file, '-y']
subprocess.check_call(pip_uninstall, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
raise PluginInstallError(f'插件 {plugin} 依赖卸载失败:{e}') from e
async def install_requirements_async(plugin: str | None = None) -> None:
"""
异步安装插件依赖
由于 Windows 平台限制无法实现完美的全异步方案详情
https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows
"""
await run_in_threadpool(install_requirements, plugin)
async def uninstall_requirements_async(plugin: str) -> None:
"""
异步卸载插件依赖
:param plugin: 插件名称
:return:
"""
await run_in_threadpool(uninstall_requirements, plugin)
class PluginStatusChecker:
"""插件状态检查器"""
+100
View File
@@ -0,0 +1,100 @@
import io
import os
import re
import zipfile
import anyio
from anyio import open_file
from dulwich import porcelain
from fastapi import UploadFile
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client
from backend.plugin.requirements import install_requirements_async
from backend.utils.pattern_validate import is_git_url
async def install_zip_plugin(file: UploadFile | str) -> str:
"""
安装 ZIP 插件
:param file: FastAPI 上传文件对象或文件完整路径
:return:
"""
if isinstance(file, str):
async with await open_file(file, mode='rb') as fb:
contents = await fb.read()
else:
contents = await file.read()
file_bytes = io.BytesIO(contents)
if not zipfile.is_zipfile(file_bytes):
raise errors.RequestError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_namelist = zf.namelist()
plugin_dir_name = plugin_namelist[0].split('/')[0]
if not plugin_namelist:
raise errors.RequestError(msg='插件压缩包内容非法')
if (
len(plugin_namelist) <= 3
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
or f'{plugin_dir_name}/README.md' not in plugin_namelist
):
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
plugin_name = re.match(
r'^([a-zA-Z0-9_]+)',
file.split(os.sep)[-1].split('.')[0].strip()
if isinstance(file, str)
else file.filename.split('.')[0].strip(),
).group()
full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name)
if await full_plugin_path.exists():
raise errors.ConflictError(msg='此插件已安装')
await full_plugin_path.mkdir(parents=True, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir_name):
new_filename = member.filename.replace(plugin_dir_name, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(full_plugin_path, members)
await install_requirements_async(plugin_dir_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
return plugin_name
async def install_git_plugin(repo_url: str) -> str:
"""
安装 Git 插件
:param repo_url:
:return:
"""
match = is_git_url(repo_url)
if not match:
raise errors.RequestError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
path = anyio.Path(PLUGIN_DIR / repo_name)
if await path.exists():
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True)
except Exception as e:
log.error(f'插件安装失败: {e}')
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
await install_requirements_async(repo_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
return repo_name
+18
View File
@@ -0,0 +1,18 @@
insert into sys_notice (id, title, type, status, content, created_time, updated_time)
values (1, 'hahahahahaahahaha', 0, 1, '你好😄
```
print(''fba yyds'')
```
| col1 | col2 | col3 |
| ---- | ---- | ---- |
| | | |
| | | |
* 1
* 2
* 3
', '2025-12-15 15:33:16', null);
@@ -0,0 +1,18 @@
insert into sys_notice (id, title, type, status, content, created_time, updated_time)
values (2112248797756129280, 'hahahahahaahahaha', 0, 1, '你好😄
```
print(''fba yyds'')
```
| col1 | col2 | col3 |
| ---- | ---- | ---- |
| | | |
| | | |
* 1
* 2
* 3
', '2025-12-15 15:33:16', null);
@@ -0,0 +1,20 @@
insert into sys_notice (id, title, type, status, content, created_time, updated_time)
values (1, 'hahahahahaahahaha', 0, 1, '你好😄
```
print(''fba yyds'')
```
| col1 | col2 | col3 |
| ---- | ---- | ---- |
| | | |
| | | |
* 1
* 2
* 3
', '2025-12-15 15:33:16', null);
select setval(pg_get_serial_sequence('sys_notice', 'id'),coalesce(max(id), 0) + 1, true) from sys_notice;
@@ -0,0 +1,18 @@
insert into sys_notice (id, title, type, status, content, created_time, updated_time)
values (2112248797756129280, 'hahahahahaahahaha', 0, 1, '你好😄
```
print(''fba yyds'')
```
| col1 | col2 | col3 |
| ---- | ---- | ---- |
| | | |
| | | |
* 1
* 2
* 3
', '2025-12-15 15:33:16', null);
-2
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter
from backend.core.conf import settings
from backend.plugin.oauth2.api.v1.github import router as github_router
from backend.plugin.oauth2.api.v1.google import router as google_router
from backend.plugin.oauth2.api.v1.linux_do import router as linux_do_router
from backend.plugin.oauth2.api.v1.user_social import router as user_social_router
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/oauth2')
@@ -11,4 +10,3 @@ v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/oauth2')
v1.include_router(user_social_router, tags=['OAuth2'])
v1.include_router(github_router, prefix='/github', tags=['Github OAuth2'])
v1.include_router(google_router, prefix='/google', tags=['Google OAuth2'])
v1.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2'])
-73
View File
@@ -1,73 +0,0 @@
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from fastapi_limiter.depends import RateLimiter
from fastapi_oauth20 import FastAPIOAuth20, LinuxDoOAuth20
from starlette.responses import RedirectResponse
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSessionTransaction
from backend.database.redis import redis_client
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
router = APIRouter()
linux_do_client = LinuxDoOAuth20(settings.OAUTH2_LINUX_DO_CLIENT_ID, settings.OAUTH2_LINUX_DO_CLIENT_SECRET)
@router.get('', summary='获取 LinuxDo 授权链接')
async def get_linux_do_oauth2_url() -> ResponseSchemaModel[str]:
state = str(uuid.uuid4())
await redis_client.setex(
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
settings.OAUTH2_STATE_EXPIRE_SECONDS,
json.dumps({'type': UserSocialAuthType.login.value}),
)
auth_url = await linux_do_client.get_authorization_url(
redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI, state=state
)
return response_base.success(data=auth_url)
@router.get(
'/callback',
summary='LinuxDo 授权自动重定向',
description='LinuxDo 授权后,自动重定向到当前地址并获取用户信息,通过用户信息自动创建系统用户',
dependencies=[Depends(RateLimiter(times=5, minutes=1))],
)
async def linux_do_oauth2_callback( # noqa: ANN201
db: CurrentSessionTransaction,
response: Response,
background_tasks: BackgroundTasks,
oauth2: Annotated[
FastAPIOAuth20,
Depends(FastAPIOAuth20(linux_do_client, redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)),
],
):
token_data, state = oauth2
access_token = token_data['access_token']
user = await linux_do_client.get_userinfo(access_token)
data = await oauth2_service.login_or_binding(
db=db,
response=response,
background_tasks=background_tasks,
user=user,
social=UserSocialType.linux_do,
state=state,
)
# 绑定流程
if data is None:
return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI)
# 登录流程
return RedirectResponse(
url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
)
-1
View File
@@ -6,7 +6,6 @@ class UserSocialType(StrEnum):
github = 'Github'
google = 'Google'
linux_do = 'LinuxDo'
class UserSocialAuthType(StrEnum):
@@ -107,7 +107,6 @@ class OAuth2Service:
await db.refresh(sys_user)
background_tasks.add_task(
login_log_service.create,
db=db,
user_uuid=sys_user.uuid,
username=sys_user.username,
login_time=timezone.now(),
@@ -168,9 +167,6 @@ class OAuth2Service:
username = user.get('name')
nickname = user.get('given_name')
avatar = user.get('picture')
case UserSocialType.linux_do:
sid = user.get('id')
nickname = user.get('name')
case _:
raise errors.ForbiddenError(msg=f'暂不支持 {social} OAuth2 登录')
@@ -90,13 +90,6 @@ class UserSocialService:
redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI,
state=state,
)
case UserSocialType.linux_do:
from backend.plugin.oauth2.api.v1.linux_do import linux_do_client
auth_url = await linux_do_client.get_authorization_url(
redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI,
state=state,
)
case _:
raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定')
+116
View File
@@ -0,0 +1,116 @@
import os
import subprocess
from importlib.metadata import PackageNotFoundError, distribution
from packaging.requirements import Requirement
from starlette.concurrency import run_in_threadpool
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
class PluginInstallError(Exception):
"""插件安装错误"""
def get_plugins() -> list[str]:
"""
获取插件列表
注意此函数从 backend.plugin.core 导入以避免循环依赖
"""
from backend.plugin.core import get_plugins as _get_plugins
return _get_plugins()
def install_requirements(plugin: str | None) -> None: # noqa: C901
"""
安装插件依赖
:param plugin: 指定插件名否则检查所有插件
:return:
"""
plugins = [plugin] if plugin else get_plugins()
for plugin in plugins:
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
missing_dependencies = False
if os.path.exists(requirements_file):
with open(requirements_file, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
try:
req = Requirement(line)
dependency = req.name.lower()
except Exception as e:
raise PluginInstallError(f'插件 {plugin} 依赖 {line} 格式错误: {e!s}') from e
try:
distribution(dependency)
except PackageNotFoundError:
missing_dependencies = True
if missing_dependencies:
try:
pip_install = ['uv', 'pip', 'install', '-r', requirements_file]
if settings.PLUGIN_PIP_CHINA:
pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
max_retries = settings.PLUGIN_PIP_MAX_RETRY
for attempt in range(max_retries):
try:
subprocess.check_call(
pip_install,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
break
except subprocess.TimeoutExpired:
if attempt == max_retries - 1:
raise PluginInstallError(f'插件 {plugin} 依赖安装超时')
continue
except subprocess.CalledProcessError as e:
if attempt == max_retries - 1:
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
continue
except subprocess.CalledProcessError as e:
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
def uninstall_requirements(plugin: str) -> None:
"""
卸载插件依赖
:param plugin: 插件名称
:return:
"""
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
if os.path.exists(requirements_file):
try:
pip_uninstall = ['uv', 'pip', 'uninstall', '-r', str(requirements_file), '-y']
subprocess.check_call(pip_uninstall, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
raise PluginInstallError(f'插件 {plugin} 依赖卸载失败:{e}') from e
async def install_requirements_async(plugin: str | None = None) -> None:
"""
异步安装插件依赖
由于 Windows 平台限制无法实现完美的全异步方案详情
https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows
"""
await run_in_threadpool(install_requirements, plugin)
async def uninstall_requirements_async(plugin: str) -> None:
"""
异步卸载插件依赖
:param plugin: 插件名称
:return:
"""
await run_in_threadpool(uninstall_requirements, plugin)
@@ -1,9 +1,12 @@
import importlib
import inspect
import os.path
from functools import lru_cache
from typing import Any, TypeVar
import sqlalchemy as sa
from backend.common.exception import errors
from backend.common.log import log
@@ -37,7 +40,7 @@ def dynamic_import_data_model(module_path: str) -> type[T]:
raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员')
def get_model_objects(module_path: str) -> list[type] | None:
def get_model_objects(module_path: str) -> list[object] | None:
"""
获取模型对象
@@ -47,15 +50,43 @@ def get_model_objects(module_path: str) -> list[type] | None:
try:
module = import_module_cached(module_path)
except ModuleNotFoundError:
log.warning(f'模块 {module_path} 中不包含模型对象')
return None
except Exception:
raise
except Exception as e:
raise e from None
classes = []
for _name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and module_path in obj.__module__:
if (inspect.isclass(obj) and module_path in obj.__module__) or (
isinstance(obj, sa.Table) and obj.metadata is not None
):
classes.append(obj)
return classes
def get_app_models() -> list[object]:
"""获取 app 所有模型类"""
from backend.core.path_conf import BASE_PATH
app_path = BASE_PATH / 'app'
list_dirs = os.listdir(app_path)
apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
objs = []
for app in apps:
module_path = f'backend.app.{app}.model'
model_objs = get_model_objects(module_path)
if model_objs:
objs.extend(model_objs)
return objs
@lru_cache
def get_all_models() -> list[object]:
"""获取所有模型类"""
from backend.plugin.core import get_plugin_models
return get_app_models() + get_plugin_models()
+1 -119
View File
@@ -1,23 +1,11 @@
import io
import os
import re
import zipfile
import anyio
from anyio import open_file
from dulwich import porcelain
from fastapi import UploadFile
from sqlparse import split
from backend.common.enums import FileType
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR
from backend.database.redis import redis_client
from backend.plugin.tools import install_requirements_async
from backend.utils.re_verify import is_git_url
from backend.core.path_conf import UPLOAD_DIR
from backend.utils.timezone import timezone
@@ -79,109 +67,3 @@ async def upload_file(file: UploadFile) -> str:
raise errors.RequestError(msg='上传文件失败')
await file.close()
return filename
async def install_zip_plugin(file: UploadFile | str) -> str:
"""
安装 ZIP 插件
:param file: FastAPI 上传文件对象或文件完整路径
:return:
"""
if isinstance(file, str):
async with await open_file(file, mode='rb') as fb:
contents = await fb.read()
else:
contents = await file.read()
file_bytes = io.BytesIO(contents)
if not zipfile.is_zipfile(file_bytes):
raise errors.RequestError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_namelist = zf.namelist()
plugin_dir_name = plugin_namelist[0].split('/')[0]
if not plugin_namelist:
raise errors.RequestError(msg='插件压缩包内容非法')
if (
len(plugin_namelist) <= 3
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
or f'{plugin_dir_name}/README.md' not in plugin_namelist
):
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
plugin_name = re.match(
r'^([a-zA-Z0-9_]+)',
file.split(os.sep)[-1].split('.')[0].strip()
if isinstance(file, str)
else file.filename.split('.')[0].strip(),
).group()
full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name)
if await full_plugin_path.exists():
raise errors.ConflictError(msg='此插件已安装')
await full_plugin_path.mkdir(parents=True, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir_name):
new_filename = member.filename.replace(plugin_dir_name, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(full_plugin_path, members)
await install_requirements_async(plugin_dir_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
return plugin_name
async def install_git_plugin(repo_url: str) -> str:
"""
安装 Git 插件
:param repo_url:
:return:
"""
match = is_git_url(repo_url)
if not match:
raise errors.RequestError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
path = anyio.Path(PLUGIN_DIR / repo_name)
if await path.exists():
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True)
except Exception as e:
log.error(f'插件安装失败: {e}')
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
await install_requirements_async(repo_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
return repo_name
async def parse_sql_script(filepath: str) -> list[str]:
"""
解析 SQL 脚本
:param filepath: 脚本文件路径
:return:
"""
path = anyio.Path(filepath)
if not await path.exists():
raise errors.NotFoundError(msg='SQL 脚本文件不存在')
async with await open_file(filepath, encoding='utf-8') as f:
contents = await f.read(1024)
while additional_contents := await f.read(1024):
contents += additional_contents
statements = split(contents)
for statement in statements:
if not any(statement.lower().startswith(_) for _ in ['select', 'insert']):
raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT')
return statements
+24
View File
@@ -0,0 +1,24 @@
def fmt_seconds(seconds: int) -> str:
"""格式化秒数为可读的时间字符串"""
days, rem = divmod(int(seconds), 86400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
parts = []
if days:
parts.append(f'{days}')
if hours:
parts.append(f'{hours} 小时')
if minutes:
parts.append(f'{minutes} 分钟')
if secs:
parts.append(f'{secs}')
return ' '.join(parts) if parts else '0 秒'
def fmt_bytes(size: float) -> str:
s, factor = size, 1024
for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
if abs(s) < factor:
return f'{s:.2f} {unit}B'
s /= factor
return f'{s:.2f} YB'
+23
View File
@@ -0,0 +1,23 @@
from math import ceil
from fastapi import Request, Response
from backend.common.exception import errors
from backend.common.response.response_code import StandardResponseCode
async def http_limit_callback(request: Request, response: Response, expire: int) -> None: # noqa: RUF029
"""
请求限制时的默认回调函数
:param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:param expire: 剩余毫秒数
:return:
"""
expires = ceil(expire / 1000)
raise errors.HTTPError(
code=StandardResponseCode.HTTP_429,
msg='请求过于频繁,请稍后重试',
headers={'Retry-After': str(expires)},
)
+15
View File
@@ -12,3 +12,18 @@ def simplify_operation_ids(app: FastAPI) -> None:
for route in app.routes:
if isinstance(route, APIRoute):
route.operation_id = route.name
def ensure_unique_route_names(app: FastAPI) -> None:
"""
检查路由名称是否唯一
:param app: FastAPI 应用实例
:return:
"""
temp_routes = set()
for route in app.routes:
if isinstance(route, APIRoute):
if route.name in temp_routes:
raise ValueError(f'Non-unique route name: {route.name}')
temp_routes.add(route.name)
+102
View File
@@ -0,0 +1,102 @@
from fastapi import FastAPI
from opentelemetry import _logs, metrics, trace
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs._internal.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from backend.common.log import log, request_id_filter
from backend.core.conf import settings
from backend.database.db import async_engine
from backend.database.redis import redis_client
def _init_tracer(resource: Resource) -> None:
"""
初始化追踪器
:param resource: 遥测资源
:return:
"""
tracer_provider = TracerProvider(resource=resource)
span_exporter = OTLPSpanExporter(endpoint=settings.GRAFANA_OTLP_GRPC_ENDPOINT, insecure=True)
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
trace.set_tracer_provider(tracer_provider)
def _init_metrics(resource: Resource) -> None:
"""
初始化指标
:param resource: 遥测资源
:return:
"""
metric_exporter = OTLPMetricExporter(endpoint=settings.GRAFANA_OTLP_GRPC_ENDPOINT, insecure=True)
meter_provider = MeterProvider(
resource=resource,
metric_readers=[PeriodicExportingMetricReader(metric_exporter)],
)
metrics.set_meter_provider(meter_provider)
def _init_logging(resource: Resource) -> None:
"""
初始化日志
:param resource: 遥测资源
:return:
"""
logger_provider = LoggerProvider(resource=resource)
logger_exporter = OTLPLogExporter(endpoint=settings.GRAFANA_OTLP_GRPC_ENDPOINT, insecure=True)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(logger_exporter))
_logs.set_logger_provider(logger_provider)
otel_logging_handler = LoggingHandler(logger_provider=logger_provider)
log.add( # type: ignore
otel_logging_handler,
level=settings.LOG_STD_LEVEL,
format=settings.LOG_FORMAT,
filter=lambda record: request_id_filter(record),
)
def init_otel(app: FastAPI) -> None:
"""
初始化 OpenTelemetry
:param app: FastAPI 应用实例
:return:
"""
from backend import __version__
resource = Resource(
attributes={
'service.name': settings.GRAFANA_APP_NAME,
'service.version': __version__,
'deployment.environment': settings.ENVIRONMENT,
},
)
_init_tracer(resource)
# _init_metrics(resource)
_init_logging(resource)
LoggingInstrumentor().instrument(set_logging_format=True)
SQLAlchemyInstrumentor().instrument(engine=async_engine.sync_engine)
RedisInstrumentor.instrument_client(redis_client) # type: ignore
HTTPXClientInstrumentor().instrument()
FastAPIInstrumentor.instrument_app(app)
@@ -3,47 +3,9 @@ import functools
import time
from collections.abc import Callable
from math import ceil
from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.routing import APIRoute
from backend.common.exception import errors
from backend.common.log import log
from backend.common.response.response_code import StandardResponseCode
def ensure_unique_route_names(app: FastAPI) -> None:
"""
检查路由名称是否唯一
:param app: FastAPI 应用实例
:return:
"""
temp_routes = set()
for route in app.routes:
if isinstance(route, APIRoute):
if route.name in temp_routes:
raise ValueError(f'Non-unique route name: {route.name}')
temp_routes.add(route.name)
async def http_limit_callback(request: Request, response: Response, expire: int) -> None: # noqa: RUF029
"""
请求限制时的默认回调函数
:param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:param expire: 剩余毫秒数
:return:
"""
expires = ceil(expire / 1000)
raise errors.HTTPError(
code=StandardResponseCode.HTTP_429,
msg='请求过于频繁,请稍后重试',
headers={'Retry-After': str(expires)},
)
def timer(func) -> Callable: # noqa: ANN001
-52
View File
@@ -1,52 +0,0 @@
from backend.database.redis import redis_client
from backend.utils.server_info import server_info
class RedisInfo:
@staticmethod
async def get_info() -> dict[str, str]:
"""获取 Redis 服务器信息"""
# 获取原始信息
info = await redis_client.info()
# 格式化信息
fmt_info: dict[str, str] = {}
for key, value in info.items():
if isinstance(value, dict):
# 将字典格式化为字符串
fmt_info[key] = ','.join(f'{k}={v}' for k, v in value.items())
else:
fmt_info[key] = str(value)
# 添加数据库大小信息
db_size = await redis_client.dbsize()
fmt_info['keys_num'] = str(db_size)
# 格式化运行时间
uptime = int(fmt_info.get('uptime_in_seconds', '0'))
fmt_info['uptime_in_seconds'] = server_info.fmt_seconds(uptime)
return fmt_info
@staticmethod
async def get_stats() -> list[dict[str, str]]:
"""获取 Redis 命令统计信息"""
# 获取命令统计信息
command_stats = await redis_client.info('commandstats')
# 格式化统计信息
stats_list: list[dict[str, str]] = []
for key, value in command_stats.items():
if not isinstance(value, dict):
continue
command_name = key.split('_')[-1]
call_count = str(value.get('calls', '0'))
stats_list.append({'name': command_name, 'value': call_count})
return stats_list
redis_info: RedisInfo = RedisInfo()
+9 -10
View File
@@ -32,19 +32,16 @@ def get_request_ip(request: Request) -> str:
return request.client.host
async def get_location_online(ip: str, user_agent: str) -> dict | None:
async def get_location_online(ip: str) -> dict | None:
"""
在线获取 IP 地址属地无法保证可用性准确率较高
:param ip: IP 地址
:param user_agent: 用户代理字符串
:return:
"""
async with httpx.AsyncClient(timeout=3) as client:
ip_api_url = f'http://ip-api.com/json/{ip}?lang=zh-CN'
headers = {'User-Agent': user_agent}
try:
response = await client.get(ip_api_url, headers=headers)
response = await client.get(f'http://ip-api.com/json/{ip}?lang=zh-CN')
if response.status_code == 200:
return response.json()
except Exception as e:
@@ -92,7 +89,7 @@ async def parse_ip_info(request: Request) -> IpInfo:
location_info = None
if settings.IP_LOCATION_PARSE == 'online':
location_info = await get_location_online(ip, request.headers.get('User-Agent'))
location_info = await get_location_online(ip)
elif settings.IP_LOCATION_PARSE == 'offline':
location_info = get_location_offline(ip)
@@ -115,9 +112,11 @@ def parse_user_agent_info(request: Request) -> UserAgentInfo:
:param request: FastAPI 请求对象
:return:
"""
os, browser, device = None, None, None
user_agent = request.headers.get('User-Agent')
user_agent_ = parse(user_agent)
os = user_agent_.get_os()
browser = user_agent_.get_browser()
device = user_agent_.get_device()
if user_agent:
user_agent_ = parse(user_agent)
os = user_agent_.get_os()
browser = user_agent_.get_browser()
device = user_agent_.get_device()
return UserAgentInfo(user_agent=user_agent, device=device, os=os, browser=browser)
-168
View File
@@ -1,168 +0,0 @@
import os
import platform
import socket
import sys
from datetime import datetime, timedelta
from datetime import timezone as tz
import psutil
from backend.utils.timezone import timezone
class ServerInfo:
@staticmethod
def format_bytes(size: float) -> str:
"""
格式化字节大小
:param size: 字节大小
:return:
"""
factor = 1024
for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
if abs(size) < factor:
return f'{size:.2f} {unit}B'
size /= factor
return f'{size:.2f} YB'
@staticmethod
def fmt_seconds(seconds: int) -> str:
"""
格式化秒数为可读的时间字符串
:param seconds: 秒数
:return:
"""
days, rem = divmod(int(seconds), 86400)
hours, rem = divmod(rem, 3600)
minutes, seconds = divmod(rem, 60)
parts = []
if days:
parts.append(f'{days}')
if hours:
parts.append(f'{hours} 小时')
if minutes:
parts.append(f'{minutes} 分钟')
if seconds:
parts.append(f'{seconds}')
return ' '.join(parts) if parts else '0 秒'
@staticmethod
def fmt_timedelta(td: timedelta) -> str:
"""
格式化时间差
:param td: 时间差对象
:return:
"""
return ServerInfo.fmt_seconds(round(td.total_seconds()))
@staticmethod
def get_cpu_info() -> dict[str, float | int]:
"""获取 CPU 信息"""
cpu_info = {
'usage': round(psutil.cpu_percent(interval=0.1), 2), # %
'logical_num': psutil.cpu_count(logical=True) or 0,
'physical_num': psutil.cpu_count(logical=False) or 0,
'max_freq': 0.0,
'min_freq': 0.0,
'current_freq': 0.0,
}
try:
if hasattr(psutil, 'cpu_freq'):
cpu_freq = psutil.cpu_freq()
if cpu_freq: # Some systems return None
cpu_info.update({
'max_freq': round(cpu_freq.max, 2),
'min_freq': round(cpu_freq.min, 2),
'current_freq': round(cpu_freq.current, 2),
})
except Exception:
pass
return cpu_info
@staticmethod
def get_mem_info() -> dict[str, float]:
"""获取内存信息"""
mem = psutil.virtual_memory()
gb_factor = 1024**3
return {
'total': round(mem.total / gb_factor, 2),
'used': round(mem.used / gb_factor, 2),
'free': round(mem.available / gb_factor, 2),
'usage': round(mem.percent, 2),
}
@staticmethod
def get_sys_info() -> dict[str, str]:
"""获取服务器信息"""
hostname = socket.gethostname()
ip = '127.0.0.1'
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(0.5)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except (TimeoutError, socket.gaierror, OSError):
pass
return {
'name': hostname,
'ip': ip,
'os': platform.system(),
'arch': platform.machine(),
}
@staticmethod
def get_disk_info() -> list[dict[str, str]]:
"""获取磁盘信息"""
disk_info = []
for partition in psutil.disk_partitions(all=False):
usage = psutil.disk_usage(partition.mountpoint)
if usage:
disk_info.append({
'dir': partition.mountpoint,
'type': partition.fstype,
'device': partition.device,
'total': ServerInfo.format_bytes(usage.total),
'free': ServerInfo.format_bytes(usage.free),
'used': ServerInfo.format_bytes(usage.used),
'usage': f'{usage.percent:.2f}%',
})
return disk_info
@staticmethod
def get_service_info() -> dict[str, str | datetime]:
"""获取服务信息"""
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
try:
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
start_time = timezone.from_datetime(create_time)
except (psutil.NoSuchProcess, OSError):
start_time = timezone.now()
elapsed = ServerInfo.fmt_timedelta(timezone.now() - start_time)
return {
'name': 'Python3',
'version': platform.python_version(),
'home': sys.executable,
'cpu_usage': f'{process.cpu_percent(interval=0.1):.2f}%',
'mem_vms': ServerInfo.format_bytes(mem_info.vms),
'mem_rss': ServerInfo.format_bytes(mem_info.rss),
'mem_free': ServerInfo.format_bytes(mem_info.vms - mem_info.rss),
'startup': timezone.to_str(start_time),
'elapsed': elapsed,
}
server_info: ServerInfo = ServerInfo()
+30
View File
@@ -0,0 +1,30 @@
import anyio
from anyio import open_file
from sqlparse import split
from backend.common.exception import errors
async def parse_sql_script(filepath: str) -> list[str]:
"""
解析 SQL 脚本
:param filepath: 脚本文件路径
:return:
"""
path = anyio.Path(filepath)
if not await path.exists():
raise errors.NotFoundError(msg='SQL 脚本文件不存在')
async with await open_file(filepath, encoding='utf-8') as f:
contents = await f.read(1024)
while additional_contents := await f.read(1024):
contents += additional_contents
statements = split(contents)
for statement in statements:
if not any(statement.lower().startswith(_) for _ in ['select', 'insert']):
raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT')
return statements

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