mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae1a43581 | ||
|
|
ee849f0854 | ||
|
|
5c9a27cc16 | ||
|
|
1b68854b84 | ||
|
|
b73585ebd2 | ||
|
|
28a6228556 | ||
|
|
dfce2ca094 | ||
|
|
326bdf9a17 | ||
|
|
cbe4e5ebe0 | ||
|
|
2d666e375f | ||
|
|
ff40c41549 | ||
|
|
7619670521 | ||
|
|
85ba942f35 | ||
|
|
88f3173f8f | ||
|
|
fd87dfb25a | ||
|
|
ad7f725e4c | ||
|
|
2ef8810376 | ||
|
|
f0e6ed9985 | ||
|
|
809879997c | ||
|
|
b153b7ac18 | ||
|
|
308ccf71ae | ||
|
|
c8f6d9da72 | ||
|
|
3f2c3e44ba | ||
|
|
b5e2a9e591 | ||
|
|
cdabe91092 | ||
|
|
69a59993e3 | ||
|
|
b6defc671e | ||
|
|
8cbea57a8a | ||
|
|
7845e1ff49 | ||
|
|
2b76b64e85 | ||
|
|
dd3165a082 | ||
|
|
407d12760b | ||
|
|
f8b244c3a9 | ||
|
|
dfc715aad8 | ||
|
|
4bc5ba53e6 | ||
|
|
6b4fd93e5f | ||
|
|
0c5956f828 | ||
|
|
122d17ab57 | ||
|
|
4fb14e480a | ||
|
|
ea4ac6ff41 | ||
|
|
bb5bcdf430 | ||
|
|
aad9afa9bb | ||
|
|
a0e6e12d85 | ||
|
|
5d680ff93f | ||
|
|
866b0e6ba4 | ||
|
|
5a49d20c9b | ||
|
|
7641d5993a |
@@ -3,7 +3,10 @@ __pycache__/
|
|||||||
.github/
|
.github/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.cursor/
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ venv/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.claude/
|
.claude/
|
||||||
|
.serena/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ repos:
|
|||||||
- id: check-toml
|
- id: check-toml
|
||||||
|
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
rev: v0.14.6
|
rev: v0.14.10
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
args:
|
args:
|
||||||
@@ -21,7 +21,7 @@ repos:
|
|||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||||
rev: 0.9.12
|
rev: 0.9.18
|
||||||
hooks:
|
hooks:
|
||||||
- id: uv-lock
|
- id: uv-lock
|
||||||
- id: uv-export
|
- id: uv-export
|
||||||
|
|||||||
+119
@@ -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>
|
<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
|
# [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]
|
[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.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.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
|
[v1.10.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
|
||||||
|
|||||||
@@ -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
@@ -36,33 +36,49 @@ COPY --from=builder /fba /fba
|
|||||||
|
|
||||||
COPY --from=builder /usr/local /usr/local
|
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
|
WORKDIR /fba/backend
|
||||||
|
|
||||||
# === FastAPI server image ===
|
# === FastAPI server image ===
|
||||||
FROM base_server AS fba_server
|
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
|
RUN mkdir -p /var/log/fba
|
||||||
|
|
||||||
EXPOSE 8001
|
EXPOSE 8001
|
||||||
|
|
||||||
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
|
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
|
|
||||||
# === Celery server image ===
|
# === Celery Worker image ===
|
||||||
FROM base_server AS fba_celery
|
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 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
|
EXPOSE 8555
|
||||||
|
|
||||||
CMD ["./celery-start.sh"]
|
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
FROM ${SERVER_TYPE}
|
FROM ${SERVER_TYPE}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ REDIS_PASSWORD=''
|
|||||||
REDIS_DATABASE=0
|
REDIS_DATABASE=0
|
||||||
# Token
|
# Token
|
||||||
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
|
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
|
||||||
# Opera Log
|
|
||||||
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
|
|
||||||
# [ App ] task
|
# [ App ] task
|
||||||
# Celery
|
# Celery
|
||||||
CELERY_BROKER_REDIS_DATABASE=1
|
CELERY_BROKER_REDIS_DATABASE=1
|
||||||
@@ -28,8 +26,6 @@ OAUTH2_GITHUB_CLIENT_ID='test'
|
|||||||
OAUTH2_GITHUB_CLIENT_SECRET='test'
|
OAUTH2_GITHUB_CLIENT_SECRET='test'
|
||||||
OAUTH2_GOOGLE_CLIENT_ID='test'
|
OAUTH2_GOOGLE_CLIENT_ID='test'
|
||||||
OAUTH2_GOOGLE_CLIENT_SECRET='test'
|
OAUTH2_GOOGLE_CLIENT_SECRET='test'
|
||||||
OAUTH2_LINUX_DO_CLIENT_ID='test'
|
|
||||||
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
|
|
||||||
# [ Plugin ] email
|
# [ Plugin ] email
|
||||||
EMAIL_USERNAME=''
|
EMAIL_USERNAME=''
|
||||||
EMAIL_PASSWORD=''
|
EMAIL_PASSWORD=''
|
||||||
|
|||||||
@@ -16,68 +16,3 @@
|
|||||||
```shell
|
```shell
|
||||||
docker run -d -p 8000:8000 --name fba_server fba_backend_independent
|
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
@@ -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,17 +8,9 @@ from sqlalchemy import pool
|
|||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
from backend.app import get_app_models
|
|
||||||
from backend.common.model import MappedBase
|
from backend.common.model import MappedBase
|
||||||
from backend.core import path_conf
|
from backend.core import path_conf
|
||||||
from backend.database.db import SQLALCHEMY_DATABASE_URL
|
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):
|
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
|
||||||
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
|
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
from fastapi import APIRouter
|
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.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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
|
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
|
||||||
async def get_redis_info() -> ResponseModel:
|
async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
|
||||||
data = {
|
info = await redis_client.info()
|
||||||
'info': await redis_info.get_info(),
|
db_size = await redis_client.dbsize()
|
||||||
'stats': await redis_info.get_stats(),
|
|
||||||
}
|
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)
|
return response_base.success(data=data)
|
||||||
|
|||||||
@@ -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 fastapi import APIRouter
|
||||||
from starlette.concurrency import run_in_threadpool
|
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.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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
|
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
|
||||||
async def get_server_info() -> ResponseModel:
|
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901
|
||||||
data = {
|
def get_all_info() -> ServerMonitorInfo: # noqa: C901
|
||||||
# 扔到线程池,避免阻塞
|
# CPU 信息
|
||||||
'cpu': await run_in_threadpool(server_info.get_cpu_info),
|
cpu_data = {
|
||||||
'mem': await run_in_threadpool(server_info.get_mem_info),
|
'usage': round(psutil.cpu_percent(interval=0.1), 2),
|
||||||
'sys': await run_in_threadpool(server_info.get_sys_info),
|
'logical_num': psutil.cpu_count(logical=True) or 0,
|
||||||
'disk': await run_in_threadpool(server_info.get_disk_info),
|
'physical_num': psutil.cpu_count(logical=False) or 0,
|
||||||
'service': await run_in_threadpool(server_info.get_service_info),
|
'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)
|
return response_base.success(data=data)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from typing import Annotated
|
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.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
|
||||||
from backend.app.admin.service.dept_service import dept_service
|
from backend.app.admin.service.dept_service import dept_service
|
||||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||||
from backend.common.security.jwt import DependsJwtAuth
|
from backend.common.security.jwt import DependsJwtAuth
|
||||||
from backend.common.security.permission import RequestPermission
|
from backend.common.security.permission import DataPermissionFilter, RequestPermission
|
||||||
from backend.common.security.rbac import DependsRBAC
|
from backend.common.security.rbac import DependsRBAC
|
||||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||||
|
|
||||||
@@ -24,14 +26,14 @@ async def get_dept(
|
|||||||
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
|
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
|
||||||
async def get_dept_tree(
|
async def get_dept_tree(
|
||||||
db: CurrentSession,
|
db: CurrentSession,
|
||||||
request: Request,
|
data_filter: Annotated[ColumnElement[bool], Depends(DataPermissionFilter(Dept))],
|
||||||
name: Annotated[str | None, Query(description='部门名称')] = None,
|
name: Annotated[str | None, Query(description='部门名称')] = None,
|
||||||
leader: Annotated[str | None, Query(description='部门负责人')] = None,
|
leader: Annotated[str | None, Query(description='部门负责人')] = None,
|
||||||
phone: Annotated[str | None, Query(description='联系电话')] = None,
|
phone: Annotated[str | None, Query(description='联系电话')] = None,
|
||||||
status: Annotated[int | None, Query(description='状态')] = None,
|
status: Annotated[int | None, Query(description='状态')] = None,
|
||||||
) -> ResponseSchemaModel[list[GetDeptTree]]:
|
) -> ResponseSchemaModel[list[GetDeptTree]]:
|
||||||
dept = await dept_service.get_tree(
|
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)
|
return response_base.success(data=dept)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||||
|
|
||||||
from backend.app.admin.model import Dept, User
|
from backend.app.admin.model import Dept, User
|
||||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
|
||||||
from backend.common.security.permission import filter_data_permission
|
|
||||||
from backend.utils.serializers import select_join_serialize
|
from backend.utils.serializers import select_join_serialize
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
async def get_all(
|
async def get_all(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
request_user: GetUserInfoWithRelationDetail,
|
data_filter: ColumnElement[bool],
|
||||||
name: str | None,
|
name: str | None,
|
||||||
leader: str | None,
|
leader: str | None,
|
||||||
phone: str | None,
|
phone: str | None,
|
||||||
@@ -47,7 +46,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
获取所有部门
|
获取所有部门
|
||||||
|
|
||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:param request_user: 请求用户
|
:param data_filter: 请求用户
|
||||||
:param name: 部门名称
|
:param name: 部门名称
|
||||||
:param leader: 负责人
|
:param leader: 负责人
|
||||||
:param phone: 联系电话
|
:param phone: 联系电话
|
||||||
@@ -65,7 +64,6 @@ class CRUDDept(CRUDPlus[Dept]):
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
filters['status'] = status
|
filters['status'] = status
|
||||||
|
|
||||||
data_filter = filter_data_permission(request_user)
|
|
||||||
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
|
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
|
||||||
|
|
||||||
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
|
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from backend.app.admin.schema.user import (
|
|||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
)
|
)
|
||||||
from backend.app.admin.utils.password_security import get_hash_password
|
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.serializers import select_join_serialize
|
||||||
from backend.utils.timezone import timezone
|
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)
|
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||||
await db.execute(user_role_stmt)
|
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)
|
return await self.delete_model(db, user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class LoginLog(DataClassBase):
|
|||||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||||
region: 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='城市')
|
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='操作系统')
|
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||||
browser: 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='设备')
|
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class OperaLog(DataClassBase):
|
|||||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||||
region: 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='城市')
|
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='操作系统')
|
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||||
browser: 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='设备')
|
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ class GetDataRuleColumnDetail(SchemaBase):
|
|||||||
"""数据规则可用模型字段详情"""
|
"""数据规则可用模型字段详情"""
|
||||||
|
|
||||||
key: str = Field(description='字段名')
|
key: str = Field(description='字段名')
|
||||||
comment: str = Field(description='字段评论')
|
comment: str | None = Field(description='字段评论')
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class LoginLogSchemaBase(SchemaBase):
|
|||||||
country: str | None = Field(None, description='国家')
|
country: str | None = Field(None, description='国家')
|
||||||
region: str | None = Field(None, description='地区')
|
region: str | None = Field(None, description='地区')
|
||||||
city: 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='浏览器')
|
browser: str | None = Field(None, description='浏览器')
|
||||||
os: str | None = Field(None, description='操作系统')
|
os: str | None = Field(None, description='操作系统')
|
||||||
device: str | None = Field(None, description='设备')
|
device: str | None = Field(None, description='设备')
|
||||||
|
|||||||
@@ -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='命令统计列表')
|
||||||
@@ -19,7 +19,7 @@ class OperaLogSchemaBase(SchemaBase):
|
|||||||
country: str | None = Field(None, description='国家')
|
country: str | None = Field(None, description='国家')
|
||||||
region: str | None = Field(None, description='地区')
|
region: str | None = Field(None, description='地区')
|
||||||
city: 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='操作系统')
|
os: str | None = Field(None, description='操作系统')
|
||||||
browser: str | None = Field(None, description='浏览器')
|
browser: str | None = Field(None, description='浏览器')
|
||||||
device: str | None = Field(None, description='设备')
|
device: str | None = Field(None, description='设备')
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ class AuthService:
|
|||||||
log.error('登陆错误: 用户密码有误')
|
log.error('登陆错误: 用户密码有误')
|
||||||
task = BackgroundTask(
|
task = BackgroundTask(
|
||||||
login_log_service.create,
|
login_log_service.create,
|
||||||
db=db,
|
|
||||||
user_uuid=user.uuid if user else uuid4_str(),
|
user_uuid=user.uuid if user else uuid4_str(),
|
||||||
username=obj.username,
|
username=obj.username,
|
||||||
login_time=timezone.now(),
|
login_time=timezone.now(),
|
||||||
@@ -160,7 +159,6 @@ class AuthService:
|
|||||||
else:
|
else:
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
login_log_service.create,
|
login_log_service.create,
|
||||||
db=db,
|
|
||||||
user_uuid=user.uuid,
|
user_uuid=user.uuid,
|
||||||
username=obj.username,
|
username=obj.username,
|
||||||
login_time=timezone.now(),
|
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_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||||
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||||
if refresh_token:
|
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()
|
auth_service: AuthService = AuthService()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Table
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.app.admin.crud.crud_data_rule import data_rule_dao
|
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.app.admin.utils.cache import user_cache_manager
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.pagination import paging_data
|
from backend.common.pagination import paging_data
|
||||||
|
from backend.common.security.permission import get_data_permission_models
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.utils.import_parse import dynamic_import_data_model
|
|
||||||
|
|
||||||
|
|
||||||
class DataRuleService:
|
class DataRuleService:
|
||||||
@@ -39,7 +40,8 @@ class DataRuleService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_models() -> list[str]:
|
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
|
@staticmethod
|
||||||
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
|
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
|
||||||
@@ -49,13 +51,15 @@ class DataRuleService:
|
|||||||
:param model: 模型名称
|
:param model: 模型名称
|
||||||
:return:
|
: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='数据规则可用模型不存在')
|
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 = [
|
model_columns = [
|
||||||
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
|
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
|
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
|
||||||
]
|
]
|
||||||
return model_columns
|
return model_columns
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.app.admin.crud.crud_dept import dept_dao
|
from backend.app.admin.crud.crud_dept import dept_dao
|
||||||
from backend.app.admin.model import Dept
|
from backend.app.admin.model import Dept
|
||||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
@@ -34,7 +34,7 @@ class DeptService:
|
|||||||
async def get_tree(
|
async def get_tree(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
request_user: GetUserInfoWithRelationDetail,
|
data_filter: ColumnElement[bool],
|
||||||
name: str | None,
|
name: str | None,
|
||||||
leader: str | None,
|
leader: str | None,
|
||||||
phone: str | None,
|
phone: str | None,
|
||||||
@@ -44,15 +44,14 @@ class DeptService:
|
|||||||
获取部门树形结构
|
获取部门树形结构
|
||||||
|
|
||||||
:param db: 数据库会话
|
:param db: 数据库会话
|
||||||
:param request_user: 请求用户
|
:param data_filter: 请求用户
|
||||||
:param name: 部门名称
|
:param name: 部门名称
|
||||||
:param leader: 部门负责人
|
:param leader: 部门负责人
|
||||||
:param phone: 联系电话
|
:param phone: 联系电话
|
||||||
:param status: 状态
|
:param status: 状态
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
dept_select = await dept_dao.get_all(db, data_filter, name, leader, phone, status)
|
||||||
dept_select = await dept_dao.get_all(db, request_user, name, leader, phone, status)
|
|
||||||
tree_data = get_tree_data(dept_select)
|
tree_data = get_tree_data(dept_select)
|
||||||
return tree_data
|
return tree_data
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginL
|
|||||||
from backend.common.context import ctx
|
from backend.common.context import ctx
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.common.pagination import paging_data
|
from backend.common.pagination import paging_data
|
||||||
|
from backend.database.db import async_db_session
|
||||||
|
|
||||||
|
|
||||||
class LoginLogService:
|
class LoginLogService:
|
||||||
@@ -30,7 +31,6 @@ class LoginLogService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def create(
|
async def create(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession,
|
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
username: str,
|
username: str,
|
||||||
login_time: datetime,
|
login_time: datetime,
|
||||||
@@ -40,7 +40,6 @@ class LoginLogService:
|
|||||||
"""
|
"""
|
||||||
创建登录日志
|
创建登录日志
|
||||||
|
|
||||||
:param db: 数据库会话
|
|
||||||
:param user_uuid: 用户 UUID
|
:param user_uuid: 用户 UUID
|
||||||
:param username: 用户名
|
:param username: 用户名
|
||||||
:param login_time: 登录时间
|
:param login_time: 登录时间
|
||||||
@@ -64,7 +63,9 @@ class LoginLogService:
|
|||||||
msg=msg,
|
msg=msg,
|
||||||
login_time=login_time,
|
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:
|
except Exception as e:
|
||||||
log.error(f'登录日志创建失败: {e}')
|
log.error(f'登录日志创建失败: {e}')
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from backend.common.exception import errors
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.core.path_conf import PLUGIN_DIR
|
from backend.core.path_conf import PLUGIN_DIR
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.plugin.tools import uninstall_requirements_async
|
from backend.plugin.installer import install_git_plugin, install_zip_plugin
|
||||||
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
|
from backend.plugin.requirements import uninstall_requirements_async
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import random
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -92,7 +90,6 @@ class UserService:
|
|||||||
"""
|
"""
|
||||||
if await user_dao.get_by_username(db, obj.username):
|
if await user_dao.get_by_username(db, obj.username):
|
||||||
raise errors.ConflictError(msg='用户名已注册')
|
raise errors.ConflictError(msg='用户名已注册')
|
||||||
obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
|
|
||||||
if not obj.password:
|
if not obj.password:
|
||||||
raise errors.RequestError(msg='密码不允许为空')
|
raise errors.RequestError(msg='密码不允许为空')
|
||||||
if not await dept_dao.get(db, obj.dept_id):
|
if not await dept_dao.get(db, obj.dept_id):
|
||||||
@@ -100,6 +97,7 @@ class UserService:
|
|||||||
for role_id in obj.roles:
|
for role_id in obj.roles:
|
||||||
if not await role_dao.get(db, role_id):
|
if not await role_dao.get(db, role_id):
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
|
obj.nickname = obj.nickname or obj.username
|
||||||
await user_dao.add(db, obj)
|
await user_dao.add(db, obj)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -144,7 +142,7 @@ class UserService:
|
|||||||
raise errors.NotFoundError(msg='用户不存在')
|
raise errors.NotFoundError(msg='用户不存在')
|
||||||
if pk == request.user.id:
|
if pk == request.user.id:
|
||||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
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:
|
case UserPermissionType.staff:
|
||||||
user = await user_dao.get(db, pk)
|
user = await user_dao.get(db, pk)
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -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.common.exception import errors
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.utils.dynamic_config import load_user_security_config
|
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(),))
|
password_hash = PasswordHash((BcryptHasher(),))
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
|
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
|
||||||
|
|
||||||
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
|
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
|
||||||
2. 新建目录后,务必更新 `conf.py` 配置中的 `CELERY_TASKS_PACKAGES`,将新建目录模块路径添加到此列表
|
2. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
|
||||||
3. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
|
|
||||||
|
|
||||||
## 消息代理
|
## 消息代理
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import celery
|
import celery
|
||||||
import celery_aio_pool
|
import celery_aio_pool
|
||||||
|
|
||||||
from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE
|
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.conf import settings
|
||||||
from backend.core.path_conf import BASE_PATH
|
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.build_tracer = celery_aio_pool.build_async_tracer
|
||||||
celery.app.trace.reset_worker_optimizations()
|
celery.app.trace.reset_worker_optimizations()
|
||||||
|
|
||||||
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}/{settings.CELERY_RABBITMQ_VHOST}'
|
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':
|
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}'
|
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 settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
|
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
|
||||||
@@ -51,6 +53,8 @@ def init_celery() -> celery.Celery:
|
|||||||
task_track_started=True,
|
task_track_started=True,
|
||||||
enable_utc=False,
|
enable_utc=False,
|
||||||
timezone=settings.DATETIME_TIMEZONE,
|
timezone=settings.DATETIME_TIMEZONE,
|
||||||
|
worker_send_task_events=True,
|
||||||
|
task_send_sent_event=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 在 Celery 中设置此参数无效
|
# 在 Celery 中设置此参数无效
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from backend.common.exception import errors
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import async_db_session
|
from backend.database.db import async_db_session
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.utils._await import run_await
|
from backend.utils.async_helper import run_await
|
||||||
from backend.utils.serializers import select_as_dict
|
from backend.utils.serializers import select_as_dict
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,33 +1,43 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
import anyio
|
||||||
import cappa
|
import cappa
|
||||||
import granian
|
import granian
|
||||||
|
|
||||||
from cappa.output import error_format
|
from cappa.output import error_format
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.prompt import IntPrompt
|
from rich.prompt import IntPrompt, Prompt
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from watchfiles import PythonFilter
|
from watchfiles import PythonFilter
|
||||||
|
|
||||||
from backend import __version__
|
from backend import __version__
|
||||||
from backend.common.enums import DataBaseType, PrimaryKeyType
|
from backend.common.enums import DataBaseType, PrimaryKeyType
|
||||||
from backend.common.exception.errors import BaseExceptionError
|
from backend.common.exception.errors import BaseExceptionError
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import async_db_session
|
from backend.core.path_conf import (
|
||||||
from backend.plugin.code_generator.schema.code import ImportParam
|
ENV_EXAMPLE_FILE_PATH,
|
||||||
from backend.plugin.code_generator.service.business_service import gen_business_service
|
ENV_FILE_PATH,
|
||||||
from backend.plugin.code_generator.service.code_service import gen_service
|
MYSQL_SCRIPT_DIR,
|
||||||
from backend.plugin.tools import get_plugin_sql, get_plugins
|
POSTGRESQL_SCRIPT_DIR,
|
||||||
from backend.utils._await import run_await
|
)
|
||||||
|
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.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[/]"'
|
output_help = '\n更多信息,尝试 "[cyan]--help[/]"'
|
||||||
|
|
||||||
@@ -39,6 +49,213 @@ class CustomReloadFilter(PythonFilter):
|
|||||||
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])
|
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
|
def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001
|
||||||
url = f'http://{host}:{port}'
|
url = f'http://{host}:{port}'
|
||||||
docs_url = url + settings.FASTAPI_DOCS_URL
|
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('\n🌐 架构官方文档: ', style='bold magenta')
|
||||||
panel_content.append('https://fastapi-practices.github.io/fastapi_best_architecture_docs/')
|
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(
|
granian.Granian(
|
||||||
target='backend.main:app',
|
target='backend.main:app',
|
||||||
interface='asgi',
|
interface='asgi',
|
||||||
@@ -124,7 +341,7 @@ async def install_plugin(
|
|||||||
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
|
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
|
||||||
|
|
||||||
plugin_name = None
|
plugin_name = None
|
||||||
console.print(Text('开始安装插件...', style='bold cyan'))
|
console.print('开始安装插件...', style='bold cyan')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if path:
|
if path:
|
||||||
@@ -132,18 +349,40 @@ async def install_plugin(
|
|||||||
if repo_url:
|
if repo_url:
|
||||||
plugin_name = await install_git_plugin(repo_url=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)
|
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
|
||||||
if sql_file and not no_sql:
|
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)
|
await execute_sql_scripts(sql_file)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
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:
|
async with async_db_session.begin() as db:
|
||||||
try:
|
try:
|
||||||
stmts = await parse_sql_script(sql_scripts)
|
stmts = await parse_sql_script(sql_scripts)
|
||||||
@@ -152,7 +391,8 @@ async def execute_sql_scripts(sql_scripts: str) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
|
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(
|
async def import_table(
|
||||||
@@ -160,18 +400,27 @@ async def import_table(
|
|||||||
table_schema: str,
|
table_schema: str,
|
||||||
table_name: str,
|
table_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||||
|
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
|
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
|
||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
await gen_service.import_business_and_model(db=db, obj=obj)
|
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:
|
except Exception as e:
|
||||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
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:
|
try:
|
||||||
ids = []
|
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:
|
if not results:
|
||||||
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
|
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
|
||||||
@@ -192,14 +441,30 @@ def generate() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
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:
|
except Exception as e:
|
||||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
||||||
|
|
||||||
console.print(Text('\n代码已生成完毕', style='bold green'))
|
console.print('\n代码已生成完成', style='bold green')
|
||||||
console.print(Text('\n详情请查看:'), Text(gen_path, style='bold magenta'))
|
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)
|
@cappa.command(help='运行 API 服务', default_long=True)
|
||||||
@@ -320,17 +585,29 @@ class Import:
|
|||||||
cappa.Arg(short='tn', help='数据库表名'),
|
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:
|
async def __call__(self) -> None:
|
||||||
await import_table(self.app, self.table_schema, self.table_name)
|
await import_table(self.app, self.table_schema, self.table_name)
|
||||||
|
|
||||||
|
|
||||||
@cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True)
|
@cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True)
|
||||||
@dataclass
|
@dataclass
|
||||||
class CodeGenerate:
|
class CodeGenerator:
|
||||||
subcmd: cappa.Subcommands[Import | None] = None
|
subcmd: cappa.Subcommands[Import | None] = None
|
||||||
|
|
||||||
def __call__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
generate()
|
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)
|
@cappa.command(help='一个高效的 fba 命令行界面', default_long=True)
|
||||||
@@ -340,7 +617,7 @@ class FbaCli:
|
|||||||
str,
|
str,
|
||||||
cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'),
|
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:
|
async def __call__(self) -> None:
|
||||||
if self.sql:
|
if self.sql:
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ class TypedContextProtocol(Protocol):
|
|||||||
region: str | None
|
region: str | None
|
||||||
city: str | None
|
city: str | None
|
||||||
|
|
||||||
user_agent: str
|
user_agent: str | None
|
||||||
os: str | None
|
os: str | None
|
||||||
browser: str | None
|
browser: str | None
|
||||||
device: str | None
|
device: str | None
|
||||||
|
|
||||||
permission: str | None
|
permission: str | None
|
||||||
|
language: str
|
||||||
|
|
||||||
|
|
||||||
class TypedContext(TypedContextProtocol, _Context):
|
class TypedContext(TypedContextProtocol, _Context):
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class IpInfo:
|
|||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class UserAgentInfo:
|
class UserAgentInfo:
|
||||||
user_agent: str
|
user_agent: str | None
|
||||||
os: str | None
|
os: str | None
|
||||||
browser: str | None
|
browser: str | None
|
||||||
device: str | None
|
device: str | None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
|
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
|
||||||
|
|
||||||
from backend.common.context import ctx
|
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)
|
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)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: 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,
|
status_code=StandardResponseCode.HTTP_500,
|
||||||
content=content,
|
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
@@ -1,45 +1,27 @@
|
|||||||
import glob
|
|
||||||
import json
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
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.conf import settings
|
||||||
from backend.core.path_conf import LOCALE_DIR
|
from backend.locale.loader import locale_loader
|
||||||
|
|
||||||
|
|
||||||
class I18n:
|
class I18n:
|
||||||
"""国际化管理器"""
|
"""国际化管理器"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
@property
|
||||||
self.locales: dict[str, dict[str, Any]] = {}
|
def current_language(self) -> str:
|
||||||
self.current_language: str = settings.I18N_DEFAULT_LANGUAGE
|
"""获取当前请求的语言"""
|
||||||
self.load_locales()
|
try:
|
||||||
|
return ctx.language
|
||||||
|
except (AttributeError, LookupError, ContextDoesNotExistError):
|
||||||
|
return settings.I18N_DEFAULT_LANGUAGE
|
||||||
|
|
||||||
def load_locales(self) -> None:
|
@current_language.setter
|
||||||
"""加载语言文本"""
|
def current_language(self, language: str) -> None:
|
||||||
patterns = [
|
"""设置当前请求的语言"""
|
||||||
LOCALE_DIR / '*.json',
|
ctx.language = language
|
||||||
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())
|
|
||||||
|
|
||||||
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
|
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -53,10 +35,10 @@ class I18n:
|
|||||||
keys = key.split('.')
|
keys = key.split('.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
translation = self.locales[self.current_language]
|
translation = locale_loader.locales[self.current_language]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
keys = 'error.language_not_found'
|
keys = 'error.language_not_found'.split('.')
|
||||||
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]
|
translation = locale_loader.locales[settings.I18N_DEFAULT_LANGUAGE]
|
||||||
|
|
||||||
for k in keys:
|
for k in keys:
|
||||||
if isinstance(translation, dict) and k in list(translation.keys()):
|
if isinstance(translation, dict) and k in list(translation.keys()):
|
||||||
@@ -64,6 +46,7 @@ class I18n:
|
|||||||
else:
|
else:
|
||||||
# Pydantic 兼容
|
# Pydantic 兼容
|
||||||
translation = None if keys[0] == 'pydantic' else key
|
translation = None if keys[0] == 'pydantic' else key
|
||||||
|
break
|
||||||
|
|
||||||
if translation and kwargs:
|
if translation and kwargs:
|
||||||
translation = translation.format(**kwargs)
|
translation = translation.format(**kwargs)
|
||||||
|
|||||||
@@ -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'
|
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:
|
def setup_logging() -> None:
|
||||||
"""
|
"""
|
||||||
设置日志处理器
|
设置日志处理器
|
||||||
@@ -75,12 +82,6 @@ def setup_logging() -> None:
|
|||||||
# 移除 loguru 默认处理器
|
# 移除 loguru 默认处理器
|
||||||
logger.remove()
|
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 处理器
|
# 配置 loguru 处理器
|
||||||
logger.configure(
|
logger.configure(
|
||||||
handlers=[
|
handlers=[
|
||||||
|
|||||||
+7
-10
@@ -6,6 +6,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT
|
|||||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
|
||||||
|
|
||||||
|
from backend.common.enums import DataBaseType, PrimaryKeyType
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.utils.snowflake import snowflake
|
from backend.utils.snowflake import snowflake
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
@@ -23,15 +24,11 @@ id_key = Annotated[
|
|||||||
autoincrement=True,
|
autoincrement=True,
|
||||||
sort_order=-999,
|
sort_order=-999,
|
||||||
comment='主键 ID',
|
comment='主键 ID',
|
||||||
),
|
)
|
||||||
]
|
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
|
||||||
|
# 雪花算法 Mapped 类型主键
|
||||||
|
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
|
||||||
# 雪花算法 Mapped 类型主键,使用方法与 id_key 相同
|
else mapped_column(
|
||||||
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
|
|
||||||
snowflake_id_key = Annotated[
|
|
||||||
int,
|
|
||||||
mapped_column(
|
|
||||||
BigInteger,
|
BigInteger,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -46,7 +43,7 @@ snowflake_id_key = Annotated[
|
|||||||
class UniversalText(TypeDecorator[str]):
|
class UniversalText(TypeDecorator[str]):
|
||||||
"""PostgreSQL、MySQL 兼容性(长)文本类型"""
|
"""PostgreSQL、MySQL 兼容性(长)文本类型"""
|
||||||
|
|
||||||
impl = LONGTEXT if settings.DATABASE_TYPE == 'mysql' else Text
|
impl = LONGTEXT if DataBaseType.mysql == settings.DATABASE_TYPE else Text
|
||||||
cache_ok = True
|
cache_ok = True
|
||||||
|
|
||||||
def process_bind_param(self, value: str | None, dialect) -> str | None: # noqa: ANN001
|
def process_bind_param(self, value: str | None, dialect) -> str | None: # noqa: ANN001
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
)
|
||||||
@@ -2,6 +2,8 @@ import asyncio
|
|||||||
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
|
|
||||||
|
from backend.common.log import log
|
||||||
|
|
||||||
|
|
||||||
async def batch_dequeue(queue: Queue, max_items: int, timeout: float) -> list:
|
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)
|
await asyncio.wait_for(collector(), timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'队列批量获取失败: {e}')
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from typing import Annotated, Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
|
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
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
|
CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
|
||||||
@@ -22,12 +24,22 @@ class SchemaBase(BaseModel):
|
|||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
use_enum_values=True,
|
use_enum_values=True,
|
||||||
json_encoders={
|
json_encoders={
|
||||||
datetime: lambda x: timezone.to_str(timezone.from_datetime(x))
|
datetime: lambda x: (
|
||||||
if x.tzinfo is not None and x.tzinfo != timezone.tz_info
|
timezone.to_str(timezone.from_datetime(x))
|
||||||
else timezone.to_str(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:
|
def ser_string(value: Any) -> str | None:
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
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.context import ctx
|
||||||
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.conf import settings
|
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:
|
class RequestPermission:
|
||||||
@@ -41,75 +44,95 @@ class RequestPermission:
|
|||||||
ctx.permission = self.value
|
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:
|
:return:
|
||||||
"""
|
"""
|
||||||
# 是否过滤数据权限
|
# 超级管理员不过滤
|
||||||
if request_user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return or_(1 == 1)
|
return or_(1 == 1)
|
||||||
|
|
||||||
for role in request_user.roles:
|
# 角色未启用数据权限过滤
|
||||||
|
for role in request.user.roles:
|
||||||
if not role.is_filter_scopes:
|
if not role.is_filter_scopes:
|
||||||
return or_(1 == 1)
|
return or_(1 == 1)
|
||||||
|
|
||||||
# 获取数据规则
|
# 获取数据规则
|
||||||
data_rules = set()
|
data_rules = set()
|
||||||
for role in request_user.roles:
|
for role in request.user.roles:
|
||||||
for scope in role.scopes:
|
for scope in role.scopes:
|
||||||
if scope.status:
|
if scope.status:
|
||||||
data_rules.update(scope.rules)
|
data_rules.update(scope.rules)
|
||||||
|
|
||||||
# 无规则用户不做过滤
|
if not data_rules:
|
||||||
if not list(data_rules):
|
|
||||||
return or_(1 == 1)
|
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_and_list = []
|
||||||
where_or_list = []
|
where_or_list = []
|
||||||
|
|
||||||
for data_rule in list(data_rules):
|
for data_rule in data_rules:
|
||||||
# 验证规则模型
|
target_model = model_map.get(data_rule.model)
|
||||||
rule_model = data_rule.model
|
if target_model is None:
|
||||||
if rule_model not in settings.DATA_PERMISSION_MODELS:
|
continue
|
||||||
raise errors.NotFoundError(msg='数据规则可用模型不存在')
|
|
||||||
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
|
|
||||||
|
|
||||||
# 验证规则列
|
table = target_model if isinstance(target_model, Table) else target_model.__table__
|
||||||
model_columns = [
|
rule_column = data_rule.column
|
||||||
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
|
if rule_column not in table.columns.keys():
|
||||||
]
|
continue
|
||||||
column = data_rule.column
|
if rule_column in settings.DATA_PERMISSION_COLUMN_EXCLUDE:
|
||||||
if column not in model_columns:
|
continue
|
||||||
raise errors.NotFoundError(msg='数据规则可用模型列不存在')
|
|
||||||
|
|
||||||
# 构建过滤条件
|
# 构建过滤条件
|
||||||
column_obj = getattr(model_ins, column)
|
column_obj = (
|
||||||
rule_expression = data_rule.expression
|
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
|
condition = None
|
||||||
match rule_expression:
|
match data_rule.expression:
|
||||||
case RoleDataRuleExpressionType.eq:
|
case RoleDataRuleExpressionType.eq:
|
||||||
condition = column_obj == data_rule.value
|
condition = column_obj == cast_value(data_rule.value)
|
||||||
case RoleDataRuleExpressionType.ne:
|
case RoleDataRuleExpressionType.ne:
|
||||||
condition = column_obj != data_rule.value
|
condition = column_obj != cast_value(data_rule.value)
|
||||||
case RoleDataRuleExpressionType.gt:
|
case RoleDataRuleExpressionType.gt:
|
||||||
condition = column_obj > data_rule.value
|
condition = column_obj > cast_value(data_rule.value)
|
||||||
case RoleDataRuleExpressionType.ge:
|
case RoleDataRuleExpressionType.ge:
|
||||||
condition = column_obj >= data_rule.value
|
condition = column_obj >= cast_value(data_rule.value)
|
||||||
case RoleDataRuleExpressionType.lt:
|
case RoleDataRuleExpressionType.lt:
|
||||||
condition = column_obj < data_rule.value
|
condition = column_obj < cast_value(data_rule.value)
|
||||||
case RoleDataRuleExpressionType.le:
|
case RoleDataRuleExpressionType.le:
|
||||||
condition = column_obj <= data_rule.value
|
condition = column_obj <= cast_value(data_rule.value)
|
||||||
case RoleDataRuleExpressionType.in_:
|
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)
|
condition = column_obj.in_(values)
|
||||||
case RoleDataRuleExpressionType.not_in:
|
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)
|
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))
|
where_list.append(or_(*where_or_list))
|
||||||
|
|
||||||
return or_(*where_list) if where_list else or_(1 == 1)
|
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)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from backend.common.exception import errors
|
|||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.common.security.jwt import DependsJwtAuth
|
from backend.common.security.jwt import DependsJwtAuth
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.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
|
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import urllib.parse
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
|
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
@@ -8,7 +10,7 @@ from backend.database.redis import redis_client
|
|||||||
# 创建 Socket.IO 服务器实例
|
# 创建 Socket.IO 服务器实例
|
||||||
sio = socketio.AsyncServer(
|
sio = socketio.AsyncServer(
|
||||||
client_manager=socketio.AsyncRedisManager(
|
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',
|
async_mode='asgi',
|
||||||
cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS,
|
cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS,
|
||||||
|
|||||||
+14
-15
@@ -1,3 +1,5 @@
|
|||||||
|
import shutil
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from re import Pattern
|
from re import Pattern
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -5,14 +7,14 @@ from typing import Any, Literal
|
|||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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):
|
class Settings(BaseSettings):
|
||||||
"""全局配置"""
|
"""全局配置"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=f'{BASE_PATH}/.env',
|
env_file=ENV_FILE_PATH,
|
||||||
env_file_encoding='utf-8',
|
env_file_encoding='utf-8',
|
||||||
extra='ignore',
|
extra='ignore',
|
||||||
case_sensitive=True,
|
case_sensitive=True,
|
||||||
@@ -23,7 +25,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# FastAPI
|
# FastAPI
|
||||||
FASTAPI_API_V1_PATH: str = '/api/v1'
|
FASTAPI_API_V1_PATH: str = '/api/v1'
|
||||||
FASTAPI_TITLE: str = 'FastAPI'
|
FASTAPI_TITLE: str = 'fba'
|
||||||
FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture'
|
FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture'
|
||||||
FASTAPI_DOCS_URL: str = '/docs'
|
FASTAPI_DOCS_URL: str = '/docs'
|
||||||
FASTAPI_REDOC_URL: str = '/redoc'
|
FASTAPI_REDOC_URL: str = '/redoc'
|
||||||
@@ -42,6 +44,7 @@ class Settings(BaseSettings):
|
|||||||
DATABASE_POOL_ECHO: bool | Literal['debug'] = False
|
DATABASE_POOL_ECHO: bool | Literal['debug'] = False
|
||||||
DATABASE_SCHEMA: str = 'fba'
|
DATABASE_SCHEMA: str = 'fba'
|
||||||
DATABASE_CHARSET: str = 'utf8mb4'
|
DATABASE_CHARSET: str = 'utf8mb4'
|
||||||
|
DATABASE_PK_MODE: Literal['autoincrement', 'snowflake'] = 'autoincrement'
|
||||||
|
|
||||||
# .env Redis
|
# .env Redis
|
||||||
REDIS_HOST: str
|
REDIS_HOST: str
|
||||||
@@ -111,9 +114,6 @@ class Settings(BaseSettings):
|
|||||||
COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
|
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 模型列
|
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
|
||||||
'id',
|
'id',
|
||||||
'sort',
|
'sort',
|
||||||
@@ -184,9 +184,6 @@ class Settings(BaseSettings):
|
|||||||
LOG_ACCESS_FILENAME: str = 'fba_access.log'
|
LOG_ACCESS_FILENAME: str = 'fba_access.log'
|
||||||
LOG_ERROR_FILENAME: str = 'fba_error.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] = [
|
OPERA_LOG_PATH_EXCLUDE: list[str] = [
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
@@ -196,10 +193,8 @@ class Settings(BaseSettings):
|
|||||||
f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
|
f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
|
||||||
f'{FASTAPI_API_V1_PATH}/oauth2/github/callback',
|
f'{FASTAPI_API_V1_PATH}/oauth2/github/callback',
|
||||||
f'{FASTAPI_API_V1_PATH}/oauth2/google/callback',
|
f'{FASTAPI_API_V1_PATH}/oauth2/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_REDACT_KEYS: list[str] = [
|
||||||
OPERA_LOG_ENCRYPT_KEY_INCLUDE: list[str] = [ # 将加密接口入参参数对应的值
|
|
||||||
'password',
|
'password',
|
||||||
'old_password',
|
'old_password',
|
||||||
'new_password',
|
'new_password',
|
||||||
@@ -217,6 +212,11 @@ class Settings(BaseSettings):
|
|||||||
# I18n 配置
|
# I18n 配置
|
||||||
I18N_DEFAULT_LANGUAGE: str = 'zh-CN'
|
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
|
# [ App ] task
|
||||||
##################################################
|
##################################################
|
||||||
@@ -249,15 +249,12 @@ class Settings(BaseSettings):
|
|||||||
OAUTH2_GITHUB_CLIENT_SECRET: str
|
OAUTH2_GITHUB_CLIENT_SECRET: str
|
||||||
OAUTH2_GOOGLE_CLIENT_ID: str
|
OAUTH2_GOOGLE_CLIENT_ID: str
|
||||||
OAUTH2_GOOGLE_CLIENT_SECRET: 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_REDIS_PREFIX: str = 'fba:oauth2:state'
|
||||||
OAUTH2_STATE_EXPIRE_SECONDS: int = 60 * 3 # 3 分钟
|
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_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_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_LOGIN_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
|
||||||
OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str = 'http://localhost:5173/profile'
|
OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str = 'http://localhost:5173/profile'
|
||||||
|
|
||||||
@@ -293,6 +290,8 @@ class Settings(BaseSettings):
|
|||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""获取全局配置单例"""
|
"""获取全局配置单例"""
|
||||||
|
if not ENV_FILE_PATH.exists():
|
||||||
|
shutil.copy(ENV_EXAMPLE_FILE_PATH, ENV_FILE_PATH)
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ from pathlib import Path
|
|||||||
# 项目根目录
|
# 项目根目录
|
||||||
BASE_PATH = Path(__file__).resolve().parent.parent
|
BASE_PATH = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# 环境变量文件
|
||||||
|
ENV_FILE_PATH = BASE_PATH / '.env'
|
||||||
|
|
||||||
|
# 环境变量示例文件
|
||||||
|
ENV_EXAMPLE_FILE_PATH = BASE_PATH / '.env.example'
|
||||||
|
|
||||||
# alembic 迁移文件存放路径
|
# alembic 迁移文件存放路径
|
||||||
ALEMBIC_VERSION_DIR = BASE_PATH / 'alembic' / 'versions'
|
ALEMBIC_VERSION_DIR = BASE_PATH / 'alembic' / 'versions'
|
||||||
|
|
||||||
@@ -19,4 +25,10 @@ UPLOAD_DIR = STATIC_DIR / 'upload'
|
|||||||
PLUGIN_DIR = BASE_PATH / 'plugin'
|
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
@@ -9,10 +9,10 @@ import socketio
|
|||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi_limiter import FastAPILimiter
|
from fastapi_limiter import FastAPILimiter
|
||||||
from fastapi_pagination import add_pagination
|
from fastapi_pagination import add_pagination
|
||||||
|
from prometheus_client import make_asgi_app
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
from starlette.types import ASGIApp
|
|
||||||
from starlette_context.middleware import ContextMiddleware
|
from starlette_context.middleware import ContextMiddleware
|
||||||
from starlette_context.plugins import RequestIdPlugin
|
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.jwt_auth_middleware import JwtAuthMiddleware
|
||||||
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
||||||
from backend.middleware.state_middleware import StateMiddleware
|
from backend.middleware.state_middleware import StateMiddleware
|
||||||
from backend.plugin.tools import build_final_router
|
from backend.plugin.core import build_final_router
|
||||||
from backend.utils.demo_site import demo_site
|
from backend.utils.demo_mode import demo_site
|
||||||
from backend.utils.health_check import ensure_unique_route_names, http_limit_callback
|
from backend.utils.limiter import http_limit_callback
|
||||||
from backend.utils.openapi import simplify_operation_ids
|
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.serializers import MsgSpecJSONResponse
|
||||||
from backend.utils.snowflake import snowflake
|
from backend.utils.snowflake import snowflake
|
||||||
|
from backend.utils.trace_id import OtelTraceIdPlugin
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -76,22 +78,7 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
def register_app() -> FastAPI:
|
def register_app() -> FastAPI:
|
||||||
"""注册 FastAPI 应用"""
|
"""注册 FastAPI 应用"""
|
||||||
|
|
||||||
class MyFastAPI(FastAPI):
|
app = 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(
|
|
||||||
title=settings.FASTAPI_TITLE,
|
title=settings.FASTAPI_TITLE,
|
||||||
version=__version__,
|
version=__version__,
|
||||||
description=settings.FASTAPI_DESCRIPTION,
|
description=settings.FASTAPI_DESCRIPTION,
|
||||||
@@ -111,6 +98,9 @@ def register_app() -> FastAPI:
|
|||||||
register_page(app)
|
register_page(app)
|
||||||
register_exception(app)
|
register_exception(app)
|
||||||
|
|
||||||
|
if settings.GRAFANA_METRICS:
|
||||||
|
register_metrics(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -164,15 +154,29 @@ def register_middleware(app: FastAPI) -> None:
|
|||||||
app.add_middleware(AccessMiddleware)
|
app.add_middleware(AccessMiddleware)
|
||||||
|
|
||||||
# ContextVar
|
# ContextVar
|
||||||
|
plugins = [OtelTraceIdPlugin()] if settings.GRAFANA_METRICS else [RequestIdPlugin(validate=True)]
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
ContextMiddleware,
|
ContextMiddleware,
|
||||||
plugins=[RequestIdPlugin(validate=True)],
|
plugins=plugins,
|
||||||
default_error_response=MsgSpecJSONResponse(
|
default_error_response=MsgSpecJSONResponse(
|
||||||
content={'code': StandardResponseCode.HTTP_400, 'msg': 'BAD_REQUEST', 'data': None},
|
content={'code': StandardResponseCode.HTTP_400, 'msg': 'BAD_REQUEST', 'data': None},
|
||||||
status_code=StandardResponseCode.HTTP_400,
|
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:
|
def register_router(app: FastAPI) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -218,3 +222,16 @@ def register_socket_app(app: FastAPI) -> None:
|
|||||||
socketio_path='/ws/socket.io',
|
socketio_path='/ws/socket.io',
|
||||||
)
|
)
|
||||||
app.mount('/ws', socket_app)
|
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)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
create_async_engine,
|
create_async_engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from backend.common.enums import DataBaseType
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.common.model import MappedBase
|
from backend.common.model import MappedBase
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
@@ -26,14 +27,14 @@ def create_database_url(*, unittest: bool = False) -> URL:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
url = URL.create(
|
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,
|
username=settings.DATABASE_USER,
|
||||||
password=settings.DATABASE_PASSWORD,
|
password=settings.DATABASE_PASSWORD,
|
||||||
host=settings.DATABASE_HOST,
|
host=settings.DATABASE_HOST,
|
||||||
port=settings.DATABASE_PORT,
|
port=settings.DATABASE_PORT,
|
||||||
database=settings.DATABASE_SCHEMA if not unittest else f'{settings.DATABASE_SCHEMA}_test',
|
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})
|
url.update_query_dict({'charset': settings.DATABASE_CHARSET})
|
||||||
return url
|
return url
|
||||||
|
|
||||||
@@ -91,6 +92,12 @@ async def create_tables() -> None:
|
|||||||
await coon.run_sync(MappedBase.metadata.create_all)
|
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:
|
def uuid4_str() -> str:
|
||||||
"""数据库引擎 UUID 类型兼容性解决方案"""
|
"""数据库引擎 UUID 类型兼容性解决方案"""
|
||||||
return str(uuid4())
|
return str(uuid4())
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,8 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from backend.core.registrar import register_app
|
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.console import console
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from backend.common.i18n import i18n
|
from backend.common.i18n import i18n
|
||||||
|
from backend.core.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def get_current_language(request: Request) -> str | None:
|
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', '')
|
accept_language = request.headers.get('Accept-Language', '')
|
||||||
if not accept_language:
|
if not accept_language:
|
||||||
return None
|
return settings.I18N_DEFAULT_LANGUAGE
|
||||||
|
|
||||||
languages = [lang.split(';')[0] for lang in accept_language.split(',')]
|
languages = [lang.split(';')[0] for lang in accept_language.split(',')]
|
||||||
lang = languages[0].lower().strip()
|
lang = languages[0].lower().strip()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
from starlette.datastructures import UploadFile
|
from starlette.datastructures import UploadFile
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
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.schema.opera_log import CreateOperaLogParam
|
||||||
from backend.app.admin.service.opera_log_service import opera_log_service
|
from backend.app.admin.service.opera_log_service import opera_log_service
|
||||||
from backend.common.context import ctx
|
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.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.queue import batch_dequeue
|
||||||
from backend.common.response.response_code import StandardResponseCode
|
from backend.common.response.response_code import StandardResponseCode
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import async_db_session
|
from backend.database.db import async_db_session
|
||||||
from backend.utils.encrypt import AESCipher, ItsDCipher, Md5Cipher
|
|
||||||
from backend.utils.trace_id import get_request_trace_id
|
from backend.utils.trace_id import get_request_trace_id
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +49,10 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
else:
|
else:
|
||||||
method = request.method
|
method = request.method
|
||||||
args = await self.get_request_args(request)
|
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
|
code = 200
|
||||||
@@ -63,6 +73,12 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
code = exception.get('code')
|
code = exception.get('code')
|
||||||
msg = exception.get('msg')
|
msg = exception.get('msg')
|
||||||
log.error(f'请求异常: {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
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = round((time.perf_counter() - ctx.perf_time) * 1000, 3)
|
elapsed = round((time.perf_counter() - ctx.perf_time) * 1000, 3)
|
||||||
@@ -71,6 +87,20 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
status = StatusType.disable
|
status = StatusType.disable
|
||||||
error = e
|
error = e
|
||||||
log.error(f'请求异常: {e!s}')
|
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')
|
route = request.scope.get('route')
|
||||||
@@ -86,7 +116,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
log.debug(f'接口摘要:[{summary}]')
|
log.debug(f'接口摘要:[{summary}]')
|
||||||
log.debug(f'请求地址:[{ctx.ip}]')
|
log.debug(f'请求地址:[{ctx.ip}]')
|
||||||
log.debug(f'请求参数:{args}')
|
log.debug(f'请求参数:{args}')
|
||||||
log.info(f'{request.client.host: <15} | {request.method: <8} | {code!s: <6} | {path} | {elapsed:.3f}ms')
|
log.info(f'{ctx.ip: <15} | {request.method: <8} | {code!s: <6} | {path} | {elapsed:.3f}ms')
|
||||||
if request.method != 'OPTIONS':
|
if request.method != 'OPTIONS':
|
||||||
log.debug('<-- 请求结束')
|
log.debug('<-- 请求结束')
|
||||||
|
|
||||||
@@ -120,7 +150,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
return response
|
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)
|
query_params = dict(request.query_params)
|
||||||
if 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
|
path_params = request.path_params
|
||||||
if path_params:
|
if path_params:
|
||||||
args['path_params'] = await self.desensitization(path_params)
|
args['path_params'] = self.desensitization(path_params)
|
||||||
|
|
||||||
# Tip: .body() 必须在 .form() 之前获取
|
# Tip: .body() 必须在 .form() 之前获取
|
||||||
# https://github.com/encode/starlette/discussions/1933
|
# https://github.com/encode/starlette/discussions/1933
|
||||||
@@ -148,28 +178,66 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
if body_data:
|
if body_data:
|
||||||
# 注意:非 json 数据默认使用 data 作为键
|
# 注意:非 json 数据默认使用 data 作为键
|
||||||
if 'application/json' not in content_type:
|
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:
|
else:
|
||||||
json_data = await request.json()
|
json_data = await request.json()
|
||||||
if isinstance(json_data, dict):
|
if isinstance(json_data, dict):
|
||||||
args['json'] = await self.desensitization(json_data)
|
args['json'] = self.desensitization(json_data)
|
||||||
else:
|
else:
|
||||||
args['data'] = str(body_data)
|
args['data'] = str(json_data)
|
||||||
|
|
||||||
# 表单参数
|
# 表单参数
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
if len(form_data) > 0:
|
if len(form_data) > 0:
|
||||||
|
serialized_form = {}
|
||||||
for k, v in form_data.items():
|
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:
|
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:
|
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
|
return args or None
|
||||||
|
|
||||||
@staticmethod
|
@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]:
|
def desensitization(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
脱敏处理
|
脱敏处理
|
||||||
@@ -177,20 +245,9 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
:param args: 需要脱敏的参数字典
|
:param args: 需要脱敏的参数字典
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
for key, value in args.items():
|
for key in args:
|
||||||
if key in settings.OPERA_LOG_ENCRYPT_KEY_INCLUDE:
|
if key in settings.OPERA_LOG_REDACT_KEYS:
|
||||||
match settings.OPERA_LOG_ENCRYPT_TYPE:
|
args[key] = '[REDACTED]'
|
||||||
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] = '******'
|
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -208,6 +265,8 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
log.info('自动执行【操作日志批量创建】任务...')
|
log.info('自动执行【操作日志批量创建】任务...')
|
||||||
async with async_db_session.begin() as db:
|
async with async_db_session.begin() as db:
|
||||||
await opera_log_service.bulk_create(db=db, objs=logs)
|
await opera_log_service.bulk_create(db=db, objs=logs)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'操作日志入库失败,丢失 {len(logs)} 条日志: {e}')
|
||||||
finally:
|
finally:
|
||||||
if not cls.opera_log_queue.empty():
|
for _ in range(len(logs)):
|
||||||
cls.opera_log_queue.task_done()
|
cls.opera_log_queue.task_done()
|
||||||
|
|||||||
Executable → Regular
@@ -9,8 +9,8 @@ from backend.common.security.permission import RequestPermission
|
|||||||
from backend.common.security.rbac import DependsRBAC
|
from backend.common.security.rbac import DependsRBAC
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||||
from backend.plugin.code_generator.schema.code import ImportParam
|
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||||
from backend.plugin.code_generator.service.code_service import gen_service
|
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -3,6 +3,7 @@ from collections.abc import Sequence
|
|||||||
from sqlalchemy import Row, RowMapping, text
|
from sqlalchemy import Row, RowMapping, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.common.enums import DataBaseType
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class CRUDGen:
|
|||||||
:param table_schema: 数据库 schema 名称
|
:param table_schema: 数据库 schema 名称
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
if settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT table_name AS table_name, table_comment AS table_comment
|
SELECT table_name AS table_name, table_comment AS table_comment
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
@@ -48,7 +49,7 @@ class CRUDGen:
|
|||||||
:param table_name: 表名
|
:param table_name: 表名
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
if settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT table_name AS table_name, table_comment AS table_comment
|
SELECT table_name AS table_name, table_comment AS table_comment
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
@@ -79,7 +80,7 @@ class CRUDGen:
|
|||||||
:param table_name: 表名
|
:param table_name: 表名
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
if settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT column_name AS column_name,
|
SELECT column_name AS column_name,
|
||||||
CASE WHEN column_key = 'PRI' THEN 1 ELSE 0 END AS is_pk,
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.common.enums import DataBaseType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
||||||
@@ -32,7 +33,7 @@ class GenColumnService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_types() -> list[str]:
|
async def get_types() -> list[str]:
|
||||||
"""获取所有列类型"""
|
"""获取所有列类型"""
|
||||||
if settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
types = GenMySQLColumnType.get_member_keys()
|
types = GenMySQLColumnType.get_member_keys()
|
||||||
else:
|
else:
|
||||||
types = GenPostgreSQLColumnType.get_member_keys()
|
types = GenPostgreSQLColumnType.get_member_keys()
|
||||||
|
|||||||
+3
-3
@@ -14,14 +14,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.path_conf import BASE_PATH
|
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_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_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.model import GenBusiness
|
||||||
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam
|
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.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.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
|
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 functools import lru_cache
|
||||||
|
|
||||||
|
from backend.common.enums import DataBaseType
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.plugin.code_generator.enums import GenMySQLColumnType, GenPostgreSQLColumnType
|
from backend.plugin.code_generator.enums import GenMySQLColumnType, GenPostgreSQLColumnType
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ def sql_type_to_sqlalchemy(typing: str) -> str:
|
|||||||
:param typing: SQL 类型字符串
|
:param typing: SQL 类型字符串
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
if settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
if typing in GenMySQLColumnType.get_member_keys():
|
if typing in GenMySQLColumnType.get_member_keys():
|
||||||
return typing
|
return typing
|
||||||
else:
|
else:
|
||||||
@@ -30,7 +31,7 @@ def sql_type_to_pydantic(typing: str) -> str:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if settings.DATABASE_TYPE == 'mysql':
|
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||||
return GenMySQLColumnType[typing].value
|
return GenMySQLColumnType[typing].value
|
||||||
if typing == 'CHARACTER VARYING': # postgresql 中 DDL VARCHAR 的别名
|
if typing == 'CHARACTER VARYING': # postgresql 中 DDL VARCHAR 的别名
|
||||||
return 'str'
|
return 'str'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
|
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
values
|
||||||
(1, '状态', 'EMAIL', 'EMAIL_STATUS', '1', 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),
|
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 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)
|
insert into sys_config (id, name, type, `key`, value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
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),
|
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 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)
|
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
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),
|
(2, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', 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)
|
insert into sys_config (id, name, type, "key", value, is_frontend, remark, created_time, updated_time)
|
||||||
values
|
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),
|
(2069061886627938305, '服务器地址', 'EMAIL', 'EMAIL_HOST', 'smtp.qq.com', false, null, now(), null),
|
||||||
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
|
||||||
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
|
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from importlib.metadata import PackageNotFoundError, distribution
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import rtoml
|
import rtoml
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
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.enums import DataBaseType, PrimaryKeyType, StatusType
|
||||||
from backend.common.exception import errors
|
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.conf import settings
|
||||||
from backend.core.path_conf import PLUGIN_DIR
|
from backend.core.path_conf import PLUGIN_DIR
|
||||||
from backend.database.redis import RedisCli, redis_client
|
from backend.database.redis import RedisCli, redis_client
|
||||||
from backend.utils._await import run_await
|
from backend.utils.async_helper import run_await
|
||||||
from backend.utils.import_parse import get_model_objects, import_module_cached
|
from backend.utils.dynamic_import import get_model_objects, import_module_cached
|
||||||
|
|
||||||
|
|
||||||
class PluginConfigError(Exception):
|
class PluginConfigError(Exception):
|
||||||
@@ -33,10 +28,6 @@ class PluginInjectError(Exception):
|
|||||||
"""插件注入错误"""
|
"""插件注入错误"""
|
||||||
|
|
||||||
|
|
||||||
class PluginInstallError(Exception):
|
|
||||||
"""插件安装错误"""
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_plugins() -> list[str]:
|
def get_plugins() -> list[str]:
|
||||||
"""获取插件列表"""
|
"""获取插件列表"""
|
||||||
@@ -55,15 +46,15 @@ def get_plugins() -> list[str]:
|
|||||||
return plugin_packages
|
return plugin_packages
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_models() -> list[type]:
|
def get_plugin_models() -> list[object]:
|
||||||
"""获取插件所有模型类"""
|
"""获取插件所有模型类"""
|
||||||
objs = []
|
objs = []
|
||||||
|
|
||||||
for plugin in get_plugins():
|
for plugin in get_plugins():
|
||||||
module_path = f'backend.plugin.{plugin}.model'
|
module_path = f'backend.plugin.{plugin}.model'
|
||||||
obj = get_model_objects(module_path)
|
model_objs = get_model_objects(module_path)
|
||||||
if obj:
|
if model_objs:
|
||||||
objs.extend(obj)
|
objs.extend(model_objs)
|
||||||
|
|
||||||
return 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:
|
if db_type == DataBaseType.mysql:
|
||||||
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
|
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
|
||||||
if pk_type == PrimaryKeyType.autoincrement:
|
sql_file = (
|
||||||
sql_file = mysql_dir / 'init.sql'
|
mysql_dir / 'init.sql' if pk_type == PrimaryKeyType.autoincrement else mysql_dir / 'init_snowflake.sql'
|
||||||
else:
|
)
|
||||||
sql_file = mysql_dir / 'init_snowflake.sql'
|
|
||||||
else:
|
else:
|
||||||
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
|
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
|
||||||
if pk_type == PrimaryKeyType.autoincrement:
|
sql_file = (
|
||||||
sql_file = postgresql_dir / 'init.sql'
|
postgresql_dir / 'init.sql'
|
||||||
else:
|
if pk_type == PrimaryKeyType.autoincrement
|
||||||
sql_file = postgresql_dir / 'init_snowflake.sql'
|
else postgresql_dir / 'init_snowflake.sql'
|
||||||
|
)
|
||||||
|
|
||||||
path = anyio.Path(sql_file)
|
path = anyio.Path(sql_file)
|
||||||
if not await path.exists():
|
if not await path.exists():
|
||||||
@@ -278,155 +269,6 @@ def build_final_router() -> APIRouter:
|
|||||||
return main_router
|
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:
|
class PluginStatusChecker:
|
||||||
"""插件状态检查器"""
|
"""插件状态检查器"""
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -3,7 +3,6 @@ from fastapi import APIRouter
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.plugin.oauth2.api.v1.github import router as github_router
|
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.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
|
from backend.plugin.oauth2.api.v1.user_social import router as user_social_router
|
||||||
|
|
||||||
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/oauth2')
|
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(user_social_router, tags=['OAuth2'])
|
||||||
v1.include_router(github_router, prefix='/github', tags=['Github OAuth2'])
|
v1.include_router(github_router, prefix='/github', tags=['Github OAuth2'])
|
||||||
v1.include_router(google_router, prefix='/google', tags=['Google OAuth2'])
|
v1.include_router(google_router, prefix='/google', tags=['Google OAuth2'])
|
||||||
v1.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2'])
|
|
||||||
|
|||||||
@@ -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}',
|
|
||||||
)
|
|
||||||
@@ -6,7 +6,6 @@ class UserSocialType(StrEnum):
|
|||||||
|
|
||||||
github = 'Github'
|
github = 'Github'
|
||||||
google = 'Google'
|
google = 'Google'
|
||||||
linux_do = 'LinuxDo'
|
|
||||||
|
|
||||||
|
|
||||||
class UserSocialAuthType(StrEnum):
|
class UserSocialAuthType(StrEnum):
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ class OAuth2Service:
|
|||||||
await db.refresh(sys_user)
|
await db.refresh(sys_user)
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
login_log_service.create,
|
login_log_service.create,
|
||||||
db=db,
|
|
||||||
user_uuid=sys_user.uuid,
|
user_uuid=sys_user.uuid,
|
||||||
username=sys_user.username,
|
username=sys_user.username,
|
||||||
login_time=timezone.now(),
|
login_time=timezone.now(),
|
||||||
@@ -168,9 +167,6 @@ class OAuth2Service:
|
|||||||
username = user.get('name')
|
username = user.get('name')
|
||||||
nickname = user.get('given_name')
|
nickname = user.get('given_name')
|
||||||
avatar = user.get('picture')
|
avatar = user.get('picture')
|
||||||
case UserSocialType.linux_do:
|
|
||||||
sid = user.get('id')
|
|
||||||
nickname = user.get('name')
|
|
||||||
case _:
|
case _:
|
||||||
raise errors.ForbiddenError(msg=f'暂不支持 {social} OAuth2 登录')
|
raise errors.ForbiddenError(msg=f'暂不支持 {social} OAuth2 登录')
|
||||||
|
|
||||||
|
|||||||
@@ -90,13 +90,6 @@ class UserSocialService:
|
|||||||
redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI,
|
redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI,
|
||||||
state=state,
|
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 _:
|
case _:
|
||||||
raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定')
|
raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定')
|
||||||
|
|
||||||
|
|||||||
@@ -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 importlib
|
||||||
import inspect
|
import inspect
|
||||||
|
import os.path
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ def dynamic_import_data_model(module_path: str) -> type[T]:
|
|||||||
raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员')
|
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:
|
try:
|
||||||
module = import_module_cached(module_path)
|
module = import_module_cached(module_path)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
log.warning(f'模块 {module_path} 中不包含模型对象')
|
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception as e:
|
||||||
raise
|
raise e from None
|
||||||
|
|
||||||
classes = []
|
classes = []
|
||||||
|
|
||||||
for _name, obj in inspect.getmembers(module):
|
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)
|
classes.append(obj)
|
||||||
|
|
||||||
return classes
|
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
@@ -1,23 +1,11 @@
|
|||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
import anyio
|
|
||||||
|
|
||||||
from anyio import open_file
|
from anyio import open_file
|
||||||
from dulwich import porcelain
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from sqlparse import split
|
|
||||||
|
|
||||||
from backend.common.enums import FileType
|
from backend.common.enums import FileType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR
|
from backend.core.path_conf import 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.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -79,109 +67,3 @@ async def upload_file(file: UploadFile) -> str:
|
|||||||
raise errors.RequestError(msg='上传文件失败')
|
raise errors.RequestError(msg='上传文件失败')
|
||||||
await file.close()
|
await file.close()
|
||||||
return filename
|
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
|
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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)},
|
||||||
|
)
|
||||||
@@ -12,3 +12,18 @@ def simplify_operation_ids(app: FastAPI) -> None:
|
|||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, APIRoute):
|
if isinstance(route, APIRoute):
|
||||||
route.operation_id = route.name
|
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)
|
||||||
|
|||||||
@@ -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
|
import time
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from math import ceil
|
|
||||||
from typing import Any
|
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.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
|
def timer(func) -> Callable: # noqa: ANN001
|
||||||
@@ -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()
|
|
||||||
@@ -32,19 +32,16 @@ def get_request_ip(request: Request) -> str:
|
|||||||
return request.client.host
|
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 地址属地,无法保证可用性,准确率较高
|
在线获取 IP 地址属地,无法保证可用性,准确率较高
|
||||||
|
|
||||||
:param ip: IP 地址
|
:param ip: IP 地址
|
||||||
:param user_agent: 用户代理字符串
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient(timeout=3) as client:
|
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:
|
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:
|
if response.status_code == 200:
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -92,7 +89,7 @@ async def parse_ip_info(request: Request) -> IpInfo:
|
|||||||
|
|
||||||
location_info = None
|
location_info = None
|
||||||
if settings.IP_LOCATION_PARSE == 'online':
|
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':
|
elif settings.IP_LOCATION_PARSE == 'offline':
|
||||||
location_info = get_location_offline(ip)
|
location_info = get_location_offline(ip)
|
||||||
|
|
||||||
@@ -115,9 +112,11 @@ def parse_user_agent_info(request: Request) -> UserAgentInfo:
|
|||||||
:param request: FastAPI 请求对象
|
:param request: FastAPI 请求对象
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
os, browser, device = None, None, None
|
||||||
user_agent = request.headers.get('User-Agent')
|
user_agent = request.headers.get('User-Agent')
|
||||||
user_agent_ = parse(user_agent)
|
if user_agent:
|
||||||
os = user_agent_.get_os()
|
user_agent_ = parse(user_agent)
|
||||||
browser = user_agent_.get_browser()
|
os = user_agent_.get_os()
|
||||||
device = user_agent_.get_device()
|
browser = user_agent_.get_browser()
|
||||||
|
device = user_agent_.get_device()
|
||||||
return UserAgentInfo(user_agent=user_agent, device=device, os=os, browser=browser)
|
return UserAgentInfo(user_agent=user_agent, device=device, os=os, browser=browser)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
Reference in New Issue
Block a user