mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc484bf414 | ||
|
|
7568275126 | ||
|
|
67a6a58e57 | ||
|
|
8e5c379853 | ||
|
|
2dffe8f6a8 | ||
|
|
c802f67a5a | ||
|
|
1916261795 | ||
|
|
325016adc8 | ||
|
|
09fd380c3a | ||
|
|
e6292747bd | ||
|
|
a96b2cfad1 | ||
|
|
a0a1bde88c | ||
|
|
9251a5d5fd | ||
|
|
e3d2c68592 | ||
|
|
4d6970c6cc | ||
|
|
d4f78d6448 | ||
|
|
efec3308df | ||
|
|
df045ac9f0 | ||
|
|
70159d4615 | ||
|
|
0441907b00 | ||
|
|
08ee3878c1 | ||
|
|
c7e686588e | ||
|
|
495046c978 | ||
|
|
ffaa1282cc | ||
|
|
15dda9ab6e | ||
|
|
814068a9e6 | ||
|
|
39cdfdfb00 | ||
|
|
4b730099cd | ||
|
|
974972b490 | ||
|
|
bffc564dd7 | ||
|
|
4722dceff0 | ||
|
|
4eded4f55a | ||
|
|
c9b4678148 | ||
|
|
d20bc91465 | ||
|
|
bc8d1ab9e3 | ||
|
|
a5e9a0e805 | ||
|
|
1897b3198e | ||
|
|
6b9c7d81de | ||
|
|
e9b83d3c93 | ||
|
|
0f0e94c9b1 | ||
|
|
ba0f30cde1 | ||
|
|
9f3673d812 | ||
|
|
ef9a6b554c | ||
|
|
dbfdc6762e | ||
|
|
2e86d79150 | ||
|
|
5352f98c13 | ||
|
|
6011b3aea2 | ||
|
|
e32b4232c5 | ||
|
|
8d03e61995 | ||
|
|
0731f38702 | ||
|
|
f7051d4f9d | ||
|
|
1f287bbb0c | ||
|
|
05e00b287b | ||
|
|
4a08938de7 | ||
|
|
6c079e41cc | ||
|
|
505ae9e59a | ||
|
|
3a0dcb8e9f | ||
|
|
a5cc8ac31b | ||
|
|
96e0635f84 | ||
|
|
15bea9e149 | ||
|
|
ee00470ced | ||
|
|
fbbf6480ee | ||
|
|
4e7db61ab2 | ||
|
|
f6507c86dd | ||
|
|
0dd79ee3be | ||
|
|
46ad7a5d7f | ||
|
|
e5b8a5242d | ||
|
|
22f1a243a0 | ||
|
|
feb2bcbe82 | ||
|
|
c256f5c4ff | ||
|
|
80146224ab | ||
|
|
87b8f6dbb1 | ||
|
|
ed1a164f55 | ||
|
|
f5285a4947 | ||
|
|
74d159334f | ||
|
|
83c3a71378 | ||
|
|
f0856f0c6f |
+3
-1
@@ -7,9 +7,11 @@ venv/
|
||||
.python-version
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
logs/
|
||||
.claude/
|
||||
.serena/
|
||||
.agents/
|
||||
logs/
|
||||
.superpowers/
|
||||
superpowers/
|
||||
.codegraph/
|
||||
.pi/
|
||||
|
||||
@@ -11,7 +11,7 @@ repos:
|
||||
- id: check-toml
|
||||
|
||||
- repo: https://github.com/tombi-toml/tombi-pre-commit
|
||||
rev: v0.9.25
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: tombi-lint
|
||||
args: ["--offline"]
|
||||
@@ -19,7 +19,7 @@ repos:
|
||||
args: ["--offline"]
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.15.12
|
||||
rev: v0.16.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
@@ -29,7 +29,7 @@ repos:
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.11.8
|
||||
rev: 0.12.3
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
- id: uv-export
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"description": "JSON Schema for FastAPI Best Architecture plugin.toml files. See: https://fastapi-practices.github.io/fastapi_best_architecture_docs/plugin/dev.html",
|
||||
|
||||
"type": "object",
|
||||
"required": ["plugin", "app"],
|
||||
"required": ["plugin"],
|
||||
"additionalProperties": false,
|
||||
|
||||
"properties": {
|
||||
"plugin": {
|
||||
"type": "object",
|
||||
"description": "Plugin metadata",
|
||||
"required": ["summary", "version", "description", "author", "tags", "database"],
|
||||
"required": ["summary", "version", "description", "author", "tags"],
|
||||
"additionalProperties": false,
|
||||
"x-tombi-table-keys-order": "schema",
|
||||
"properties": {
|
||||
@@ -55,7 +55,7 @@
|
||||
"database": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Supported databases",
|
||||
"description": "Supported databases. Required for app-level and extend-level plugins, optional for capability-level plugins without models.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql"]
|
||||
@@ -111,7 +111,11 @@
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "number" },
|
||||
{ "type": "boolean" }
|
||||
{ "type": "boolean" },
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+110
@@ -1,3 +1,110 @@
|
||||
<a id="v1.15.0"></a>
|
||||
# [v1.15.0](https://github.com/fastapi-practices/fastapi-best-architecture/releases/tag/v1.15.0) - 2026-07-10
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.14.0 by [@wu-clan](https://github.com/wu-clan) in [#1194](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1194)
|
||||
* Add celery explicit idempotent protection by [@wu-clan](https://github.com/wu-clan) in [#1195](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1195)
|
||||
* Optimize plugin core and add otel hooks by [@wu-clan](https://github.com/wu-clan) in [#1196](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1196)
|
||||
* Fix user salt column LargeBinary length by [@IAseven](https://github.com/IAseven) in [#1197](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1197)
|
||||
* Add plugin deps command to the CLI by [@wu-clan](https://github.com/wu-clan) in [#1200](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1200)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#1205](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1205)
|
||||
* Fix redis pubsub socket timeout by [@wu-clan](https://github.com/wu-clan) in [#1206](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1206)
|
||||
* Add AI dynamic configuration menu by [@wu-clan](https://github.com/wu-clan) in [#1209](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1209)
|
||||
* Refactor cache key and prefix APIs by [@wu-clan](https://github.com/wu-clan) in [#1210](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1210)
|
||||
* Fix redis rate limiter bucket routing by [@wu-clan](https://github.com/wu-clan) in [#1212](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1212)
|
||||
* Add underlying security config for JWT by [@wu-clan](https://github.com/wu-clan) in [#1214](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1214)
|
||||
* Fix JWT auth dependency error propagation by [@wu-clan](https://github.com/wu-clan) in [#1215](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1215)
|
||||
* Add is superuser protocol to ctx by [@wu-clan](https://github.com/wu-clan) in [#1216](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1216)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#1218](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1218)
|
||||
* Add support for capability plugins by [@yzbf-lin](https://github.com/yzbf-lin) in [#1217](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1217)
|
||||
* Update capability plugin config validation by [@wu-clan](https://github.com/wu-clan) in [#1219](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1219)
|
||||
* Update document link addresses in the CLI by [@wu-clan](https://github.com/wu-clan) in [#1220](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1220)
|
||||
* Fix duplicate log reporting in OTEL by [@IAseven](https://github.com/IAseven) in [#1221](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1221)
|
||||
* Refactor plugin dynamic config loading by [@wu-clan](https://github.com/wu-clan) in [#1222](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1222)
|
||||
* Update xdb database file to release by [@wu-clan](https://github.com/wu-clan) in [#1223](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1223)
|
||||
* Update plugin config format validation by [@wu-clan](https://github.com/wu-clan) in [#1224](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1224)
|
||||
* Update the version number to 1.15.0 by [@wu-clan](https://github.com/wu-clan) in [#1225](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1225)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.14.0...v1.15.0
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/IAseven"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FIAseven.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@IAseven"></a>
|
||||
<a href="https://github.com/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/yzbf-lin"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyzbf-lin.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yzbf-lin"></a>
|
||||
|
||||
[Changes][v1.15.0]
|
||||
|
||||
|
||||
<a id="v1.14.0"></a>
|
||||
# [v1.14.0](https://github.com/fastapi-practices/fastapi-best-architecture/releases/tag/v1.14.0) - 2026-05-30
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.13.4 by [@wu-clan](https://github.com/wu-clan) in [#1170](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1170)
|
||||
* Update xdb dependency and database file by [@wu-clan](https://github.com/wu-clan) in [#1171](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1171)
|
||||
* Optimize database operations within loops by [@wu-clan](https://github.com/wu-clan) in [#1177](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1177)
|
||||
* Optimize some global variable definitions by [@wu-clan](https://github.com/wu-clan) in [#1178](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1178)
|
||||
* Optimize SQL script execution in the CLI by [@wu-clan](https://github.com/wu-clan) in [#1181](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1181)
|
||||
* Fix cache invalidation decorator usage by [@wu-clan](https://github.com/wu-clan) in [#1182](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1182)
|
||||
* Optimize user session logging and deletion by [@wu-clan](https://github.com/wu-clan) in [#1185](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1185)
|
||||
* Optimize operation log truncation handling by [@wu-clan](https://github.com/wu-clan) in [#1186](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1186)
|
||||
* Add opera log task cancel to lifespan by [@wu-clan](https://github.com/wu-clan) in [#1187](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1187)
|
||||
* Optimize Grafana observability metrics and config by [@wu-clan](https://github.com/wu-clan) in [#1188](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1188)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#1189](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1189)
|
||||
* Optimize the usage of setex for redis by [@wu-clan](https://github.com/wu-clan) in [#1190](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1190)
|
||||
* Refactor physical deletion to logical deletion by [@wu-clan](https://github.com/wu-clan) in [#1191](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1191)
|
||||
* Fix task scheduler unpack fields by [@wu-clan](https://github.com/wu-clan) in [#1192](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1192)
|
||||
* Update the version number to 1.14.0 by [@wu-clan](https://github.com/wu-clan) in [#1193](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1193)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.4...v1.14.0
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.14.0]
|
||||
|
||||
|
||||
<a id="v1.13.4"></a>
|
||||
# [v1.13.4](https://github.com/fastapi-practices/fastapi-best-architecture/releases/tag/v1.13.4) - 2026-04-28
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.13.3 by [@wu-clan](https://github.com/wu-clan) in [#1149](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1149)
|
||||
* Update the architecture description in README by [@wu-clan](https://github.com/wu-clan) in [#1150](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1150)
|
||||
* Fix app pytest client fixture scope by [@IAseven](https://github.com/IAseven) in [#1152](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1152)
|
||||
* Remove cleanup operations after plugin install by [@wu-clan](https://github.com/wu-clan) in [#1154](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1154)
|
||||
* Fix mysql enum data type mismatch by [@wu-clan](https://github.com/wu-clan) in [#1155](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1155)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#1156](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1156)
|
||||
* Add plugin depends on and lifecycle ordering by [@AH-Toby](https://github.com/AH-Toby) in [#1153](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1153)
|
||||
* Update plugin README content format by [@wu-clan](https://github.com/wu-clan) in [#1158](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1158)
|
||||
* Update Grafana dashboard and datasource config by [@wu-clan](https://github.com/wu-clan) in [#1159](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1159)
|
||||
* Add database and queue observability by [@wu-clan](https://github.com/wu-clan) in [#1160](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1160)
|
||||
* Fix Grafana tempo and observability config by [@wu-clan](https://github.com/wu-clan) in [#1161](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1161)
|
||||
* Add the snowflake ID enabled config by [@wu-clan](https://github.com/wu-clan) in [#1162](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1162)
|
||||
* Fix the add plugin CLI command config by [@wu-clan](https://github.com/wu-clan) in [#1163](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1163)
|
||||
* Optimize the core code ordering of plugins by [@wu-clan](https://github.com/wu-clan) in [#1164](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1164)
|
||||
* Add uninstall plugin operation checks by [@wu-clan](https://github.com/wu-clan) in [#1165](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1165)
|
||||
* Fix multi level cache storage logic by [@wu-clan](https://github.com/wu-clan) in [#1166](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1166)
|
||||
* Fix typing warnings for return methods by [@wu-clan](https://github.com/wu-clan) in [#1167](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1167)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#1168](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1168)
|
||||
* Update the version number to 1.13.4 by [@wu-clan](https://github.com/wu-clan) in [#1169](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1169)
|
||||
|
||||
## New Contributors
|
||||
* [@AH-Toby](https://github.com/AH-Toby) made their first contribution in [#1153](https://github.com/fastapi-practices/fastapi-best-architecture/pull/1153)
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.3...v1.13.4
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/AH-Toby"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FAH-Toby.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@AH-Toby"></a>
|
||||
<a href="https://github.com/IAseven"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FIAseven.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@IAseven"></a>
|
||||
<a href="https://github.com/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.13.4]
|
||||
|
||||
|
||||
<a id="v1.13.3"></a>
|
||||
# [v1.13.3](https://github.com/fastapi-practices/fastapi-best-architecture/releases/tag/v1.13.3) - 2026-04-08
|
||||
|
||||
@@ -1473,6 +1580,9 @@
|
||||
[Changes][v1.0.0]
|
||||
|
||||
|
||||
[v1.15.0]: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.14.0...v1.15.0
|
||||
[v1.14.0]: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.4...v1.14.0
|
||||
[v1.13.4]: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.3...v1.13.4
|
||||
[v1.13.3]: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.2...v1.13.3
|
||||
[v1.13.2]: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.1...v1.13.2
|
||||
[v1.13.1]: https://github.com/fastapi-practices/fastapi-best-architecture/compare/v1.13.0...v1.13.1
|
||||
|
||||
+3
-9
@@ -2,12 +2,12 @@
|
||||
ARG SERVER_TYPE=fba_server
|
||||
|
||||
# === Python environment from uv ===
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS builder
|
||||
|
||||
# Used for build Python packages
|
||||
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc python3-dev \
|
||||
&& apt-get install -y --no-install-recommends gcc make python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . /fba
|
||||
@@ -31,19 +31,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)"
|
||||
|
||||
# === Runtime base server image ===
|
||||
FROM python:3.10-slim-bookworm AS base_server
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS base_server
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ADD https://astral.sh/uv/install.sh /uv-installer.sh
|
||||
|
||||
RUN sh /uv-installer.sh && rm /uv-installer.sh
|
||||
|
||||
ENV PATH="/root/.local/bin/:$PATH"
|
||||
|
||||
COPY --from=builder /fba /fba
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
|
||||
@@ -6,6 +6,9 @@ DATABASE_HOST='127.0.0.1'
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USER='postgres'
|
||||
DATABASE_PASSWORD='123456'
|
||||
# 额外数据源
|
||||
# DATABASE_SOURCES='{"read": "postgresql+asyncpg://user:password@host:5432/fba"}'
|
||||
DATABASE_SOURCES='{}'
|
||||
# Redis
|
||||
REDIS_HOST='127.0.0.1'
|
||||
REDIS_PORT=6379
|
||||
|
||||
+11
-11
@@ -2,16 +2,16 @@ 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
|
||||
|
||||
def _register_model_globals() -> None:
|
||||
"""导入所有模型并注册到 backend 模块命名空间"""
|
||||
for model_obj in get_all_models():
|
||||
model_name = model_obj.name if isinstance(model_obj, sa.Table) else model_obj.__name__
|
||||
if model_name not in globals():
|
||||
globals()[model_name] = model_obj
|
||||
|
||||
|
||||
__version__ = '1.13.4'
|
||||
_register_model_globals()
|
||||
|
||||
|
||||
__version__ = '1.15.0'
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from backend.common.model import MappedBase
|
||||
from backend.core import path_conf
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.database.db import SQLALCHEMY_DATABASE_URL
|
||||
from backend.database.db import get_database_url
|
||||
|
||||
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
|
||||
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
|
||||
@@ -35,7 +35,7 @@ target_metadata = MappedBase.metadata
|
||||
# ... etc.
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%'),
|
||||
get_database_url().render_as_string(hide_password=False).replace('%', '%%'),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
def process_revision_directives(context, revision, directives) -> None: # noqa: ANN001
|
||||
def process_revision_directives(context, revision, directives) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""当迁移无变化时,不生成迁移记录"""
|
||||
if config.cmd_opts.autogenerate:
|
||||
script = directives[0]
|
||||
|
||||
@@ -18,9 +18,11 @@ router = APIRouter()
|
||||
async def get_sessions(
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
) -> ResponseSchemaModel[list[GetTokenDetail]]:
|
||||
token_keys = await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:*')
|
||||
token_keys = await redis_client.get_by_prefix(settings.TOKEN_REDIS_PREFIX)
|
||||
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
|
||||
data: list[GetTokenDetail] = []
|
||||
if not token_keys:
|
||||
return response_base.success(data=data)
|
||||
|
||||
def append_token_detail() -> None:
|
||||
data.append(
|
||||
@@ -37,8 +39,12 @@ async def get_sessions(
|
||||
),
|
||||
)
|
||||
|
||||
for key in token_keys:
|
||||
token = await redis_client.get(key)
|
||||
token_values = await redis_client.mget(*token_keys)
|
||||
token_details: list[GetTokenDetail] = []
|
||||
extra_info_keys: list[str] = []
|
||||
for token in token_values:
|
||||
if not token:
|
||||
continue
|
||||
token_payload = jwt_decode(token)
|
||||
user_id = token_payload.user_id
|
||||
session_uuid = token_payload.session_uuid
|
||||
@@ -55,7 +61,11 @@ async def get_sessions(
|
||||
last_login_time='未知',
|
||||
expire_time=token_payload.expire_time,
|
||||
)
|
||||
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
token_details.append(token_detail)
|
||||
extra_info_keys.append(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
|
||||
extra_infos = await redis_client.mget(*extra_info_keys) if extra_info_keys else []
|
||||
for token_detail, extra_info in zip(token_details, extra_infos, strict=True):
|
||||
if extra_info:
|
||||
extra_info = json.loads(extra_info)
|
||||
# 排除 swagger 登录生成的 token
|
||||
|
||||
@@ -28,8 +28,8 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='Server 监控', dependencies=[DependsSuperUser])
|
||||
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901
|
||||
def get_all_info() -> ServerMonitorInfo: # noqa: C901
|
||||
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # ruff:ignore[complex-structure]
|
||||
def get_all_info() -> ServerMonitorInfo: # ruff:ignore[complex-structure]
|
||||
# CPU 信息
|
||||
cpu_data = {
|
||||
'physical_num': psutil.cpu_count(logical=False) or 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import DataRule
|
||||
from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
@@ -19,7 +20,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
:param pk: 规则 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None) -> Select:
|
||||
"""
|
||||
@@ -28,7 +29,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -43,7 +44,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
|
||||
"""
|
||||
@@ -52,7 +53,17 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[DataRule]:
|
||||
"""
|
||||
通过 ID 列表批量获取数据规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=pks, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
|
||||
"""
|
||||
@@ -73,7 +84,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
:param obj: 更新规则参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
@@ -83,7 +94,17 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
:param pks: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, delete, insert
|
||||
from sqlalchemy import Select, and_, delete, insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
@@ -13,6 +13,7 @@ from backend.app.admin.schema.data_scope import (
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
@@ -26,7 +27,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None:
|
||||
"""
|
||||
@@ -36,7 +37,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param name: 范围名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def get_join(self, db: AsyncSession, pk: int) -> Any:
|
||||
"""
|
||||
@@ -49,9 +50,14 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
result = await self.select_models(
|
||||
db,
|
||||
id=pk,
|
||||
deleted=0,
|
||||
join_conditions=[
|
||||
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == self.model.id),
|
||||
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=DataRule,
|
||||
join_on=and_(DataRule.id == data_scope_rule.c.data_rule_id, DataRule.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -64,7 +70,17 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[DataScope]:
|
||||
"""
|
||||
通过 ID 列表批量获取数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=pks, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
@@ -74,7 +90,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param status: 范围状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -102,7 +118,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param obj: 更新数据范围参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
@staticmethod
|
||||
async def update_rules(db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
@@ -135,7 +151,17 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param pks: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement
|
||||
from sqlalchemy import ColumnElement, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import Dept, User
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDept(CRUDPlus[Dept]):
|
||||
@@ -21,7 +22,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, id=dept_id, del_flag=False)
|
||||
return await self.select_model_by_column(db, id=dept_id, deleted=0)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
|
||||
"""
|
||||
@@ -31,7 +32,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param name: 部门名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name, del_flag=False)
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
@@ -53,7 +54,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param status: 部门状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'del_flag': False}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -85,7 +86,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param obj: 更新部门参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, dept_id, obj)
|
||||
return await self.update_model_by_column(db, obj, id=dept_id, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, dept_id: int) -> int:
|
||||
"""
|
||||
@@ -95,7 +96,16 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag')
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=dept_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_join(self, db: AsyncSession, dept_id: int) -> Any | None:
|
||||
"""
|
||||
@@ -108,7 +118,14 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
result = await self.select_model(
|
||||
db,
|
||||
dept_id,
|
||||
join_conditions=[JoinConfig(model=User, join_on=User.dept_id == self.model.id, fill_result=True)],
|
||||
deleted=0,
|
||||
join_conditions=[
|
||||
JoinConfig(
|
||||
model=User,
|
||||
join_on=and_(User.dept_id == self.model.id, User.deleted == 0),
|
||||
fill_result=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
return select_join_serialize(result, relationships=['Dept-o2m-User'])
|
||||
|
||||
@@ -120,7 +137,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, parent_id=dept_id, del_flag=False)
|
||||
return await self.select_models(db, parent_id=dept_id, deleted=0)
|
||||
|
||||
|
||||
dept_dao: CRUDDept = CRUDDept(Dept)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import Menu, role_menu
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDMenu(CRUDPlus[Menu]):
|
||||
@@ -19,7 +20,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, menu_id)
|
||||
return await self.select_model(db, menu_id, deleted=0)
|
||||
|
||||
async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
|
||||
"""
|
||||
@@ -29,7 +30,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param title: 菜单标题
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, title=title, type__ne=2)
|
||||
return await self.select_model_by_column(db, title=title, type__ne=2, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
|
||||
"""
|
||||
@@ -40,7 +41,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param status: 菜单状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if title is not None:
|
||||
filters['title__like'] = f'%{title}%'
|
||||
@@ -57,13 +58,23 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
filters = {'type__in': [0, 1, 3, 4]}
|
||||
filters = {'type__in': [0, 1, 3, 4], 'deleted': 0}
|
||||
|
||||
if menu_ids:
|
||||
filters['id__in'] = menu_ids
|
||||
|
||||
return await self.select_models_order(db, 'sort', 'asc', **filters)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, menu_ids: list[int]) -> Sequence[Menu]:
|
||||
"""
|
||||
通过 ID 列表批量获取菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=menu_ids, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
|
||||
"""
|
||||
创建菜单
|
||||
@@ -83,7 +94,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param obj: 更新菜单参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, menu_id, obj)
|
||||
return await self.update_model_by_column(db, obj, id=menu_id, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, menu_id: int) -> int:
|
||||
"""
|
||||
@@ -96,7 +107,16 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
role_menu_stmt = delete(role_menu).where(role_menu.c.menu_id == menu_id)
|
||||
await db.execute(role_menu_stmt)
|
||||
|
||||
return await self.delete_model(db, menu_id)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=menu_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_children(self, db: AsyncSession, menu_id: int) -> Sequence[Menu | None]:
|
||||
"""
|
||||
@@ -106,7 +126,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, parent_id=menu_id)
|
||||
return await self.select_models(db, parent_id=menu_id, deleted=0)
|
||||
|
||||
|
||||
menu_dao: CRUDMenu = CRUDMenu(Menu)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, delete, insert, select
|
||||
from sqlalchemy import Select, and_, delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
@@ -14,7 +14,19 @@ from backend.app.admin.schema.role import (
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
try:
|
||||
from backend.plugin.tenant.utils import get_tenant_dict as inject_tenant_dict
|
||||
except ImportError:
|
||||
raise ImportError('租户插件方法导入失败,请联系系统管理员')
|
||||
else:
|
||||
|
||||
def inject_tenant_dict(obj: dict[str, Any]) -> dict[str, Any]:
|
||||
return obj
|
||||
|
||||
|
||||
class CRUDRole(CRUDPlus[Role]):
|
||||
@@ -28,7 +40,7 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, role_id)
|
||||
return await self.select_model(db, role_id, deleted=0)
|
||||
|
||||
@staticmethod
|
||||
async def get_menus(db: AsyncSession, role_id: int) -> Sequence[Menu] | None:
|
||||
@@ -39,7 +51,11 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
menu_stmt = select(Menu).join(role_menu, Menu.id == role_menu.c.menu_id).where(role_menu.c.role_id == role_id)
|
||||
menu_stmt = (
|
||||
select(Menu)
|
||||
.join(role_menu, Menu.id == role_menu.c.menu_id)
|
||||
.where(role_menu.c.role_id == role_id, Menu.deleted == 0)
|
||||
)
|
||||
result = await db.execute(menu_stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -54,11 +70,20 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
result = await self.select_models(
|
||||
db,
|
||||
id=role_id,
|
||||
deleted=0,
|
||||
join_conditions=[
|
||||
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == self.model.id),
|
||||
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=Menu,
|
||||
join_on=and_(Menu.id == role_menu.c.menu_id, Menu.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == self.model.id),
|
||||
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=DataScope,
|
||||
join_on=and_(DataScope.id == role_data_scope.c.data_scope_id, DataScope.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -71,7 +96,17 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, role_ids: list[int]) -> Sequence[Role]:
|
||||
"""
|
||||
通过 ID 列表批量获取角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=role_ids, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
@@ -82,7 +117,7 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:return:
|
||||
"""
|
||||
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -99,7 +134,7 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param name: 角色名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
|
||||
"""
|
||||
@@ -120,7 +155,7 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param obj: 更新角色参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, role_id, obj)
|
||||
return await self.update_model_by_column(db, obj, id=role_id, deleted=0)
|
||||
|
||||
@staticmethod
|
||||
async def update_menus(db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
@@ -136,9 +171,11 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
await db.execute(role_menu_stmt)
|
||||
|
||||
if menu_ids.menus:
|
||||
role_menu_data = [
|
||||
CreateRoleMenuParam(role_id=role_id, menu_id=menu_id).model_dump() for menu_id in menu_ids.menus
|
||||
]
|
||||
role_menu_data = []
|
||||
for menu_id in menu_ids.menus:
|
||||
menu_dict = CreateRoleMenuParam(role_id=role_id, menu_id=menu_id).model_dump()
|
||||
role_menu_data.append(inject_tenant_dict(menu_dict))
|
||||
|
||||
role_menu_stmt = insert(role_menu)
|
||||
await db.execute(role_menu_stmt, role_menu_data)
|
||||
|
||||
@@ -158,10 +195,11 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
await db.execute(role_scope_stmt)
|
||||
|
||||
if scope_ids.scopes:
|
||||
role_scope_data = [
|
||||
CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump()
|
||||
for scope_id in scope_ids.scopes
|
||||
]
|
||||
role_scope_data = []
|
||||
for scope_id in scope_ids.scopes:
|
||||
scope_dict = CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump()
|
||||
role_scope_data.append(inject_tenant_dict(scope_dict))
|
||||
|
||||
role_scope_stmt = insert(role_data_scope)
|
||||
await db.execute(role_scope_stmt, role_scope_data)
|
||||
|
||||
@@ -175,7 +213,17 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_ids)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=role_ids,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
role_dao: CRUDRole = CRUDRole(Role)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
|
||||
from sqlalchemy import Select, delete, insert, select
|
||||
from sqlalchemy import Select, and_, delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
@@ -27,10 +28,21 @@ from backend.app.admin.schema.user import (
|
||||
from backend.app.admin.utils.password_security import get_hash_password
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.plugin.core import check_plugin_installed
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
try:
|
||||
from backend.plugin.tenant.utils import get_tenant_dict as inject_tenant_dict
|
||||
except ImportError:
|
||||
raise ImportError('租户插件方法导入失败,请联系系统管理员')
|
||||
else:
|
||||
|
||||
def inject_tenant_dict(obj: dict[str, Any]) -> dict[str, Any]:
|
||||
return obj
|
||||
|
||||
|
||||
class CRUDUser(CRUDPlus[User]):
|
||||
"""用户数据库操作类"""
|
||||
@@ -43,7 +55,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, user_id)
|
||||
return await self.select_model(db, user_id, deleted=0)
|
||||
|
||||
async def get_by_username(self, db: AsyncSession, username: str) -> User | None:
|
||||
"""
|
||||
@@ -53,7 +65,17 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, username=username)
|
||||
return await self.select_model_by_column(db, username=username, deleted=0)
|
||||
|
||||
async def get_all_by_usernames(self, db: AsyncSession, usernames: list[str]) -> Sequence[User]:
|
||||
"""
|
||||
通过用户名列表批量获取用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param usernames: 用户名列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, username__in=usernames, deleted=0)
|
||||
|
||||
async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
|
||||
"""
|
||||
@@ -63,7 +85,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, nickname=nickname)
|
||||
return await self.select_model_by_column(db, nickname=nickname, deleted=0)
|
||||
|
||||
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
||||
"""
|
||||
@@ -73,7 +95,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param email: 电子邮箱
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, email=email)
|
||||
return await self.select_model_by_column(db, email=email, deleted=0)
|
||||
|
||||
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
@@ -85,7 +107,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if dept:
|
||||
filters['dept_id'] = dept
|
||||
@@ -100,9 +122,17 @@ class CRUDUser(CRUDPlus[User]):
|
||||
'id',
|
||||
'desc',
|
||||
join_conditions=[
|
||||
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=Dept,
|
||||
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
|
||||
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=Role,
|
||||
join_on=and_(Role.id == user_role.c.role_id, Role.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
@@ -120,16 +150,21 @@ class CRUDUser(CRUDPlus[User]):
|
||||
|
||||
dict_obj = obj.model_dump(exclude={'roles'})
|
||||
dict_obj.update({'salt': salt})
|
||||
|
||||
new_user = self.model(**dict_obj)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
if obj.roles:
|
||||
role_stmt = select(Role).where(Role.id.in_(obj.roles))
|
||||
role_stmt = select(Role).where(Role.id.in_(obj.roles), Role.deleted == 0)
|
||||
result = await db.execute(role_stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
user_role_data = [AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump() for role in roles]
|
||||
user_role_data = []
|
||||
for role in roles:
|
||||
role_dict = AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump()
|
||||
user_role_data.append(inject_tenant_dict(role_dict))
|
||||
|
||||
user_role_stmt = insert(user_role)
|
||||
await db.execute(user_role_stmt, user_role_data)
|
||||
|
||||
@@ -143,17 +178,19 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
dict_obj = obj.model_dump()
|
||||
dict_obj.update({'is_staff': True, 'salt': None})
|
||||
|
||||
new_user = self.model(**dict_obj)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
role_stmt = select(Role).where(Role.status == StatusType.enable)
|
||||
role_stmt = select(Role).where(Role.status == StatusType.enable, Role.deleted == 0)
|
||||
result = await db.execute(role_stmt)
|
||||
role = result.scalars().first() # 默认绑定第一个角色
|
||||
if role is None:
|
||||
raise errors.NotFoundError(msg='未找到可用角色,请联系系统管理员')
|
||||
|
||||
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
|
||||
user_role_data = inject_tenant_dict(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
|
||||
user_role_stmt = insert(user_role).values(user_role_data)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
|
||||
@@ -168,17 +205,21 @@ class CRUDUser(CRUDPlus[User]):
|
||||
role_ids = obj.roles
|
||||
del obj.roles
|
||||
|
||||
count = await self.update_model(db, user_id, obj)
|
||||
count = await self.update_model_by_column(db, obj, id=user_id, deleted=0)
|
||||
|
||||
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
if role_ids:
|
||||
role_stmt = select(Role).where(Role.id.in_(role_ids))
|
||||
role_stmt = select(Role).where(Role.id.in_(role_ids), Role.deleted == 0)
|
||||
result = await db.execute(role_stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
user_role_data = [AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump() for role in roles]
|
||||
user_role_data = []
|
||||
for role in roles:
|
||||
role_dict = AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump()
|
||||
user_role_data.append(inject_tenant_dict(role_dict))
|
||||
|
||||
user_role_stmt = insert(user_role)
|
||||
await db.execute(user_role_stmt, user_role_data)
|
||||
|
||||
@@ -192,7 +233,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
|
||||
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username, deleted=0)
|
||||
|
||||
async def update_password_changed_time(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
@@ -202,7 +243,9 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'last_password_changed_time': timezone.now()})
|
||||
return await self.update_model_by_column(
|
||||
db, {'last_password_changed_time': timezone.now()}, id=user_id, deleted=0
|
||||
)
|
||||
|
||||
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
@@ -213,7 +256,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'nickname': nickname})
|
||||
return await self.update_model_by_column(db, {'nickname': nickname}, id=user_id, deleted=0)
|
||||
|
||||
async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
|
||||
"""
|
||||
@@ -224,7 +267,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param avatar: 头像地址
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'avatar': avatar})
|
||||
return await self.update_model_by_column(db, {'avatar': avatar}, id=user_id, deleted=0)
|
||||
|
||||
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
|
||||
"""
|
||||
@@ -235,7 +278,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param email: 邮箱
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'email': email})
|
||||
return await self.update_model_by_column(db, {'email': email}, id=user_id, deleted=0)
|
||||
|
||||
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
|
||||
"""
|
||||
@@ -248,7 +291,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
new_pwd = get_hash_password(password, salt)
|
||||
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}, flush=True)
|
||||
return await self.update_model_by_column(db, {'password': new_pwd, 'salt': salt}, flush=True, id=pk, deleted=0)
|
||||
|
||||
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
|
||||
"""
|
||||
@@ -259,7 +302,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param is_super: 是否超级管理员
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_superuser': is_super})
|
||||
return await self.update_model_by_column(db, {'is_superuser': is_super}, id=user_id, deleted=0)
|
||||
|
||||
async def set_staff(self, db: AsyncSession, user_id: int, *, is_staff: bool) -> int:
|
||||
"""
|
||||
@@ -270,7 +313,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param is_staff: 是否可登录后台
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_staff': is_staff})
|
||||
return await self.update_model_by_column(db, {'is_staff': is_staff}, id=user_id, deleted=0)
|
||||
|
||||
async def set_status(self, db: AsyncSession, user_id: int, status: int) -> int:
|
||||
"""
|
||||
@@ -281,7 +324,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'status': status})
|
||||
return await self.update_model_by_column(db, {'status': status}, id=user_id, deleted=0)
|
||||
|
||||
async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login: bool) -> int:
|
||||
"""
|
||||
@@ -292,7 +335,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param multi_login: 是否允许多端登录
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
|
||||
return await self.update_model_by_column(db, {'is_multi_login': multi_login}, id=user_id, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
@@ -313,7 +356,16 @@ class CRUDUser(CRUDPlus[User]):
|
||||
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
return await self.delete_model(db, user_id)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=user_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_join(
|
||||
self,
|
||||
@@ -330,7 +382,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if user_id:
|
||||
filters['id'] = user_id
|
||||
@@ -340,15 +392,35 @@ class CRUDUser(CRUDPlus[User]):
|
||||
result = await self.select_models(
|
||||
db,
|
||||
join_conditions=[
|
||||
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=Dept,
|
||||
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
|
||||
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=Role,
|
||||
join_on=and_(Role.id == user_role.c.role_id, Role.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == Role.id),
|
||||
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=Menu,
|
||||
join_on=and_(Menu.id == role_menu.c.menu_id, Menu.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == Role.id),
|
||||
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=DataScope,
|
||||
join_on=and_(DataScope.id == role_data_scope.c.data_scope_id, DataScope.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == DataScope.id),
|
||||
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
|
||||
JoinConfig(
|
||||
model=DataRule,
|
||||
join_on=and_(DataRule.id == data_scope_rule.c.data_rule_id, DataRule.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
|
||||
@@ -9,9 +9,13 @@ class DataRule(Base):
|
||||
"""数据规则表"""
|
||||
|
||||
__tablename__ = 'sys_data_rule'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_data_rule_name_deleted'),
|
||||
{'comment': '数据规则表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(512), unique=True, comment='名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(512), comment='名称')
|
||||
model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
|
||||
column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
|
||||
operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)')
|
||||
|
||||
@@ -9,7 +9,11 @@ class DataScope(Base):
|
||||
"""数据范围表"""
|
||||
|
||||
__tablename__ = 'sys_data_scope'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_data_scope_name_deleted'),
|
||||
{'comment': '数据范围表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
|
||||
|
||||
@@ -2,14 +2,26 @@ import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, id_key
|
||||
from backend.common.model import Base, TenantMixin, id_key
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
class Dept(Base):
|
||||
class Dept(Base, TenantMixin):
|
||||
"""部门表"""
|
||||
|
||||
__tablename__ = 'sys_dept'
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'tenant_id', 'deleted', name='uk_sys_dept_name_tenant_deleted'),
|
||||
{'comment': '部门表'},
|
||||
)
|
||||
else:
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_dept_name_deleted'),
|
||||
{'comment': '部门表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='部门名称')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
@@ -17,7 +29,6 @@ class Dept(Base):
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='邮箱')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
|
||||
del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)')
|
||||
|
||||
# 父级部门
|
||||
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
|
||||
|
||||
@@ -4,11 +4,11 @@ import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
|
||||
from backend.common.model import DataClassBase, TenantMixin, TimeZone, UniversalText, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class LoginLog(DataClassBase):
|
||||
class LoginLog(DataClassBase, TenantMixin):
|
||||
"""登录日志表"""
|
||||
|
||||
__tablename__ = 'sys_login_log'
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from backend.common.model import MappedBase
|
||||
from backend.core.conf import settings
|
||||
|
||||
# 租户列定义(根据配置决定是否添加)
|
||||
_tenant_columns = (
|
||||
(lambda: [sa.Column('tenant_id', sa.BigInteger, nullable=False, index=True, comment='租户ID')])
|
||||
if settings.TENANT_ENABLED
|
||||
else list
|
||||
)
|
||||
|
||||
# 用户角色表
|
||||
user_role = sa.Table(
|
||||
@@ -9,6 +17,7 @@ user_role = sa.Table(
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||
*_tenant_columns(),
|
||||
)
|
||||
|
||||
# 角色菜单表
|
||||
@@ -18,6 +27,7 @@ role_menu = sa.Table(
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||
sa.Column('menu_id', sa.BigInteger, primary_key=True, comment='菜单ID'),
|
||||
*_tenant_columns(),
|
||||
)
|
||||
|
||||
# 角色数据范围表
|
||||
@@ -27,6 +37,7 @@ role_data_scope = sa.Table(
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
|
||||
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||
*_tenant_columns(),
|
||||
)
|
||||
|
||||
# 数据范围规则表
|
||||
|
||||
@@ -4,11 +4,11 @@ import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
|
||||
from backend.common.model import DataClassBase, TenantMixin, TimeZone, UniversalText, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class OperaLog(DataClassBase):
|
||||
class OperaLog(DataClassBase, TenantMixin):
|
||||
"""操作日志表"""
|
||||
|
||||
__tablename__ = 'sys_opera_log'
|
||||
|
||||
@@ -2,16 +2,28 @@ import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, UniversalText, id_key
|
||||
from backend.common.model import Base, TenantMixin, UniversalText, id_key
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
class Role(Base):
|
||||
class Role(Base, TenantMixin):
|
||||
"""角色表"""
|
||||
|
||||
__tablename__ = 'sys_role'
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'tenant_id', 'deleted', name='uk_sys_role_name_tenant_deleted'),
|
||||
{'comment': '角色表'},
|
||||
)
|
||||
else:
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_role_name_deleted'),
|
||||
{'comment': '角色表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='角色名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(32), comment='角色名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
|
||||
is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
|
||||
@@ -4,23 +4,37 @@ import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, TimeZone, id_key
|
||||
from backend.common.model import Base, TenantMixin, TimeZone, id_key
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import uuid4_str
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class User(Base):
|
||||
class User(Base, TenantMixin):
|
||||
"""用户表"""
|
||||
|
||||
__tablename__ = 'sys_user'
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('username', 'tenant_id', 'deleted', name='uk_sys_user_username_tenant_deleted'),
|
||||
sa.UniqueConstraint('email', 'tenant_id', 'deleted', name='uk_sys_user_email_tenant_deleted'),
|
||||
{'comment': '用户表'},
|
||||
)
|
||||
else:
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('username', 'deleted', name='uk_sys_user_username_deleted'),
|
||||
sa.UniqueConstraint('email', 'deleted', name='uk_sys_user_email_deleted'),
|
||||
{'comment': '用户表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
|
||||
username: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='用户名')
|
||||
username: Mapped[str] = mapped_column(sa.String(64), index=True, comment='用户名')
|
||||
nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
|
||||
password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
|
||||
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(255), comment='加密盐')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, unique=True, index=True, comment='邮箱')
|
||||
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(256), comment='加密盐')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, index=True, comment='邮箱')
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
|
||||
avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
|
||||
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
|
||||
|
||||
@@ -4,11 +4,11 @@ import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
||||
from backend.common.model import DataClassBase, TenantMixin, TimeZone, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class UserPasswordHistory(DataClassBase):
|
||||
class UserPasswordHistory(DataClassBase, TenantMixin):
|
||||
"""用户密码历史记录表"""
|
||||
|
||||
__tablename__ = 'sys_user_password_history'
|
||||
|
||||
@@ -32,9 +32,10 @@ class GetDeptDetail(DeptSchemaBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='部门 ID')
|
||||
del_flag: bool = Field(description='是否删除')
|
||||
deleted: int = Field(description='是否已删除(0:否;id:是)')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
deleted_time: datetime | None = Field(None, description='删除时间')
|
||||
|
||||
|
||||
class GetDeptTree(GetDeptDetail):
|
||||
|
||||
@@ -8,6 +8,7 @@ from backend.app.admin.schema.dept import GetDeptDetail
|
||||
from backend.app.admin.schema.role import GetRoleWithRelationDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
class AuthSchemaBase(SchemaBase):
|
||||
@@ -20,6 +21,9 @@ class AuthSchemaBase(SchemaBase):
|
||||
class AuthLoginParam(AuthSchemaBase):
|
||||
"""用户登录参数"""
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
tenant_id: int = Field(description='租户 ID')
|
||||
|
||||
uuid: str | None = Field(None, description='验证码 UUID')
|
||||
captcha: str | None = Field(None, description='验证码')
|
||||
|
||||
@@ -80,6 +84,9 @@ class GetUserInfoDetail(UserInfoSchemaBase):
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
tenant_id: int = Field(description='租户 ID')
|
||||
|
||||
dept_id: int | None = Field(None, description='部门 ID')
|
||||
id: int = Field(description='用户 ID')
|
||||
uuid: str = Field(description='用户 UUID')
|
||||
|
||||
@@ -18,6 +18,7 @@ from backend.common.i18n import t
|
||||
from backend.common.log import log
|
||||
from backend.common.response.response_code import CustomErrorCode
|
||||
from backend.common.security.jwt import (
|
||||
check_tenant_status,
|
||||
create_access_token,
|
||||
create_new_token,
|
||||
create_refresh_token,
|
||||
@@ -74,13 +75,14 @@ class AuthService:
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
ctx.tenant_id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
swagger=True,
|
||||
)
|
||||
return access_token_data.access_token, user
|
||||
|
||||
async def login(
|
||||
async def login( # noqa: C901
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -110,11 +112,21 @@ class AuthService:
|
||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
if obj.tenant_id is None:
|
||||
raise errors.RequestError(msg='租户 ID 不能为空')
|
||||
ctx.tenant_id = obj.tenant_id
|
||||
await check_tenant_status(db, ctx.tenant_id)
|
||||
else:
|
||||
# 登录前先写入当前租户,供后续登录请求流程使用
|
||||
ctx.tenant_id = settings.TENANT_DEFAULT_ID
|
||||
|
||||
user, days_remaining = await self.user_verify(db, obj.username, obj.password)
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
await db.refresh(user)
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
ctx.tenant_id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
@@ -128,6 +140,7 @@ class AuthService:
|
||||
refresh_token_data = await create_refresh_token(
|
||||
access_token_data.session_uuid,
|
||||
user.id,
|
||||
ctx.tenant_id,
|
||||
multi_login=user.is_multi_login,
|
||||
)
|
||||
response.set_cookie(
|
||||
@@ -213,21 +226,24 @@ class AuthService:
|
||||
raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
|
||||
|
||||
token_payload = jwt_decode(refresh_token)
|
||||
ctx.tenant_id = token_payload.tenant_id
|
||||
user = await user_dao.get(db, token_payload.user_id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系系统管理员')
|
||||
|
||||
await check_tenant_status(db, ctx.tenant_id)
|
||||
token_keys = await redis_client.get_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
||||
if not user.is_multi_login and [
|
||||
key
|
||||
for key in await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*')
|
||||
if not key.endswith(f':{token_payload.session_uuid}')
|
||||
key for key in token_keys if not key.endswith(f':{token_payload.session_uuid}')
|
||||
]:
|
||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||
new_token = await create_new_token(
|
||||
refresh_token,
|
||||
token_payload.session_uuid,
|
||||
user.id,
|
||||
ctx.tenant_id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
|
||||
@@ -121,9 +121,9 @@ class DataScopeService:
|
||||
data_scope = await data_scope_dao.get(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
for rule_id in rule_ids.rules:
|
||||
rule = await data_rule_dao.get(db, rule_id)
|
||||
if not rule:
|
||||
if rule_ids.rules:
|
||||
rules = await data_rule_dao.get_all_by_ids(db, list(set(rule_ids.rules)))
|
||||
if {rule.id for rule in rules} != set(rule_ids.rules):
|
||||
raise errors.NotFoundError(msg='数据规则不存在')
|
||||
count = await data_scope_dao.update_rules(db, pk, rule_ids)
|
||||
await user_cache_manager.clear_by_data_scope_id(db, [pk])
|
||||
|
||||
@@ -48,22 +48,22 @@ class LoginLogService:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
obj = CreateLoginLogParam(
|
||||
user_uuid=user_uuid,
|
||||
username=username,
|
||||
status=status,
|
||||
ip=ctx.ip,
|
||||
country=ctx.country,
|
||||
region=ctx.region,
|
||||
city=ctx.city,
|
||||
user_agent=ctx.user_agent,
|
||||
browser=ctx.browser,
|
||||
os=ctx.os,
|
||||
device=ctx.device,
|
||||
msg=msg,
|
||||
login_time=login_time,
|
||||
)
|
||||
# 为后台任务创建独立数据库会话
|
||||
data = {
|
||||
'user_uuid': user_uuid,
|
||||
'username': username,
|
||||
'status': status,
|
||||
'ip': ctx.ip,
|
||||
'country': ctx.country,
|
||||
'region': ctx.region,
|
||||
'city': ctx.city,
|
||||
'user_agent': ctx.user_agent,
|
||||
'browser': ctx.browser,
|
||||
'os': ctx.os,
|
||||
'device': ctx.device,
|
||||
'msg': msg,
|
||||
'login_time': login_time,
|
||||
}
|
||||
obj = CreateLoginLogParam(**data)
|
||||
async with async_db_session.begin() as db:
|
||||
await login_log_dao.create(db, obj)
|
||||
except Exception as e:
|
||||
|
||||
@@ -27,12 +27,13 @@ class PluginService:
|
||||
"""获取所有插件"""
|
||||
|
||||
changed_key = f'{settings.PLUGIN_REDIS_PREFIX}:changed'
|
||||
keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*') if key != changed_key]
|
||||
keys = [key for key in await redis_client.get_by_prefix(settings.PLUGIN_REDIS_PREFIX) if key != changed_key]
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for info in await redis_client.mget(*keys):
|
||||
plugin_infos = await redis_client.mget(*keys)
|
||||
for info in plugin_infos:
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
|
||||
@@ -145,9 +145,9 @@ class RoleService:
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
for menu_id in menu_ids.menus:
|
||||
menu = await menu_dao.get(db, menu_id)
|
||||
if not menu:
|
||||
if menu_ids.menus:
|
||||
menus = await menu_dao.get_all_by_ids(db, list(set(menu_ids.menus)))
|
||||
if {menu.id for menu in menus} != set(menu_ids.menus):
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
count = await role_dao.update_menus(db, pk, menu_ids)
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
@@ -167,9 +167,9 @@ class RoleService:
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
for scope_id in scope_ids.scopes:
|
||||
scope = await data_scope_dao.get(db, scope_id)
|
||||
if not scope:
|
||||
if scope_ids.scopes:
|
||||
scopes = await data_scope_dao.get_all_by_ids(db, list(set(scope_ids.scopes)))
|
||||
if {scope.id for scope in scopes} != set(scope_ids.scopes):
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
count = await role_dao.update_scopes(db, pk, scope_ids)
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
|
||||
@@ -57,18 +57,18 @@ class UserPasswordHistoryService:
|
||||
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||
failure_count = int(failure_count) if failure_count else 0
|
||||
failure_count += 1
|
||||
await redis_client.setex(
|
||||
await redis_client.set(
|
||||
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
|
||||
settings.USER_LOCK_SECONDS,
|
||||
str(failure_count),
|
||||
ex=settings.USER_LOCK_SECONDS,
|
||||
)
|
||||
|
||||
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
||||
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
||||
await redis_client.setex(
|
||||
await redis_client.set(
|
||||
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
|
||||
settings.USER_LOCK_SECONDS,
|
||||
timezone.to_str(locked_until),
|
||||
ex=settings.USER_LOCK_SECONDS,
|
||||
)
|
||||
raise errors.AuthorizationError(msg='登录失败次数过多,账号已被锁定')
|
||||
|
||||
|
||||
@@ -90,12 +90,15 @@ class UserService:
|
||||
"""
|
||||
if await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
if obj.email and await user_dao.check_email(db, obj.email):
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
if not obj.password:
|
||||
raise errors.RequestError(msg='密码不允许为空')
|
||||
if not await dept_dao.get(db, obj.dept_id):
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
for role_id in obj.roles:
|
||||
if not await role_dao.get(db, role_id):
|
||||
if obj.roles:
|
||||
roles = await role_dao.get_all_by_ids(db, list(set(obj.roles)))
|
||||
if {role.id for role in roles} != set(obj.roles):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
obj.nickname = obj.nickname or obj.username
|
||||
await user_dao.add(db, obj)
|
||||
@@ -115,17 +118,22 @@ class UserService:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
if obj.email and obj.email != user.email:
|
||||
email_user = await user_dao.check_email(db, obj.email)
|
||||
if email_user:
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
if obj.dept_id and obj.dept_id != user.dept_id and not await dept_dao.get(db, dept_id=obj.dept_id):
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
for role_id in obj.roles:
|
||||
if not await role_dao.get(db, role_id):
|
||||
if obj.roles:
|
||||
roles = await role_dao.get_all_by_ids(db, list(set(obj.roles)))
|
||||
if {role.id for role in roles} != set(obj.roles):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
count = await user_dao.update(db, user.id, obj)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_permission(*, db: AsyncSession, request: Request, pk: int, type: UserPermissionType) -> int: # noqa: C901
|
||||
async def update_permission(*, db: AsyncSession, request: Request, pk: int, type: UserPermissionType) -> int: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
更新用户权限
|
||||
|
||||
@@ -170,15 +178,15 @@ class UserService:
|
||||
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
||||
if not new_multi_login:
|
||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||
await redis_client.delete_prefix(
|
||||
await redis_client.delete_by_prefix(
|
||||
key_prefix,
|
||||
exclude=f'{key_prefix}:{token_payload.session_uuid}',
|
||||
exclude_keys=f'{key_prefix}:{token_payload.session_uuid}',
|
||||
)
|
||||
else:
|
||||
# 系统管理员修改他人时,他人 token 全部失效
|
||||
if not new_multi_login:
|
||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||
await redis_client.delete_prefix(key_prefix)
|
||||
await redis_client.delete_by_prefix(key_prefix)
|
||||
case _:
|
||||
raise errors.RequestError(msg='权限类型不存在')
|
||||
|
||||
@@ -205,14 +213,9 @@ class UserService:
|
||||
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
||||
await password_security_service.save_password_history(db, history_obj)
|
||||
await user_dao.update_password_changed_time(db, user.id)
|
||||
|
||||
key_prefix = [
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
|
||||
]
|
||||
for prefix in key_prefix:
|
||||
await redis_client.delete_prefix(prefix)
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -259,6 +262,9 @@ class UserService:
|
||||
raise errors.RequestError(msg='验证码已失效,请重新获取')
|
||||
if captcha != captcha_code:
|
||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||
email_user = await user_dao.check_email(db, email)
|
||||
if email_user and email_user.id != user_id:
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
|
||||
count = await user_dao.update_email(db, user_id, email)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
@@ -288,14 +294,9 @@ class UserService:
|
||||
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
||||
await password_security_service.save_password_history(db, history_obj)
|
||||
await user_dao.update_password_changed_time(db, user.id)
|
||||
|
||||
key_prefix = [
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}',
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}',
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
||||
]
|
||||
for prefix in key_prefix:
|
||||
await redis_client.delete_prefix(prefix)
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -311,13 +312,9 @@ class UserService:
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
count = await user_dao.delete(db, user.id)
|
||||
key_prefix = [
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
|
||||
]
|
||||
for key in key_prefix:
|
||||
await redis_client.delete_prefix(key)
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
|
||||
|
||||
from .actions import * # noqa: F403
|
||||
from .actions import * # ruff:ignore[undefined-local-with-import-star]
|
||||
|
||||
@@ -5,7 +5,7 @@ from backend.common.socketio.server import sio
|
||||
|
||||
|
||||
@sio.event
|
||||
async def task_worker_status(sid, data) -> None: # noqa: ANN001
|
||||
async def task_worker_status(sid, data) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""任务 Worker 状态事件"""
|
||||
worker = await run_in_threadpool(celery_app.control.ping)
|
||||
await sio.emit('task_worker_status', worker, sid)
|
||||
|
||||
@@ -7,17 +7,27 @@ import celery_aio_pool
|
||||
from celery.signals import worker_process_init
|
||||
from opentelemetry.instrumentation.celery import CeleryInstrumentor
|
||||
|
||||
from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE
|
||||
from backend.app.task.tasks.beat import get_local_beat_schedule
|
||||
from backend.common.enums import DataBaseType
|
||||
from backend.common.observability.otel import init_resource, init_tracer
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
|
||||
_celery_otel_initialized = False
|
||||
|
||||
|
||||
@worker_process_init.connect(weak=False)
|
||||
def init_celery_tracing(*args, **kwargs) -> None:
|
||||
"""初始化 Celery 追踪"""
|
||||
if settings.GRAFANA_METRICS_ENABLE:
|
||||
CeleryInstrumentor().instrument()
|
||||
global _celery_otel_initialized
|
||||
|
||||
if not settings.GRAFANA_METRICS_ENABLE or _celery_otel_initialized:
|
||||
return
|
||||
|
||||
resource = init_resource(settings.GRAFANA_CELERY_OTEL_SERVICE_NAME)
|
||||
init_tracer(resource)
|
||||
CeleryInstrumentor().instrument()
|
||||
_celery_otel_initialized = True
|
||||
|
||||
|
||||
def find_task_packages() -> list[str]:
|
||||
@@ -57,7 +67,7 @@ def init_celery() -> celery.Celery:
|
||||
database_engine_options={'echo': settings.DATABASE_ECHO},
|
||||
# result_expires=0,
|
||||
# beat_sync_every=1,
|
||||
beat_schedule=LOCAL_BEAT_SCHEDULE,
|
||||
beat_schedule=get_local_beat_schedule(),
|
||||
beat_scheduler='backend.app.task.utils.schedulers:DatabaseScheduler',
|
||||
task_cls='backend.app.task.tasks.base:TaskBase',
|
||||
task_track_started=True,
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.task.model import TaskScheduler
|
||||
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
|
||||
@@ -20,7 +21,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
|
||||
:param pk: 任务调度 ID
|
||||
:return:
|
||||
"""
|
||||
return await task_scheduler_dao.select_model(db, pk)
|
||||
return await task_scheduler_dao.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[TaskScheduler]:
|
||||
"""
|
||||
@@ -29,7 +30,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, type: int | None) -> Select:
|
||||
"""
|
||||
@@ -39,7 +40,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
|
||||
:param type: 任务调度类型
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -56,7 +57,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
|
||||
:param name: 任务调度名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateTaskSchedulerParam) -> None:
|
||||
"""
|
||||
@@ -106,10 +107,19 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
|
||||
:param pk: 任务调度 ID
|
||||
:return:
|
||||
"""
|
||||
task_scheduler = await self.get(db, pk)
|
||||
await db.delete(task_scheduler)
|
||||
TaskScheduler.no_changes = False
|
||||
return 1
|
||||
count = await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=pk,
|
||||
deleted=0,
|
||||
)
|
||||
if count:
|
||||
TaskScheduler.no_changes = False
|
||||
return count
|
||||
|
||||
|
||||
task_scheduler_dao: CRUDTaskScheduler = CRUDTaskScheduler(TaskScheduler)
|
||||
|
||||
@@ -22,7 +22,7 @@ class DatabaseBackend(BaseBackend):
|
||||
task_cls = Task
|
||||
taskset_cls = TaskSet
|
||||
|
||||
def __init__(self, dburi=None, engine_options=None, url=None, **kwargs) -> None: # noqa: ANN001
|
||||
def __init__(self, dburi=None, engine_options=None, url=None, **kwargs) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
# The `url` argument was added later and is used by
|
||||
# the app to set backend by url (celery.app.backends.by_url)
|
||||
super().__init__(expires_type=maybe_timedelta, url=url, **kwargs)
|
||||
@@ -52,14 +52,14 @@ class DatabaseBackend(BaseBackend):
|
||||
self._create_tables()
|
||||
|
||||
@property
|
||||
def extended_result(self): # noqa: ANN201
|
||||
def extended_result(self): # ruff:ignore[missing-return-type-undocumented-public-function]
|
||||
return self.app.conf.find_value_for_key('extended', 'result')
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
"""Create the task and taskset tables."""
|
||||
self.result_session()
|
||||
|
||||
def result_session(self, session_manager=None) -> Session: # noqa: ANN001
|
||||
def result_session(self, session_manager=None) -> Session: # ruff:ignore[missing-type-function-argument]
|
||||
if session_manager is None:
|
||||
session_manager = self.session_manager
|
||||
return session_manager.session_factory(
|
||||
@@ -69,7 +69,7 @@ class DatabaseBackend(BaseBackend):
|
||||
)
|
||||
|
||||
@retry
|
||||
def _store_result(self, task_id, result, state, traceback=None, request=None, **kwargs) -> None: # noqa: ANN001
|
||||
def _store_result(self, task_id, result, state, traceback=None, request=None, **kwargs) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""Store return value and state of an executed task."""
|
||||
session = self.result_session()
|
||||
with session_cleanup(session):
|
||||
@@ -84,7 +84,7 @@ class DatabaseBackend(BaseBackend):
|
||||
self._update_result(task, result, state, traceback=traceback, request=request)
|
||||
session.commit()
|
||||
|
||||
def _update_result(self, task, result, state, traceback=None, request=None) -> None: # noqa: ANN001
|
||||
def _update_result(self, task, result, state, traceback=None, request=None) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
meta = self._get_result_meta(
|
||||
result=result,
|
||||
state=state,
|
||||
@@ -106,7 +106,7 @@ class DatabaseBackend(BaseBackend):
|
||||
setattr(task, column, value)
|
||||
|
||||
@retry
|
||||
def _get_task_meta_for(self, task_id: str): # noqa: ANN202
|
||||
def _get_task_meta_for(self, task_id: str): # ruff:ignore[missing-return-type-private-function]
|
||||
"""Get task meta-data for a task by id."""
|
||||
session = self.result_session()
|
||||
with session_cleanup(session):
|
||||
@@ -124,7 +124,7 @@ class DatabaseBackend(BaseBackend):
|
||||
return self.meta_from_decoded(data)
|
||||
|
||||
@retry
|
||||
def _save_group(self, group_id: str, result: PickleType): # noqa: ANN202
|
||||
def _save_group(self, group_id: str, result: PickleType): # ruff:ignore[missing-return-type-private-function]
|
||||
"""Store the result of an executed group."""
|
||||
session = self.result_session()
|
||||
with session_cleanup(session):
|
||||
@@ -170,7 +170,7 @@ class DatabaseBackend(BaseBackend):
|
||||
session.query(self.taskset_cls).filter(self.taskset_cls.date_done < (now - expires)).delete()
|
||||
session.commit()
|
||||
|
||||
def __reduce__(self, args=(), kwargs=None): # noqa: ANN001, ANN204
|
||||
def __reduce__(self, args=(), kwargs=None): # ruff:ignore[missing-type-function-argument, missing-return-type-special-method]
|
||||
kwargs = kwargs or {}
|
||||
kwargs.update({'dburi': self.url, 'expires': self.expires, 'engine_options': self.engine_options})
|
||||
return super().__reduce__(args, kwargs)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from backend.app.task.model.result import TaskExtended as TaskResult # noqa: F401
|
||||
from backend.app.task.model.result import TaskExtended as TaskResult # ruff:ignore[unused-import]
|
||||
from backend.app.task.model.scheduler import TaskScheduler as TaskScheduler
|
||||
|
||||
@@ -45,7 +45,7 @@ class Task(MappedBase):
|
||||
return f'<Task {self.task_id} state: {self.status}>'
|
||||
|
||||
@classmethod
|
||||
def configure(cls, schema=None, name=None) -> None: # noqa: ANN001
|
||||
def configure(cls, schema=None, name=None) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
cls.__table__.schema = schema
|
||||
cls.id.default.schema = schema
|
||||
cls.__table__.name = name or cls.__tablename__
|
||||
@@ -88,7 +88,7 @@ class TaskSet(MappedBase):
|
||||
result = sa.Column(PickleType, nullable=True)
|
||||
date_done = sa.Column(TimeZone, default=timezone.now, nullable=True)
|
||||
|
||||
def __init__(self, taskset_id, result) -> None: # noqa: ANN001
|
||||
def __init__(self, taskset_id, result) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
self.taskset_id = taskset_id
|
||||
self.result = result
|
||||
|
||||
@@ -103,7 +103,7 @@ class TaskSet(MappedBase):
|
||||
return f'<TaskSet: {self.taskset_id}>'
|
||||
|
||||
@classmethod
|
||||
def configure(cls, schema=None, name=None) -> None: # noqa: ANN001
|
||||
def configure(cls, schema=None, name=None) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
cls.__table__.schema = schema
|
||||
cls.id.default.schema = schema
|
||||
cls.__table__.name = name or cls.__tablename__
|
||||
|
||||
@@ -18,9 +18,13 @@ class TaskScheduler(Base):
|
||||
"""任务调度表"""
|
||||
|
||||
__tablename__ = 'task_scheduler'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_task_scheduler_name_deleted'),
|
||||
{'comment': '任务调度表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='任务名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='任务名称')
|
||||
task: Mapped[str] = mapped_column(sa.String(256), comment='要运行的 Celery 任务')
|
||||
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的位置参数')
|
||||
kwargs: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的关键字参数')
|
||||
@@ -43,12 +47,12 @@ class TaskScheduler(Base):
|
||||
no_changes: bool = False
|
||||
|
||||
@staticmethod
|
||||
def before_insert_or_update(mapper, connection, target) -> None: # noqa: ANN001
|
||||
def before_insert_or_update(mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
if target.expire_seconds is not None and target.expire_time:
|
||||
raise errors.ConflictError(msg='expires 和 expire_seconds 只能设置一个')
|
||||
|
||||
@classmethod
|
||||
def changed(cls, mapper, connection, target) -> None: # noqa: ANN001
|
||||
def changed(cls, mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
if not target.no_changes:
|
||||
cls.update_changed(mapper, connection, target)
|
||||
|
||||
@@ -58,7 +62,7 @@ class TaskScheduler(Base):
|
||||
await redis_client.set(f'{settings.CELERY_REDIS_PREFIX}:last_update', timezone.to_str(now))
|
||||
|
||||
@classmethod
|
||||
def update_changed(cls, mapper, connection, target) -> None: # noqa: ANN001
|
||||
def update_changed(cls, mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
asyncio.create_task(cls.update_changed_async())
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class TaskBase(Task):
|
||||
autoretry_for = (SQLAlchemyError,)
|
||||
max_retries = settings.CELERY_TASK_MAX_RETRIES
|
||||
|
||||
async def before_start(self, task_id: str, args, kwargs) -> None: # noqa: ANN001
|
||||
async def before_start(self, task_id: str, args, kwargs) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""
|
||||
任务开始前执行钩子
|
||||
|
||||
@@ -24,7 +24,7 @@ class TaskBase(Task):
|
||||
"""
|
||||
await task_notification(msg=f'任务 {task_id} 开始执行')
|
||||
|
||||
async def on_success(self, retval: Any, task_id: str, args, kwargs) -> None: # noqa: ANN001
|
||||
async def on_success(self, retval: Any, task_id: str, args, kwargs) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""
|
||||
任务成功后执行钩子
|
||||
|
||||
@@ -34,7 +34,7 @@ class TaskBase(Task):
|
||||
"""
|
||||
await task_notification(msg=f'任务 {task_id} 执行成功')
|
||||
|
||||
def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None: # noqa: ANN001
|
||||
def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""
|
||||
任务失败后执行钩子
|
||||
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
from celery.schedules import schedule
|
||||
|
||||
from backend.app.task.utils.tzcrontab import TzAwareCrontab
|
||||
|
||||
# 参考:https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html
|
||||
LOCAL_BEAT_SCHEDULE = {
|
||||
'测试同步任务': {
|
||||
'task': 'task_demo',
|
||||
'schedule': schedule(30),
|
||||
},
|
||||
'测试异步任务': {
|
||||
'task': 'task_demo_async',
|
||||
'schedule': TzAwareCrontab('1'),
|
||||
},
|
||||
'测试传参任务': {
|
||||
'task': 'task_demo_params',
|
||||
'schedule': TzAwareCrontab('1'),
|
||||
'args': ['你好,'],
|
||||
'kwargs': {'world': '世界'},
|
||||
},
|
||||
'清理操作日志': {
|
||||
'task': 'backend.app.task.tasks.db_log.tasks.delete_db_opera_log',
|
||||
'schedule': TzAwareCrontab('0', '0', day_of_week='6'),
|
||||
},
|
||||
'清理登录日志': {
|
||||
'task': 'backend.app.task.tasks.db_log.tasks.delete_db_login_log',
|
||||
'schedule': TzAwareCrontab('0', '0', day_of_month='15'),
|
||||
},
|
||||
}
|
||||
|
||||
def get_local_beat_schedule() -> dict[str, dict[str, Any]]:
|
||||
"""获取本地 Celery beat 任务配置"""
|
||||
# 参考:https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html
|
||||
return {
|
||||
'测试同步任务': {
|
||||
'task': 'task_demo',
|
||||
'schedule': schedule(30),
|
||||
},
|
||||
'测试异步任务': {
|
||||
'task': 'task_demo_async',
|
||||
'schedule': TzAwareCrontab('1'),
|
||||
},
|
||||
'测试传参任务': {
|
||||
'task': 'task_demo_params',
|
||||
'schedule': TzAwareCrontab('1'),
|
||||
'args': ['你好,'],
|
||||
'kwargs': {'world': '世界'},
|
||||
},
|
||||
'清理操作日志': {
|
||||
'task': 'backend.app.task.tasks.db_log.tasks.delete_db_opera_log',
|
||||
'schedule': TzAwareCrontab('0', '0', day_of_week='6'),
|
||||
},
|
||||
'清理登录日志': {
|
||||
'task': 'backend.app.task.tasks.db_log.tasks.delete_db_login_log',
|
||||
'schedule': TzAwareCrontab('0', '0', day_of_month='15'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from multiprocessing.util import Finalize
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from celery import current_app, schedules
|
||||
from celery.beat import ScheduleEntry, Scheduler
|
||||
@@ -31,10 +31,10 @@ if TYPE_CHECKING:
|
||||
from redis.asyncio.lock import Lock
|
||||
|
||||
# 此计划程序必须比常规的 5 分钟更频繁地唤醒,因为它需要考虑对计划的外部更改
|
||||
DEFAULT_MAX_INTERVAL = 5 # seconds
|
||||
_DEFAULT_MAX_INTERVAL: Final = 5 # seconds
|
||||
|
||||
# 计划锁时长,避免重复创建
|
||||
DEFAULT_MAX_LOCK_TIMEOUT = DEFAULT_MAX_INTERVAL * 5 # seconds
|
||||
_DEFAULT_MAX_LOCK_TIMEOUT: Final = _DEFAULT_MAX_INTERVAL * 5 # seconds
|
||||
|
||||
logger = get_logger('fba.schedulers')
|
||||
|
||||
@@ -42,7 +42,7 @@ logger = get_logger('fba.schedulers')
|
||||
class ModelEntry(ScheduleEntry):
|
||||
"""任务调度实体"""
|
||||
|
||||
def __init__(self, model: TaskScheduler, app=None) -> None: # noqa:ANN001,C901
|
||||
def __init__(self, model: TaskScheduler, app=None) -> None: # ruff:ignore[missing-type-function-argument, complex-structure]
|
||||
super().__init__(
|
||||
app=app or current_app._get_current_object(),
|
||||
name=model.name,
|
||||
@@ -97,7 +97,7 @@ class ModelEntry(ScheduleEntry):
|
||||
model.no_changes = True
|
||||
self.model.enabled = self.enabled = model.enabled = False
|
||||
async with async_db_session.begin() as db:
|
||||
stmt = select(TaskScheduler).where(TaskScheduler.id == model.id)
|
||||
stmt = select(TaskScheduler).where(TaskScheduler.id == model.id, TaskScheduler.deleted == 0)
|
||||
query = await db.execute(stmt)
|
||||
task = query.scalars().first()
|
||||
if task:
|
||||
@@ -129,7 +129,7 @@ class ModelEntry(ScheduleEntry):
|
||||
|
||||
return self.schedule.is_due(self.last_run_at)
|
||||
|
||||
def __next__(self): # noqa: ANN204
|
||||
def __next__(self): # ruff:ignore[missing-return-type-special-method]
|
||||
self.model.last_run_time = timezone.now()
|
||||
self.model.total_run_count += 1
|
||||
self.model.no_changes = True
|
||||
@@ -145,7 +145,11 @@ class ModelEntry(ScheduleEntry):
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
stmt = select(TaskScheduler).where(TaskScheduler.id == self.model.id).with_for_update()
|
||||
stmt = (
|
||||
select(TaskScheduler)
|
||||
.where(TaskScheduler.id == self.model.id, TaskScheduler.deleted == 0)
|
||||
.with_for_update()
|
||||
)
|
||||
query = await db.execute(stmt)
|
||||
task = query.scalars().first()
|
||||
if task:
|
||||
@@ -157,10 +161,10 @@ class ModelEntry(ScheduleEntry):
|
||||
logger.warning(f'任务 {self.model.name} 不存在,跳过更新')
|
||||
|
||||
@classmethod
|
||||
async def from_entry(cls, name, app=None, **entry) -> ModelEntry: # noqa: ANN001
|
||||
async def from_entry(cls, name, app=None, **entry) -> ModelEntry: # ruff:ignore[missing-type-function-argument]
|
||||
"""保存或更新本地任务调度"""
|
||||
async with async_db_session.begin() as db:
|
||||
stmt = select(TaskScheduler).where(TaskScheduler.name == name)
|
||||
stmt = select(TaskScheduler).where(TaskScheduler.name == name, TaskScheduler.deleted == 0)
|
||||
query = await db.execute(stmt)
|
||||
task = query.scalars().first()
|
||||
temp = await cls._unpack_fields(name, **entry)
|
||||
@@ -186,20 +190,20 @@ class ModelEntry(ScheduleEntry):
|
||||
'interval_every': every,
|
||||
'interval_period': PeriodType.SECONDS.value,
|
||||
}
|
||||
stmt = select(TaskScheduler).filter_by(**spec)
|
||||
stmt = select(TaskScheduler).filter_by(**spec, deleted=0)
|
||||
query = await db.execute(stmt)
|
||||
obj = query.scalars().first()
|
||||
if not obj:
|
||||
obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump())
|
||||
elif isinstance(schedule, schedules.crontab):
|
||||
crontab = f'{schedule._orig_minute} {schedule._orig_hour} {schedule._orig_day_of_month} {schedule._orig_month_of_year} {schedule._orig_day_of_week}' # noqa: E501
|
||||
crontab = f'{schedule._orig_minute} {schedule._orig_hour} {schedule._orig_day_of_month} {schedule._orig_month_of_year} {schedule._orig_day_of_week}' # ruff:ignore[line-too-long]
|
||||
crontab_verify(crontab)
|
||||
spec = {
|
||||
'name': name,
|
||||
'type': TaskSchedulerType.CRONTAB.value,
|
||||
'crontab': crontab,
|
||||
}
|
||||
stmt = select(TaskScheduler).filter_by(**spec)
|
||||
stmt = select(TaskScheduler).filter_by(**spec, deleted=0)
|
||||
query = await db.execute(stmt)
|
||||
obj = query.scalars().first()
|
||||
if not obj:
|
||||
@@ -222,10 +226,10 @@ class ModelEntry(ScheduleEntry):
|
||||
) -> dict:
|
||||
model_schedule = await cls.to_model_schedule(name, task, schedule)
|
||||
model_dict = select_as_dict(model_schedule)
|
||||
for k in ['id', 'created_time', 'updated_time']:
|
||||
for k in ['id', 'created_time', 'updated_time', 'deleted', 'deleted_time']:
|
||||
try:
|
||||
del model_dict[k]
|
||||
except KeyError: # noqa:PERF203
|
||||
except KeyError: # ruff:ignore[try-except-in-loop]
|
||||
continue
|
||||
model_dict.update(
|
||||
args=json.dumps(args, ensure_ascii=False) if args else None,
|
||||
@@ -284,7 +288,7 @@ class DatabaseScheduler(Scheduler):
|
||||
self._dirty = set()
|
||||
super().__init__(*args, **kwargs)
|
||||
self._finalize = Finalize(self, self.sync, exitpriority=5)
|
||||
self.max_interval = kwargs.get('max_interval') or self.app.conf.beat_max_loop_interval or DEFAULT_MAX_INTERVAL
|
||||
self.max_interval = kwargs.get('max_interval') or self.app.conf.beat_max_loop_interval or _DEFAULT_MAX_INTERVAL
|
||||
|
||||
def schedules_equal(self, *args, **kwargs) -> bool:
|
||||
"""重写父函数"""
|
||||
@@ -293,7 +297,7 @@ class DatabaseScheduler(Scheduler):
|
||||
return False
|
||||
return super().schedules_equal(*args, **kwargs)
|
||||
|
||||
def reserve(self, entry): # noqa: ANN001, ANN201
|
||||
def reserve(self, entry): # ruff:ignore[missing-type-function-argument, missing-return-type-undocumented-public-function]
|
||||
"""重写父函数"""
|
||||
new_entry = next(entry)
|
||||
# 需要按名称存储条目,因为条目可能会发生变化
|
||||
@@ -334,7 +338,7 @@ class DatabaseScheduler(Scheduler):
|
||||
"""重写父函数"""
|
||||
if self.lock:
|
||||
logger.debug('beat: Extending lock...')
|
||||
run_await(self.lock.extend)(DEFAULT_MAX_LOCK_TIMEOUT, replace_ttl=True)
|
||||
run_await(self.lock.extend)(_DEFAULT_MAX_LOCK_TIMEOUT, replace_ttl=True)
|
||||
|
||||
return super().tick(**kwargs)
|
||||
|
||||
@@ -383,7 +387,10 @@ class DatabaseScheduler(Scheduler):
|
||||
"""获取所有任务调度"""
|
||||
async with async_db_session() as db:
|
||||
logger.debug('DatabaseScheduler: Fetching database schedule')
|
||||
stmt = select(TaskScheduler).where(TaskScheduler.enabled == True) # noqa: E712
|
||||
stmt = select(TaskScheduler).where(
|
||||
TaskScheduler.enabled.is_(True),
|
||||
TaskScheduler.deleted == 0,
|
||||
)
|
||||
query = await db.execute(stmt)
|
||||
schedulers = query.scalars().all()
|
||||
s = {}
|
||||
@@ -421,7 +428,7 @@ class DatabaseScheduler(Scheduler):
|
||||
|
||||
|
||||
@beat_init.connect
|
||||
def acquire_distributed_beat_lock(sender=None, **kwargs) -> None: # noqa: ANN001
|
||||
def acquire_distributed_beat_lock(sender=None, **kwargs) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""
|
||||
尝试在启动时获取锁
|
||||
|
||||
@@ -435,7 +442,7 @@ def acquire_distributed_beat_lock(sender=None, **kwargs) -> None: # noqa: ANN00
|
||||
logger.debug('beat: Acquiring lock...')
|
||||
lock = redis_client.lock(
|
||||
scheduler.lock_key,
|
||||
timeout=DEFAULT_MAX_LOCK_TIMEOUT,
|
||||
timeout=_DEFAULT_MAX_LOCK_TIMEOUT,
|
||||
sleep=scheduler.max_interval,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from backend.utils.timezone import timezone
|
||||
class TzAwareCrontab(schedules.crontab):
|
||||
"""时区感知 Crontab"""
|
||||
|
||||
def __init__(self, minute='*', hour='*', day_of_week='*', day_of_month='*', month_of_year='*', app=None) -> None: # noqa: ANN001
|
||||
def __init__(self, minute='*', hour='*', day_of_week='*', day_of_month='*', month_of_year='*', app=None) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
super().__init__(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
|
||||
+110
-23
@@ -6,7 +6,7 @@ import sys
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Final, Literal
|
||||
|
||||
import anyio
|
||||
import cappa
|
||||
@@ -23,6 +23,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
from watchfiles import Change, PythonFilter
|
||||
|
||||
from backend import __version__
|
||||
from backend.common.dataclasses import PluginEntry
|
||||
from backend.common.enums import DataBaseType, PrimaryKeyType
|
||||
from backend.common.exception.errors import BaseExceptionError
|
||||
from backend.common.model import MappedBase
|
||||
@@ -41,25 +42,26 @@ from backend.database.db import (
|
||||
async_db_session,
|
||||
create_database_async_engine,
|
||||
create_database_async_session,
|
||||
create_database_url,
|
||||
get_database_url,
|
||||
)
|
||||
from backend.database.redis import RedisCli, redis_client
|
||||
from backend.plugin.core import (
|
||||
build_sql_filename,
|
||||
get_plugin_destroy_sql,
|
||||
get_plugin_sql,
|
||||
get_plugins,
|
||||
get_required_plugins,
|
||||
load_plugin_config,
|
||||
resolve_plugin_order,
|
||||
)
|
||||
from backend.plugin.installer import install_git_frontend_plugin, install_git_plugin, install_zip_plugin, zip_plugin
|
||||
from backend.plugin.installer import remove_plugin as _remove_plugin
|
||||
from backend.plugin.requirements import uninstall_requirements_async
|
||||
from backend.plugin.requirements import install_requirements_async, uninstall_requirements_async
|
||||
from backend.plugin.sql import build_sql_filename, get_plugin_destroy_sql, get_plugin_sql
|
||||
from backend.plugin.validator import validate_plugin_config
|
||||
from backend.utils.console import console
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
from backend.utils.sql_parser import parse_sql_script
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
output_help = "\n更多信息,尝试 '[cyan]--help[/]'"
|
||||
_OUTPUT_HELP: Final = "\n更多信息,尝试 '[cyan]--help[/]'"
|
||||
|
||||
|
||||
class CustomReloadFilter(PythonFilter):
|
||||
@@ -206,7 +208,7 @@ async def auto_init() -> None:
|
||||
ok = Prompt.ask('即将[red]新建/重建数据库[/red],确认继续吗?', choices=['y', 'n'], default='n')
|
||||
|
||||
if ok.lower() == 'y':
|
||||
async_init_engine = create_database_async_engine(create_database_url(with_database=False))
|
||||
async_init_engine = create_database_async_engine(get_database_url(with_database=False))
|
||||
async with async_init_engine.connect() as conn:
|
||||
await conn.execution_options(isolation_level='AUTOCOMMIT')
|
||||
if not await create_database(conn):
|
||||
@@ -215,7 +217,7 @@ async def auto_init() -> None:
|
||||
console.warning('已取消数据库操作')
|
||||
|
||||
console.print('\n[bold cyan]步骤 3/3:[/] 初始化数据库表和数据', style='bold')
|
||||
async_init_engine = create_database_async_engine(create_database_url())
|
||||
async_init_engine = create_database_async_engine(get_database_url())
|
||||
async_init_db_session = create_database_async_session(async_init_engine)
|
||||
redis_init_client = RedisCli(
|
||||
host=settings.REDIS_HOST,
|
||||
@@ -232,7 +234,7 @@ async def init(db: AsyncSession, redis: RedisCli) -> None:
|
||||
"""交互式初始化数据库表结构和数据"""
|
||||
panel_content = _build_db_config_panel_content()
|
||||
pk_details = panel_content.from_markup(
|
||||
'[link=https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html](了解详情)[/]'
|
||||
'[link=https://docs.fba.wu-clan.cc/fastapi_best_architecture_docs/backend/reference/pk.html](了解详情)[/]'
|
||||
)
|
||||
panel_content.append(pk_details)
|
||||
panel_content.append('\n\n【Redis 配置】', style='bold green')
|
||||
@@ -262,7 +264,7 @@ async def init(db: AsyncSession, redis: RedisCli) -> None:
|
||||
settings.TOKEN_REDIS_PREFIX,
|
||||
settings.TOKEN_REFRESH_REDIS_PREFIX,
|
||||
]:
|
||||
await redis.delete_prefix(prefix)
|
||||
await redis.delete_by_prefix(prefix)
|
||||
|
||||
console.note('重建数据库表')
|
||||
conn = await db.connection()
|
||||
@@ -283,7 +285,7 @@ async def init(db: AsyncSession, redis: RedisCli) -> None:
|
||||
console.warning('已取消初始化操作')
|
||||
|
||||
|
||||
def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001
|
||||
def run(host: str, port: int, reload: bool, workers: int) -> None: # ruff:ignore[boolean-type-hint-positional-argument]
|
||||
"""启动 API 服务"""
|
||||
url = f'http://{host}:{port}'
|
||||
docs_url = url + settings.FASTAPI_DOCS_URL
|
||||
@@ -314,7 +316,7 @@ def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT0
|
||||
panel_content.append(f'\n📡 OpenAPI JSON: {openapi_url}', 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://docs.fba.wu-clan.cc/fastapi_best_architecture_docs/')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba (v{__version__})', border_style='purple', padding=(1, 2)))
|
||||
granian.Granian(
|
||||
@@ -359,11 +361,11 @@ def run_celery_flower(port: int, basic_auth: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def install_plugin( # noqa: C901
|
||||
async def install_plugin( # ruff:ignore[complex-structure]
|
||||
path: str | None,
|
||||
repo_url: str | None,
|
||||
frontend: bool, # noqa: FBT001
|
||||
no_sql: bool, # noqa: FBT001
|
||||
frontend: bool, # ruff:ignore[boolean-type-hint-positional-argument]
|
||||
no_sql: bool, # ruff:ignore[boolean-type-hint-positional-argument]
|
||||
db_type: DataBaseType,
|
||||
pk_type: PrimaryKeyType,
|
||||
) -> None:
|
||||
@@ -407,7 +409,7 @@ async def install_plugin( # noqa: C901
|
||||
await conn.run_sync(MappedBase.metadata.create_all)
|
||||
|
||||
if not no_sql:
|
||||
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
|
||||
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type, tenant=settings.TENANT_ENABLED)
|
||||
if sql_file:
|
||||
console.info(f'正在执行插件 {plugin_name} 初始化 SQL 脚本:{sql_file}')
|
||||
async with async_db_session.begin() as db:
|
||||
@@ -419,7 +421,56 @@ async def install_plugin( # noqa: C901
|
||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
||||
|
||||
|
||||
async def remove_plugin(plugin: str | None, *, no_sql: bool = False) -> None: # noqa: C901
|
||||
def should_sync_plugin_deps(plugin: str | None, *, allow_empty: bool) -> bool:
|
||||
"""检查是否需要同步插件依赖"""
|
||||
plugins = get_plugins()
|
||||
if plugin is not None and plugin not in plugins:
|
||||
raise cappa.Exit(f'插件 {plugin} 不存在', code=1)
|
||||
if not plugins:
|
||||
if allow_empty:
|
||||
console.warning('当前没有已安装的插件,跳过插件依赖同步')
|
||||
return False
|
||||
raise cappa.Exit('当前没有已安装的插件', code=1)
|
||||
return True
|
||||
|
||||
|
||||
async def sync_project_deps() -> None:
|
||||
"""同步项目依赖"""
|
||||
console.note('正在同步项目依赖...')
|
||||
try:
|
||||
await run_in_threadpool(subprocess.run, ['uv', 'sync'], cwd=BASE_PATH.parent, check=True)
|
||||
except FileNotFoundError:
|
||||
raise cappa.Exit('uv 未安装,请先安装 uv', code=1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise cappa.Exit('项目依赖同步失败', code=e.returncode)
|
||||
console.tip('项目依赖同步完成')
|
||||
|
||||
|
||||
async def sync_plugin_deps(plugin: str | None = None) -> None:
|
||||
"""同步插件依赖"""
|
||||
console.note(f'正在安装插件 {plugin} 依赖...' if plugin else '正在安装所有插件依赖...')
|
||||
try:
|
||||
await install_requirements_async(plugin)
|
||||
except Exception as e:
|
||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
||||
console.tip(f'插件 {plugin} 依赖安装完成' if plugin else '所有插件依赖安装完成')
|
||||
|
||||
|
||||
async def sync_deps(plugin: str | None, *, no_project: bool = False, no_plugin: bool = False) -> None:
|
||||
"""同步项目和插件依赖"""
|
||||
if no_project and no_plugin:
|
||||
raise cappa.Exit('--no-project 和 --no-plugin 不能同时使用', code=1)
|
||||
if plugin is not None and no_plugin:
|
||||
raise cappa.Exit('--plugin 和 --no-plugin 不能同时使用', code=1)
|
||||
|
||||
should_sync_plugins = False if no_plugin else should_sync_plugin_deps(plugin, allow_empty=not no_project)
|
||||
if not no_project:
|
||||
await sync_project_deps()
|
||||
if should_sync_plugins:
|
||||
await sync_plugin_deps(plugin)
|
||||
|
||||
|
||||
async def remove_plugin(plugin: str | None, *, no_sql: bool = False) -> None: # ruff:ignore[complex-structure]
|
||||
"""卸载插件"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise cappa.Exit('插件卸载仅在开发环境可用', code=1)
|
||||
@@ -487,13 +538,25 @@ async def get_sql_scripts() -> list[str]:
|
||||
'init',
|
||||
settings.DATABASE_PK_MODE,
|
||||
suffix='test_data',
|
||||
tenant=settings.TENANT_ENABLED,
|
||||
)
|
||||
|
||||
if await anyio.Path(main_sql_file).exists():
|
||||
sql_scripts.append(str(main_sql_file))
|
||||
|
||||
plugins = []
|
||||
for plugin in get_plugins():
|
||||
plugin_sql = await get_plugin_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
|
||||
plugin_config = load_plugin_config(plugin)
|
||||
validate_plugin_config(plugin, plugin_config)
|
||||
plugins.append(PluginEntry(name=plugin, depends_on=plugin_config['plugin'].get('depends_on')))
|
||||
|
||||
for plugin in resolve_plugin_order(plugins):
|
||||
plugin_sql = await get_plugin_sql(
|
||||
plugin.name,
|
||||
settings.DATABASE_TYPE,
|
||||
settings.DATABASE_PK_MODE,
|
||||
tenant=settings.TENANT_ENABLED,
|
||||
)
|
||||
if plugin_sql:
|
||||
sql_scripts.append(plugin_sql)
|
||||
|
||||
@@ -504,8 +567,9 @@ async def execute_sql_scripts(db: AsyncSession, sql_scripts: str, *, is_init: bo
|
||||
"""解析并执行 SQL 脚本"""
|
||||
try:
|
||||
stmts = await parse_sql_script(sql_scripts)
|
||||
conn = await db.connection()
|
||||
for stmt in stmts:
|
||||
await db.execute(text(stmt))
|
||||
await conn.exec_driver_sql(stmt)
|
||||
except Exception as e:
|
||||
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
|
||||
|
||||
@@ -517,8 +581,9 @@ async def execute_destroy_sql_scripts(db: AsyncSession, sql_scripts: str) -> Non
|
||||
"""执行插件销毁 SQL 脚本"""
|
||||
try:
|
||||
stmts = await parse_sql_script(sql_scripts, is_destroy=True)
|
||||
conn = await db.connection()
|
||||
for stmt in stmts:
|
||||
await db.execute(text(stmt))
|
||||
await conn.exec_driver_sql(stmt)
|
||||
except Exception as e:
|
||||
raise cappa.Exit(f'销毁 SQL 脚本执行失败:{e}', code=1)
|
||||
|
||||
@@ -723,6 +788,26 @@ class Remove:
|
||||
await remove_plugin(self.plugin, no_sql=self.no_sql)
|
||||
|
||||
|
||||
@cappa.command(help='同步项目和插件依赖', default_long=True)
|
||||
@dataclass
|
||||
class Deps:
|
||||
plugin: Annotated[
|
||||
str | None,
|
||||
cappa.Arg(default=None, help='指定插件名称,不指定则同步所有插件依赖'),
|
||||
]
|
||||
no_project: Annotated[
|
||||
bool,
|
||||
cappa.Arg(default=False, help='跳过项目依赖同步'),
|
||||
]
|
||||
no_plugin: Annotated[
|
||||
bool,
|
||||
cappa.Arg(default=False, help='跳过插件依赖同步'),
|
||||
]
|
||||
|
||||
async def __call__(self) -> None:
|
||||
await sync_deps(self.plugin, no_project=self.no_project, no_plugin=self.no_plugin)
|
||||
|
||||
|
||||
@cappa.command(help='格式化代码')
|
||||
@dataclass
|
||||
class Format:
|
||||
@@ -926,7 +1011,9 @@ class FbaCli:
|
||||
str,
|
||||
cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'),
|
||||
]
|
||||
subcmd: cappa.Subcommands[Init | Run | Add | Remove | Format | Celery | CodeGenerator | Alembic | None] = None
|
||||
subcmd: cappa.Subcommands[Init | Run | Add | Remove | Deps | Format | Celery | CodeGenerator | Alembic | None] = (
|
||||
None
|
||||
)
|
||||
|
||||
async def __call__(self) -> None:
|
||||
if self.sql:
|
||||
@@ -935,5 +1022,5 @@ class FbaCli:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
output = cappa.Output(error_format=f'{error_format}\n{output_help}')
|
||||
output = cappa.Output(error_format=f'{error_format}\n{_OUTPUT_HELP}')
|
||||
asyncio.run(cappa.invoke_async(FbaCli, version=__version__, output=output))
|
||||
|
||||
Vendored
+37
-32
@@ -1,6 +1,7 @@
|
||||
import functools
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from inspect import isawaitable
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from msgspec import json
|
||||
@@ -16,12 +17,13 @@ from backend.utils.serializers import select_columns_serialize, select_list_seri
|
||||
|
||||
P = ParamSpec('P')
|
||||
T = TypeVar('T')
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _build_cache_key(
|
||||
name: str,
|
||||
async def _build_cache_key(
|
||||
namespace: str,
|
||||
key: str | None,
|
||||
key_builder: Callable[..., str] | None,
|
||||
key_builder: Callable[..., str | Awaitable[str]] | None,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
@@ -29,9 +31,9 @@ def _build_cache_key(
|
||||
if key:
|
||||
if '.' in key:
|
||||
param, field = key.split('.', 1)
|
||||
value = kwargs.get(param)
|
||||
if value is None:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{param}" 不存在或值为空')
|
||||
value = kwargs.get(param, _MISSING)
|
||||
if value is _MISSING:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{param}" 不存在')
|
||||
|
||||
if isinstance(value, list):
|
||||
raise errors.ServerError(msg='缓存键构建失败:不支持从列表中提取字段,请使用 key_builder 处理列表参数')
|
||||
@@ -43,16 +45,19 @@ def _build_cache_key(
|
||||
else:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,对象中不存在字段 "{field}"')
|
||||
else:
|
||||
value = kwargs.get(key)
|
||||
if value is None:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{key}" 不存在或值为空')
|
||||
value = kwargs.get(key, _MISSING)
|
||||
if value is _MISSING:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{key}" 不存在')
|
||||
|
||||
return f'{name}:{value}'
|
||||
return f'{namespace}:{value if value is not None else "none"}'
|
||||
|
||||
if key_builder:
|
||||
return f'{name}:{key_builder(*args, **kwargs)}'
|
||||
value = key_builder(*args, **kwargs)
|
||||
if isawaitable(value):
|
||||
value = await value
|
||||
return f'{namespace}:{value}'
|
||||
|
||||
return name
|
||||
return namespace
|
||||
|
||||
|
||||
def _serialize_result(result: Any) -> bytes:
|
||||
@@ -100,16 +105,16 @@ def user_key_builder() -> str:
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def cached( # noqa: C901
|
||||
name: str,
|
||||
def cached( # ruff:ignore[complex-structure]
|
||||
namespace: str,
|
||||
*,
|
||||
key: str | None = None,
|
||||
key_builder: Callable[..., str] | None = None,
|
||||
key_builder: Callable[..., str | Awaitable[str]] | None = None,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""
|
||||
缓存装饰器
|
||||
|
||||
:param name: 缓存名称(通常为缓存 Key 前缀)
|
||||
:param namespace: 缓存命名空间(通常为缓存 Key 前缀)
|
||||
:param key: 从方法参数中获取指定参数名的值作为缓存 Key,与 key_builder 互斥
|
||||
:param key_builder: 自定义 Key 生成函数,与 key 互斥
|
||||
:return:
|
||||
@@ -117,10 +122,10 @@ def cached( # noqa: C901
|
||||
if key is not None and key_builder is not None:
|
||||
raise errors.ServerError(msg='缓存 key 和 key_builder 不能同时使用')
|
||||
|
||||
def decorator(func: Callable[P, T]) -> Callable[P, T]: # noqa: C901
|
||||
def decorator(func: Callable[P, T]) -> Callable[P, T]: # ruff:ignore[complex-structure]
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
cache_key = _build_cache_key(name, key, key_builder, *args, **kwargs)
|
||||
cache_key = await _build_cache_key(namespace, key, key_builder, *args, **kwargs)
|
||||
|
||||
# L1: 本地缓存
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
@@ -154,7 +159,7 @@ def cached( # noqa: C901
|
||||
|
||||
# 回填 L2
|
||||
if settings.CACHE_REDIS_TTL:
|
||||
await redis_client.setex(cache_key, settings.CACHE_REDIS_TTL, serialized_result)
|
||||
await redis_client.set(cache_key, serialized_result, ex=settings.CACHE_REDIS_TTL)
|
||||
else:
|
||||
await redis_client.set(cache_key, serialized_result)
|
||||
except Exception as e:
|
||||
@@ -167,17 +172,17 @@ def cached( # noqa: C901
|
||||
return decorator
|
||||
|
||||
|
||||
def cache_invalidate( # noqa: C901
|
||||
name: str,
|
||||
def cache_invalidate( # ruff:ignore[complex-structure]
|
||||
namespace: str,
|
||||
*,
|
||||
key: str | None = None,
|
||||
key_builder: Callable[..., str] | None = None,
|
||||
key_builder: Callable[..., str | Awaitable[str]] | None = None,
|
||||
atomic: bool = True,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""
|
||||
缓存失效装饰器
|
||||
|
||||
:param name: 缓存名称(通常为缓存 Key 前缀)
|
||||
:param namespace: 缓存命名空间(通常为缓存 Key 前缀)
|
||||
:param key: 从方法参数中获取指定参数名的值作为缓存 Key,与 key_builder 互斥
|
||||
:param key_builder: 自定义 Key 生成函数,与 key 互斥
|
||||
:param atomic: 是否保证缓存原子性
|
||||
@@ -196,25 +201,25 @@ def cache_invalidate( # noqa: C901
|
||||
invalidate_error = None
|
||||
|
||||
try:
|
||||
invalidate_key = _build_cache_key(name, key, key_builder, *args, **kwargs)
|
||||
invalidate_key = await _build_cache_key(namespace, key, key_builder, *args, **kwargs)
|
||||
|
||||
# L1 缓存失效
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
if invalidate_key == name:
|
||||
local_cache_manager.delete_prefix(invalidate_key)
|
||||
if invalidate_key == namespace:
|
||||
local_cache_manager.delete_by_prefix(invalidate_key)
|
||||
else:
|
||||
local_cache_manager.delete(invalidate_key)
|
||||
|
||||
# 广播失效消息(通知其他节点清除本地缓存)
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
if invalidate_key == name:
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key, is_delete_prefix=True)
|
||||
if invalidate_key == namespace:
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key, delete_by_prefix=True)
|
||||
else:
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key)
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key, delete_by_prefix=False)
|
||||
|
||||
# L2 缓存失效
|
||||
if invalidate_key == name:
|
||||
await redis_client.delete_prefix(invalidate_key)
|
||||
if invalidate_key == namespace:
|
||||
await redis_client.delete_by_prefix(invalidate_key)
|
||||
else:
|
||||
await redis_client.delete(invalidate_key)
|
||||
|
||||
|
||||
Vendored
+12
-6
@@ -10,7 +10,7 @@ class LocalCacheManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.hot_cache: cachebox.TTLCache = cachebox.TTLCache(
|
||||
settings.CACHE_LOCAL_MAXSIZE, ttl=settings.CACHE_LOCAL_TTL
|
||||
settings.CACHE_LOCAL_MAXSIZE, global_ttl=settings.CACHE_LOCAL_TTL
|
||||
)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
@@ -36,17 +36,23 @@ class LocalCacheManager:
|
||||
"""清空缓存"""
|
||||
self.hot_cache.clear()
|
||||
|
||||
def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None) -> None:
|
||||
def delete_by_prefix(self, key_prefix: str, exclude_keys: str | list[str] | None = None) -> None:
|
||||
"""
|
||||
删除指定前缀的缓存
|
||||
|
||||
:param prefix: 要删除的键前缀
|
||||
:param exclude: 要排除的键或键列表
|
||||
:param key_prefix: 要删除的键前缀
|
||||
:param exclude_keys: 要排除的键或键列表
|
||||
:return:
|
||||
"""
|
||||
exclude_set = set(exclude) if isinstance(exclude, list) else {exclude} if isinstance(exclude, str) else set()
|
||||
exclude_set = (
|
||||
set(exclude_keys)
|
||||
if isinstance(exclude_keys, list)
|
||||
else {exclude_keys}
|
||||
if isinstance(exclude_keys, str)
|
||||
else set()
|
||||
)
|
||||
for key in list(self.hot_cache.keys()):
|
||||
if key.startswith(prefix) and key not in exclude_set:
|
||||
if (key == key_prefix or key.startswith(f'{key_prefix}:')) and key not in exclude_set:
|
||||
try:
|
||||
del self.hot_cache[key]
|
||||
except KeyError:
|
||||
|
||||
Vendored
+10
-10
@@ -13,22 +13,22 @@ class CachePubSubManager:
|
||||
_pubsub_task: asyncio.Task | None = None
|
||||
|
||||
@staticmethod
|
||||
async def publish_invalidation(key: str, *, is_delete_prefix: bool) -> None:
|
||||
async def publish_invalidation(cache_key: str, *, delete_by_prefix: bool) -> None:
|
||||
"""
|
||||
发布缓存失效通知
|
||||
|
||||
:param key: 缓存键
|
||||
:param is_delete_prefix: 是否删除符合前缀的所有缓存
|
||||
:param cache_key: 缓存键
|
||||
:param delete_by_prefix: 是否删除符合前缀的所有缓存
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
message = json.dumps({'key': key, 'is_delete_prefix': is_delete_prefix})
|
||||
message = json.dumps({'cache_key': cache_key, 'delete_by_prefix': delete_by_prefix})
|
||||
await redis_client.publish(settings.CACHE_PUBSUB_CHANNEL, message)
|
||||
except Exception as e:
|
||||
log.warning(f'[CachePubSub] 发布通知失败: {e}')
|
||||
|
||||
@staticmethod
|
||||
async def subscribe_and_listen() -> None: # noqa: C901
|
||||
async def subscribe_and_listen() -> None: # ruff:ignore[complex-structure]
|
||||
"""订阅并监听缓存失效通知"""
|
||||
reconnect_attempts = 0
|
||||
|
||||
@@ -38,7 +38,7 @@ class CachePubSubManager:
|
||||
|
||||
try:
|
||||
# 使用独立连接
|
||||
pubsub_client = RedisCli()
|
||||
pubsub_client = RedisCli(socket_timeout=None)
|
||||
pubsub = pubsub_client.pubsub()
|
||||
await pubsub.subscribe(settings.CACHE_PUBSUB_CHANNEL)
|
||||
|
||||
@@ -49,11 +49,11 @@ class CachePubSubManager:
|
||||
if message['type'] == 'message':
|
||||
try:
|
||||
data = json.loads(message['data'])
|
||||
key = data['key']
|
||||
if not data['is_delete_prefix']:
|
||||
local_cache_manager.delete(key)
|
||||
cache_key = data['cache_key']
|
||||
if not data['delete_by_prefix']:
|
||||
local_cache_manager.delete(cache_key)
|
||||
else:
|
||||
local_cache_manager.delete_prefix(key)
|
||||
local_cache_manager.delete_by_prefix(cache_key)
|
||||
except json.JSONDecodeError as e:
|
||||
log.warning(f'[CachePubSub] 消息格式错误 {e}')
|
||||
except Exception as e:
|
||||
|
||||
@@ -22,6 +22,8 @@ class TypedContextProtocol(Protocol):
|
||||
language: str
|
||||
|
||||
user_id: int | None
|
||||
is_superuser: bool
|
||||
tenant_id: int
|
||||
|
||||
|
||||
class TypedContext(TypedContextProtocol, _Context):
|
||||
|
||||
@@ -58,6 +58,7 @@ class NewToken:
|
||||
@dataclasses.dataclass
|
||||
class TokenPayload:
|
||||
user_id: int
|
||||
tenant_id: int
|
||||
session_uuid: str
|
||||
expire_time: datetime
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ class PluginLevelType(StrEnum):
|
||||
"""插件级别类型"""
|
||||
|
||||
app = 'app'
|
||||
capability = 'capability'
|
||||
extend = 'extend'
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ async def _validation_exception_handler(exc: RequestValidationError | Validation
|
||||
return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_422, content=content)
|
||||
|
||||
|
||||
def register_exception(app: FastAPI) -> None: # noqa: C901
|
||||
def register_exception(app: FastAPI) -> None: # ruff:ignore[complex-structure]
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""
|
||||
@@ -190,6 +190,7 @@ def register_exception(app: FastAPI) -> None: # noqa: C901
|
||||
else:
|
||||
res = response_base.fail(res=CustomResponseCode.HTTP_500)
|
||||
content = res.model_dump()
|
||||
ctx.__request_unknown_exception__ = content
|
||||
content.update(trace_id=get_request_trace_id())
|
||||
return MsgSpecJSONResponse(
|
||||
status_code=StandardResponseCode.HTTP_500,
|
||||
@@ -223,6 +224,10 @@ def register_exception(app: FastAPI) -> None: # noqa: C901
|
||||
else:
|
||||
res = response_base.fail(res=CustomResponseCode.HTTP_500)
|
||||
content = res.model_dump()
|
||||
if isinstance(exc, BaseExceptionError):
|
||||
ctx.__request_custom_exception__ = content
|
||||
else:
|
||||
ctx.__request_unknown_exception__ = content
|
||||
content.update(trace_id=get_request_trace_id())
|
||||
response = MsgSpecJSONResponse(
|
||||
status_code=exc.code if isinstance(exc, BaseExceptionError) else StandardResponseCode.HTTP_500,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
||||
from typing import Any, overload
|
||||
from typing import Any, TypeAlias, overload
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from backend.common.enums import LifespanStage
|
||||
|
||||
LifespanFunc = Callable[[FastAPI], AbstractAsyncContextManager[dict[str, Any] | None]]
|
||||
LifespanFunc: TypeAlias = Callable[[FastAPI], AbstractAsyncContextManager[dict[str, Any] | None]]
|
||||
|
||||
|
||||
class LifespanManager:
|
||||
@@ -58,7 +58,7 @@ class LifespanManager:
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def combined_lifespan(app: FastAPI): # noqa: ANN202
|
||||
async def combined_lifespan(app: FastAPI): # ruff:ignore[missing-return-type-private-function]
|
||||
state: dict[str, Any] = {}
|
||||
async with AsyncExitStack() as exit_stack:
|
||||
for stage in LifespanStage:
|
||||
|
||||
+39
-5
@@ -46,10 +46,10 @@ class UniversalText(TypeDecorator[str]):
|
||||
impl = LONGTEXT if DataBaseType.mysql == settings.DATABASE_TYPE else Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: str | None, dialect) -> str | None: # noqa: ANN001
|
||||
def process_bind_param(self, value: str | None, dialect) -> str | None: # ruff:ignore[missing-type-function-argument]
|
||||
return value
|
||||
|
||||
def process_result_value(self, value: str | None, dialect) -> str | None: # noqa: ANN001
|
||||
def process_result_value(self, value: str | None, dialect) -> str | None: # ruff:ignore[missing-type-function-argument]
|
||||
return value
|
||||
|
||||
|
||||
@@ -63,13 +63,13 @@ class TimeZone(TypeDecorator[datetime]):
|
||||
def python_type(self) -> type[datetime]:
|
||||
return datetime
|
||||
|
||||
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None: # noqa: ANN001
|
||||
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None: # ruff:ignore[missing-type-function-argument]
|
||||
if value is not None and value.utcoffset() != timezone.now().utcoffset():
|
||||
# TODO 处理夏令时偏移
|
||||
value = timezone.from_datetime(value)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value: datetime | None, dialect) -> datetime | None: # noqa: ANN001
|
||||
def process_result_value(self, value: datetime | None, dialect) -> datetime | None: # ruff:ignore[missing-type-function-argument]
|
||||
if value is not None and value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.tz_info)
|
||||
return value
|
||||
@@ -83,6 +83,20 @@ class UserMixin(MappedAsDataclass):
|
||||
updated_by: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者')
|
||||
|
||||
|
||||
class TenantMixin(MappedAsDataclass):
|
||||
"""租户 Mixin 数据类"""
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
init=False,
|
||||
nullable=False,
|
||||
index=True,
|
||||
sort_order=997,
|
||||
comment='租户ID',
|
||||
)
|
||||
|
||||
|
||||
class DateTimeMixin(MappedAsDataclass):
|
||||
"""日期时间 Mixin 数据类"""
|
||||
|
||||
@@ -102,6 +116,26 @@ class DateTimeMixin(MappedAsDataclass):
|
||||
)
|
||||
|
||||
|
||||
class LogicalDeleteMixin(MappedAsDataclass):
|
||||
"""逻辑删除 Mixin 数据类"""
|
||||
|
||||
deleted: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
init=False,
|
||||
default=0,
|
||||
server_default='0',
|
||||
sort_order=999,
|
||||
comment='是否已删除(0:否;id:是)',
|
||||
)
|
||||
deleted_time: Mapped[datetime | None] = mapped_column(
|
||||
TimeZone,
|
||||
init=False,
|
||||
default=None,
|
||||
sort_order=999,
|
||||
comment='删除时间',
|
||||
)
|
||||
|
||||
|
||||
class MappedBase(AsyncAttrs, DeclarativeBase):
|
||||
"""
|
||||
声明式基类, 作为所有基类或数据模型类的父类而存在
|
||||
@@ -134,7 +168,7 @@ class DataClassBase(MappedAsDataclass, MappedBase):
|
||||
__abstract__ = True
|
||||
|
||||
|
||||
class Base(DataClassBase, DateTimeMixin):
|
||||
class Base(DataClassBase, DateTimeMixin, LogicalDeleteMixin):
|
||||
"""
|
||||
声明性数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构
|
||||
"""
|
||||
|
||||
@@ -20,9 +20,8 @@ from redis.observability.config import OTelConfig
|
||||
from redis.observability.providers import get_observability_instance
|
||||
|
||||
from backend.common.log import log, request_id_filter
|
||||
from backend.common.observability.prometheus.config import PROMETHEUS_APP_NAME
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_engine
|
||||
from backend.database.db import get_database_engines
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
|
||||
@@ -103,8 +102,7 @@ def init_otel(app: FastAPI) -> None:
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
resource = init_resource(PROMETHEUS_APP_NAME)
|
||||
|
||||
resource = init_resource(settings.GRAFANA_PROMETHEUS_APP_NAME)
|
||||
init_tracer(resource)
|
||||
init_metrics(resource)
|
||||
init_logging(resource)
|
||||
@@ -115,7 +113,10 @@ def init_otel(app: FastAPI) -> None:
|
||||
|
||||
AsyncioInstrumentor().instrument()
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
LoggingInstrumentor().instrument(set_logging_format=True)
|
||||
# 禁止自动将 OTel handler 安装到 stdlib root logger,
|
||||
# 避免与上面注册的 LoggingHandler(loguru sink)重复推送。
|
||||
LoggingInstrumentor().instrument(set_logging_format=True, enable_log_auto_instrumentation=False)
|
||||
RedisInstrumentor.instrument_client(client=redis_client) # type: ignore
|
||||
SQLAlchemyInstrumentor().instrument(engine=async_engine.sync_engine)
|
||||
for engine in get_database_engines().values():
|
||||
SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine)
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Grafana 配置为静态定义,修改此值需要手动同步以下配置文件:
|
||||
# - deploy/backend/grafana/fba_datasource.yml
|
||||
# - deploy/backend/grafana/dashboards/fba_server.json
|
||||
PROMETHEUS_APP_NAME = 'fba_server'
|
||||
@@ -1,6 +1,6 @@
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
|
||||
from backend.common.observability.prometheus.config import PROMETHEUS_APP_NAME
|
||||
from backend.core.conf import settings
|
||||
|
||||
_PROMETHEUS_FASTAPI_REQUEST_IN_PROGRESS_GAUGE = Gauge(
|
||||
name='fba_request_in_progress',
|
||||
@@ -35,35 +35,41 @@ _PROMETHEUS_FASTAPI_RESPONSE_COUNTER = Counter(
|
||||
|
||||
def inc_fastapi_request_in_progress(*, method: str, path: str) -> None:
|
||||
"""增加当前正在处理的 FastAPI 请求数"""
|
||||
_PROMETHEUS_FASTAPI_REQUEST_IN_PROGRESS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, method=method, path=path).inc()
|
||||
_PROMETHEUS_FASTAPI_REQUEST_IN_PROGRESS_GAUGE.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, method=method, path=path
|
||||
).inc()
|
||||
|
||||
|
||||
def dec_fastapi_request_in_progress(*, method: str, path: str) -> None:
|
||||
"""减少当前正在处理的 FastAPI 请求数"""
|
||||
_PROMETHEUS_FASTAPI_REQUEST_IN_PROGRESS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, method=method, path=path).dec()
|
||||
_PROMETHEUS_FASTAPI_REQUEST_IN_PROGRESS_GAUGE.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, method=method, path=path
|
||||
).dec()
|
||||
|
||||
|
||||
def inc_fastapi_request(*, method: str, path: str) -> None:
|
||||
"""记录 FastAPI 请求总数"""
|
||||
_PROMETHEUS_FASTAPI_REQUEST_COUNTER.labels(app_name=PROMETHEUS_APP_NAME, method=method, path=path).inc()
|
||||
_PROMETHEUS_FASTAPI_REQUEST_COUNTER.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, method=method, path=path
|
||||
).inc()
|
||||
|
||||
|
||||
def observe_fastapi_request_cost_time(*, method: str, path: str, elapsed: float, trace_id: str) -> None:
|
||||
"""记录 FastAPI 请求耗时"""
|
||||
_PROMETHEUS_FASTAPI_REQUEST_COST_TIME_HISTOGRAM.labels(
|
||||
app_name=PROMETHEUS_APP_NAME, method=method, path=path
|
||||
).observe(amount=elapsed, exemplar={'TraceID': trace_id})
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, method=method, path=path
|
||||
).observe(amount=elapsed, exemplar={settings.GRAFANA_PROMETHEUS_EXEMPLAR_TRACE_ID_KEY: trace_id})
|
||||
|
||||
|
||||
def inc_fastapi_exception(*, method: str, path: str, exception_type: str) -> None:
|
||||
"""记录 FastAPI 异常总数"""
|
||||
_PROMETHEUS_FASTAPI_EXCEPTION_COUNTER.labels(
|
||||
app_name=PROMETHEUS_APP_NAME, method=method, path=path, exception_type=exception_type
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, method=method, path=path, exception_type=exception_type
|
||||
).inc()
|
||||
|
||||
|
||||
def inc_fastapi_response(*, method: str, path: str, status_code: int | str) -> None:
|
||||
"""记录 FastAPI 响应总数"""
|
||||
_PROMETHEUS_FASTAPI_RESPONSE_COUNTER.labels(
|
||||
app_name=PROMETHEUS_APP_NAME, method=method, path=path, status_code=status_code
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, method=method, path=path, status_code=status_code
|
||||
).inc()
|
||||
|
||||
@@ -4,7 +4,7 @@ from asyncio import Queue
|
||||
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
|
||||
from backend.common.observability.prometheus.config import PROMETHEUS_APP_NAME
|
||||
from backend.core.conf import settings
|
||||
|
||||
_PROMETHEUS_QUEUE_SIZE_GAUGE = Gauge(
|
||||
name='fba_queue_size',
|
||||
@@ -27,17 +27,21 @@ _PROMETHEUS_QUEUE_EXCEPTION_COUNTER = Counter(
|
||||
|
||||
def observe_queue_size(queue: Queue, *, queue_name: str) -> None:
|
||||
"""记录队列当前长度"""
|
||||
_PROMETHEUS_QUEUE_SIZE_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, queue_name=queue_name).set(queue.qsize())
|
||||
_PROMETHEUS_QUEUE_SIZE_GAUGE.labels(app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, queue_name=queue_name).set(
|
||||
queue.qsize()
|
||||
)
|
||||
|
||||
|
||||
def observe_batch_dequeue_cost(start_time: float, *, queue_name: str) -> None:
|
||||
"""记录批量消费耗时"""
|
||||
elapsed = round((time.perf_counter() - start_time) * 1000, 3)
|
||||
_PROMETHEUS_QUEUE_BATCH_DEQUEUE_COST_TIME_HISTOGRAM.labels(
|
||||
app_name=PROMETHEUS_APP_NAME, queue_name=queue_name
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, queue_name=queue_name
|
||||
).observe(elapsed)
|
||||
|
||||
|
||||
def inc_queue_exception(*, queue_name: str) -> None:
|
||||
"""记录队列异常"""
|
||||
_PROMETHEUS_QUEUE_EXCEPTION_COUNTER.labels(app_name=PROMETHEUS_APP_NAME, queue_name=queue_name).inc()
|
||||
_PROMETHEUS_QUEUE_EXCEPTION_COUNTER.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, queue_name=queue_name
|
||||
).inc()
|
||||
|
||||
@@ -3,27 +3,35 @@ from typing import Any
|
||||
from prometheus_client import Gauge
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
from backend.common.observability.prometheus.config import PROMETHEUS_APP_NAME
|
||||
from backend.core.conf import settings
|
||||
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE = Gauge(
|
||||
name='fba_sqlalchemy_pool_connections',
|
||||
documentation='SQLAlchemy 连接池状态',
|
||||
labelnames=['app_name', 'state'],
|
||||
labelnames=['app_name', 'source', 'state'],
|
||||
)
|
||||
|
||||
|
||||
def observe_sqlalchemy_pool_connections(*_event_args: Any, pool: QueuePool) -> None:
|
||||
def observe_sqlalchemy_pool_connections(
|
||||
*_event_args: Any,
|
||||
pool: QueuePool,
|
||||
source: str = 'default',
|
||||
) -> None:
|
||||
"""监听 SQLAlchemy 连接池状态"""
|
||||
total_size = pool.size()
|
||||
checked_out_size = pool.checkedout()
|
||||
overflow_size = pool.overflow()
|
||||
idle_size = max(total_size + overflow_size - checked_out_size, 0)
|
||||
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, state='size').set(total_size)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, state='checked_out').set(
|
||||
checked_out_size
|
||||
)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, state='idle').set(idle_size)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, state='overflow').set(
|
||||
overflow_size
|
||||
)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, source=source, state='size'
|
||||
).set(total_size)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, source=source, state='checked_out'
|
||||
).set(checked_out_size)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, source=source, state='idle'
|
||||
).set(idle_size)
|
||||
_PROMETHEUS_SQLALCHEMY_POOL_CONNECTIONS_GAUGE.labels(
|
||||
app_name=settings.GRAFANA_PROMETHEUS_APP_NAME, source=source, state='overflow'
|
||||
).set(overflow_size)
|
||||
|
||||
+42
-1
@@ -2,6 +2,8 @@ import asyncio
|
||||
import time
|
||||
|
||||
from asyncio import Queue
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from backend.common.log import log
|
||||
from backend.common.observability.prometheus.queue import (
|
||||
@@ -10,8 +12,10 @@ from backend.common.observability.prometheus.queue import (
|
||||
observe_queue_size,
|
||||
)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
async def batch_dequeue(queue: Queue, max_items: int, timeout: float, *, queue_name: str = 'default') -> list:
|
||||
|
||||
async def batch_dequeue(queue: Queue[T], max_items: int, timeout: float, *, queue_name: str = 'default') -> list[T]:
|
||||
"""
|
||||
从异步队列中获取多个项目
|
||||
|
||||
@@ -42,3 +46,40 @@ async def batch_dequeue(queue: Queue, max_items: int, timeout: float, *, queue_n
|
||||
observe_queue_size(queue, queue_name=queue_name)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
async def batch_consume(
|
||||
queue: Queue[T],
|
||||
max_items: int,
|
||||
timeout: float,
|
||||
handler: Callable[..., Awaitable[None]],
|
||||
*,
|
||||
queue_name: str = 'default',
|
||||
error_message: str = '队列批量处理失败',
|
||||
item_name: str = '数据',
|
||||
) -> None:
|
||||
"""
|
||||
持续批量消费队列
|
||||
|
||||
:param queue: 用于获取项目的 `asyncio.Queue` 队列
|
||||
:param max_items: 从队列中获取的最大项目数量
|
||||
:param timeout: 总的等待超时时间(秒)
|
||||
:param handler: 批量处理函数
|
||||
:param queue_name: 队列名称,用于 Prometheus 标签
|
||||
:param error_message: 处理失败日志消息
|
||||
:param item_name: 队列数据名称
|
||||
:return:
|
||||
"""
|
||||
while True:
|
||||
items = await batch_dequeue(queue, max_items=max_items, timeout=timeout, queue_name=queue_name)
|
||||
if not items:
|
||||
continue
|
||||
|
||||
try:
|
||||
await handler(items)
|
||||
except Exception as e:
|
||||
log.error(f'{error_message},丢失 {len(items)} 条{item_name}: {e}')
|
||||
finally:
|
||||
for _ in items:
|
||||
queue.task_done()
|
||||
observe_queue_size(queue, queue_name=queue_name)
|
||||
|
||||
+110
-26
@@ -2,10 +2,10 @@ import json
|
||||
import uuid
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
from pydantic_core import from_json
|
||||
@@ -22,9 +22,6 @@ from backend.database.db import async_db_session
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
# JWT dependency injection
|
||||
DependsJwtAuth = Depends(HTTPBearer())
|
||||
|
||||
|
||||
def jwt_encode(payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
@@ -53,7 +50,8 @@ def jwt_decode(token: str) -> TokenPayload:
|
||||
session_uuid = payload.get('session_uuid')
|
||||
user_id = payload.get('sub')
|
||||
expire = payload.get('exp')
|
||||
if not session_uuid or not user_id or not expire:
|
||||
tenant_id = payload.get('tenant_id')
|
||||
if not session_uuid or not user_id or not expire or tenant_id is None:
|
||||
raise errors.TokenError(msg='Token 无效')
|
||||
except ExpiredSignatureError:
|
||||
raise errors.TokenError(msg='Token 已过期')
|
||||
@@ -63,14 +61,22 @@ def jwt_decode(token: str) -> TokenPayload:
|
||||
user_id=int(user_id),
|
||||
session_uuid=session_uuid,
|
||||
expire_time=timezone.from_datetime(timezone.to_utc(expire)),
|
||||
tenant_id=int(tenant_id),
|
||||
)
|
||||
|
||||
|
||||
async def create_access_token(user_id: int, *, multi_login: bool, **kwargs) -> AccessToken:
|
||||
async def create_access_token(
|
||||
user_id: int,
|
||||
tenant_id: int,
|
||||
*,
|
||||
multi_login: bool,
|
||||
**kwargs,
|
||||
) -> AccessToken:
|
||||
"""
|
||||
生成加密 token
|
||||
|
||||
:param user_id: 用户 ID
|
||||
:param tenant_id: 租户 ID
|
||||
:param multi_login: 是否允许多端登录
|
||||
:param kwargs: token 额外信息
|
||||
:return:
|
||||
@@ -81,34 +87,36 @@ async def create_access_token(user_id: int, *, multi_login: bool, **kwargs) -> A
|
||||
'session_uuid': session_uuid,
|
||||
'exp': timezone.to_utc(expire).timestamp(),
|
||||
'sub': str(user_id),
|
||||
'tenant_id': tenant_id,
|
||||
})
|
||||
|
||||
if not multi_login:
|
||||
await redis_client.delete_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
|
||||
|
||||
await redis_client.setex(
|
||||
await redis_client.set(
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}',
|
||||
settings.TOKEN_EXPIRE_SECONDS,
|
||||
access_token,
|
||||
ex=settings.TOKEN_EXPIRE_SECONDS,
|
||||
)
|
||||
|
||||
# Token 附加信息单独存储
|
||||
if kwargs:
|
||||
await redis_client.setex(
|
||||
await redis_client.set(
|
||||
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}',
|
||||
settings.TOKEN_EXPIRE_SECONDS,
|
||||
json.dumps(kwargs, ensure_ascii=False),
|
||||
ex=settings.TOKEN_EXPIRE_SECONDS,
|
||||
)
|
||||
|
||||
return AccessToken(access_token=access_token, access_token_expire_time=expire, session_uuid=session_uuid)
|
||||
|
||||
|
||||
async def create_refresh_token(session_uuid: str, user_id: int, *, multi_login: bool) -> RefreshToken:
|
||||
async def create_refresh_token(session_uuid: str, user_id: int, tenant_id: int, *, multi_login: bool) -> RefreshToken:
|
||||
"""
|
||||
生成加密刷新 token,仅用于创建新的 token
|
||||
|
||||
:param session_uuid: 会话 UUID
|
||||
:param user_id: 用户 ID
|
||||
:param tenant_id: 租户 ID
|
||||
:param multi_login: 是否允许多端登录
|
||||
:return:
|
||||
"""
|
||||
@@ -117,15 +125,16 @@ async def create_refresh_token(session_uuid: str, user_id: int, *, multi_login:
|
||||
'session_uuid': session_uuid,
|
||||
'exp': timezone.to_utc(expire).timestamp(),
|
||||
'sub': str(user_id),
|
||||
'tenant_id': tenant_id,
|
||||
})
|
||||
|
||||
if not multi_login:
|
||||
await redis_client.delete_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
|
||||
|
||||
await redis_client.setex(
|
||||
await redis_client.set(
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}',
|
||||
settings.TOKEN_REFRESH_EXPIRE_SECONDS,
|
||||
refresh_token,
|
||||
ex=settings.TOKEN_REFRESH_EXPIRE_SECONDS,
|
||||
)
|
||||
return RefreshToken(refresh_token=refresh_token, refresh_token_expire_time=expire)
|
||||
|
||||
@@ -134,6 +143,7 @@ async def create_new_token(
|
||||
refresh_token: str,
|
||||
session_uuid: str,
|
||||
user_id: int,
|
||||
tenant_id: int,
|
||||
*,
|
||||
multi_login: bool,
|
||||
**kwargs,
|
||||
@@ -144,6 +154,7 @@ async def create_new_token(
|
||||
:param refresh_token: 刷新 token
|
||||
:param session_uuid: 会话 UUID
|
||||
:param user_id: 用户 ID
|
||||
:param tenant_id: 租户 ID
|
||||
:param multi_login: 是否允许多端登录
|
||||
:param kwargs: token 附加信息
|
||||
:return:
|
||||
@@ -155,8 +166,18 @@ async def create_new_token(
|
||||
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
|
||||
new_access_token = await create_access_token(user_id, multi_login=multi_login, **kwargs)
|
||||
new_refresh_token = await create_refresh_token(new_access_token.session_uuid, user_id, multi_login=multi_login)
|
||||
new_access_token = await create_access_token(
|
||||
user_id,
|
||||
tenant_id,
|
||||
multi_login=multi_login,
|
||||
**kwargs,
|
||||
)
|
||||
new_refresh_token = await create_refresh_token(
|
||||
new_access_token.session_uuid,
|
||||
user_id,
|
||||
tenant_id,
|
||||
multi_login=multi_login,
|
||||
)
|
||||
return NewToken(
|
||||
new_access_token=new_access_token.access_token,
|
||||
new_access_token_expire_time=new_access_token.access_token_expire_time,
|
||||
@@ -192,6 +213,41 @@ def get_token(request: Request) -> str:
|
||||
return token
|
||||
|
||||
|
||||
async def check_tenant_status(db: AsyncSession, tenant_id: int) -> None:
|
||||
"""
|
||||
校验租户状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param tenant_id: 租户 ID
|
||||
:return:
|
||||
"""
|
||||
if not settings.TENANT_ENABLED:
|
||||
return
|
||||
|
||||
if tenant_id == settings.TENANT_DEFAULT_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.plugin.tenant.crud.crud_package import tenant_package_dao
|
||||
from backend.plugin.tenant.crud.crud_tenant import tenant_dao
|
||||
except ImportError:
|
||||
raise errors.ServerError(msg='租户插件方法导入失败,请联系系统管理员')
|
||||
|
||||
tenant = await tenant_dao.get(db, tenant_id)
|
||||
if not tenant:
|
||||
raise errors.NotFoundError(msg='租户不存在,请联系系统管理员')
|
||||
|
||||
if tenant.status == 0:
|
||||
raise errors.AuthorizationError(msg='租户已被禁用,请联系系统管理员')
|
||||
|
||||
if tenant.expire_time and tenant.expire_time < timezone.now():
|
||||
raise errors.AuthorizationError(msg='租户已过期,请联系系统管理员')
|
||||
|
||||
package = await tenant_package_dao.get(db, tenant.package_id)
|
||||
if package and package.status == 0:
|
||||
raise errors.AuthorizationError(msg='租户套餐已被禁用,请联系系统管理员')
|
||||
|
||||
|
||||
async def get_current_user(db: AsyncSession, pk: int) -> User:
|
||||
"""
|
||||
获取当前用户
|
||||
@@ -207,11 +263,14 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
|
||||
raise errors.TokenError(msg='Token 无效')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定,请联系系统管理员')
|
||||
if user.dept and user.dept_id:
|
||||
if not user.dept.status:
|
||||
raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员')
|
||||
if user.dept.del_flag:
|
||||
raise errors.AuthorizationError(msg='用户所属部门已被删除,请联系系统管理员')
|
||||
|
||||
if settings.TENANT_ENABLED:
|
||||
await check_tenant_status(db, ctx.tenant_id)
|
||||
|
||||
if user.dept_id and not user.dept:
|
||||
raise errors.AuthorizationError(msg='用户所属部门不存在或已被删除,请联系系统管理员')
|
||||
if user.dept and not user.dept.status:
|
||||
raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员')
|
||||
if user.roles:
|
||||
role_status = [role.status for role in user.roles]
|
||||
if all(status == 0 for status in role_status):
|
||||
@@ -231,10 +290,10 @@ async def get_jwt_user(user_id: int) -> GetUserInfoWithRelationDetail:
|
||||
async with async_db_session() as db:
|
||||
current_user = await get_current_user(db, user_id)
|
||||
user = GetUserInfoWithRelationDetail.model_validate(current_user)
|
||||
await redis_client.setex(
|
||||
await redis_client.set(
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
||||
settings.TOKEN_EXPIRE_SECONDS,
|
||||
user.model_dump_json(),
|
||||
ex=settings.TOKEN_EXPIRE_SECONDS,
|
||||
)
|
||||
else:
|
||||
# TODO: 在恰当的时机,应替换为使用 model_validate_json
|
||||
@@ -252,6 +311,7 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
|
||||
"""
|
||||
token_payload = jwt_decode(token)
|
||||
ctx.user_id = token_payload.user_id
|
||||
ctx.tenant_id = token_payload.tenant_id
|
||||
redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{ctx.user_id}:{token_payload.session_uuid}')
|
||||
if not redis_token:
|
||||
raise errors.TokenError(msg='Token 已过期')
|
||||
@@ -259,7 +319,31 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
|
||||
if token != redis_token:
|
||||
raise errors.TokenError(msg='Token 已失效')
|
||||
|
||||
return await get_jwt_user(ctx.user_id)
|
||||
user = await get_jwt_user(ctx.user_id)
|
||||
ctx.is_superuser = user.is_superuser
|
||||
return user
|
||||
|
||||
|
||||
def jwt_authentication_verify(
|
||||
request: Request,
|
||||
token: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())],
|
||||
) -> str:
|
||||
"""
|
||||
JWT 认证依赖
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param token: HTTP Bearer 认证信息
|
||||
:return:
|
||||
"""
|
||||
if isinstance(request.user, UnauthenticatedUser):
|
||||
if token_exception := ctx.get('__request_jwt_authentication_exception__'):
|
||||
raise token_exception
|
||||
raise errors.TokenError
|
||||
return token.credentials
|
||||
|
||||
|
||||
# JWT 依赖注入
|
||||
DependsJwtAuth = Depends(jwt_authentication_verify)
|
||||
|
||||
|
||||
def superuser_verify(request: Request, _token: str = DependsJwtAuth) -> bool:
|
||||
|
||||
@@ -54,7 +54,7 @@ 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
|
||||
def filter_data_permission( # ruff:ignore[complex-structure]
|
||||
request: Request, *models: type[Model] | AliasedClass | Alias | Table
|
||||
) -> ColumnElement[bool]:
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,7 @@ from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
|
||||
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
RBAC 权限校验(鉴权顺序很重要,谨慎修改)
|
||||
|
||||
@@ -34,6 +34,8 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
|
||||
|
||||
# 检测用户角色
|
||||
user_roles = request.user.roles
|
||||
if not user_roles:
|
||||
raise errors.AuthorizationError(msg='用户未分配角色,请联系系统管理员')
|
||||
enabled_roles = [role for role in user_roles if role.status == StatusType.enable]
|
||||
if not enabled_roles:
|
||||
raise errors.AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员')
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .actions import * # noqa: F403
|
||||
from .actions import * # ruff:ignore[undefined-local-with-import-star]
|
||||
|
||||
@@ -14,6 +14,10 @@ from backend.database.redis import redis_client
|
||||
sio = socketio.AsyncServer(
|
||||
client_manager=socketio.AsyncRedisManager(
|
||||
f'redis://:{urllib.parse.quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DATABASE}',
|
||||
redis_options={
|
||||
'socket_timeout': None,
|
||||
'socket_connect_timeout': settings.REDIS_TIMEOUT,
|
||||
},
|
||||
),
|
||||
async_mode='asgi',
|
||||
cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS,
|
||||
@@ -37,6 +41,11 @@ async def connect(sid, environ, auth) -> bool:
|
||||
|
||||
# 免授权直连
|
||||
if token == settings.WS_NO_AUTH_MARKER:
|
||||
if settings.ENVIRONMENT == 'prod':
|
||||
log.error('WebSocket 连接失败:生产环境禁止免授权直连')
|
||||
return False
|
||||
await redis_client.set(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}', session_uuid)
|
||||
await redis_client.sadd(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}', sid)
|
||||
await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid)
|
||||
return True
|
||||
|
||||
@@ -47,6 +56,8 @@ async def connect(sid, environ, auth) -> bool:
|
||||
log.info(f'WebSocket 连接失败:{e!s}')
|
||||
return False
|
||||
|
||||
await redis_client.set(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}', session_uuid)
|
||||
await redis_client.sadd(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}', sid)
|
||||
await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid)
|
||||
return True
|
||||
|
||||
@@ -54,4 +65,13 @@ async def connect(sid, environ, auth) -> bool:
|
||||
@sio.event
|
||||
async def disconnect(sid) -> None:
|
||||
"""Socket 断开连接事件"""
|
||||
await redis_client.spop(settings.TOKEN_ONLINE_REDIS_PREFIX)
|
||||
session_uuid = await redis_client.get(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}')
|
||||
if not session_uuid:
|
||||
return
|
||||
|
||||
session_key = f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}'
|
||||
await redis_client.delete(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}')
|
||||
await redis_client.srem(session_key, sid)
|
||||
if await redis_client.scard(session_key) == 0:
|
||||
await redis_client.delete(session_key)
|
||||
await redis_client.srem(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid)
|
||||
|
||||
+36
-2
@@ -4,7 +4,7 @@ from functools import cache
|
||||
from re import Pattern
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
||||
|
||||
from backend.core.path_conf import ENV_EXAMPLE_FILE_PATH, ENV_FILE_PATH
|
||||
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
|
||||
DATABASE_PORT: int
|
||||
DATABASE_USER: str
|
||||
DATABASE_PASSWORD: str
|
||||
DATABASE_SOURCES: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
# 数据库
|
||||
DATABASE_ECHO: bool | Literal['debug'] = False
|
||||
@@ -100,6 +101,7 @@ class Settings(BaseSettings):
|
||||
TOKEN_EXTRA_INFO_REDIS_PREFIX: str = 'fba:token_extra_info'
|
||||
TOKEN_ONLINE_REDIS_PREFIX: str = 'fba:token_online'
|
||||
TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:refresh_token'
|
||||
TOKEN_REQUEST_UNDERLYING_SECURITY: bool = True
|
||||
TOKEN_REQUEST_PATH_EXCLUDE: list[str] = [ # JWT / RBAC 路由白名单
|
||||
f'{FASTAPI_API_V1_PATH}/auth/login',
|
||||
]
|
||||
@@ -143,7 +145,8 @@ class Settings(BaseSettings):
|
||||
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
|
||||
'id',
|
||||
'sort',
|
||||
'del_flag',
|
||||
'deleted',
|
||||
'deleted_time',
|
||||
'created_time',
|
||||
'updated_time',
|
||||
]
|
||||
@@ -241,6 +244,11 @@ class Settings(BaseSettings):
|
||||
OPERA_LOG_QUEUE_MAXSIZE: int = 100000
|
||||
OPERA_LOG_QUEUE_BATCH_CONSUME_SIZE: int = 100
|
||||
OPERA_LOG_QUEUE_TIMEOUT: int = 60 # 1 分钟
|
||||
OPERA_LOG_BODY_MAX_SIZE: int = 10240 # 10 KB
|
||||
|
||||
# 租户
|
||||
TENANT_ENABLED: bool = True
|
||||
TENANT_DEFAULT_ID: int = 0
|
||||
|
||||
# Plugin 配置
|
||||
PLUGIN_REQUIRED: list[str] = ['dict']
|
||||
@@ -255,6 +263,17 @@ class Settings(BaseSettings):
|
||||
# Grafana
|
||||
GRAFANA_METRICS_ENABLE: bool = False
|
||||
GRAFANA_OTLP_GRPC_ENDPOINT: str = 'fba_alloy:4317'
|
||||
# 以下配置为静态定义,修改后需要手动同步相关 Grafana 配置:
|
||||
# - GRAFANA_PROMETHEUS_APP_NAME:deploy/backend/grafana/fba_datasource.yml
|
||||
# deploy/backend/grafana/dashboards/fba_server.json
|
||||
# - GRAFANA_CELERY_OTEL_SERVICE_NAME:deploy/backend/grafana/dashboards/fba_celery.json
|
||||
# - GRAFANA_METRICS_PATH:deploy/backend/grafana/fba_config.alloy
|
||||
# deploy/backend/grafana/dashboards/fba_server.json
|
||||
# - GRAFANA_PROMETHEUS_EXEMPLAR_TRACE_ID_KEY:deploy/backend/grafana/fba_datasource.yml
|
||||
GRAFANA_PROMETHEUS_APP_NAME: str = 'fba_server'
|
||||
GRAFANA_CELERY_OTEL_SERVICE_NAME: str = 'fba_celery_worker'
|
||||
GRAFANA_METRICS_PATH: str = '/metrics'
|
||||
GRAFANA_PROMETHEUS_EXEMPLAR_TRACE_ID_KEY: str = 'TraceID'
|
||||
|
||||
##################################################
|
||||
# [ App ] task
|
||||
@@ -311,6 +330,21 @@ class Settings(BaseSettings):
|
||||
EMAIL_CAPTCHA_REDIS_PREFIX: str
|
||||
EMAIL_CAPTCHA_EXPIRE_SECONDS: int
|
||||
|
||||
##################################################
|
||||
# [ Plugin ] ai
|
||||
##################################################
|
||||
# 动态配置
|
||||
AI_EXA_API_KEY: str = ''
|
||||
AI_TAVILY_API_KEY: str = ''
|
||||
|
||||
# 基础配置(in plugin.toml)
|
||||
AI_CODE_MODE_DYNAMIC_CATALOG: bool = False
|
||||
AI_CODE_MODE_MAX_RETRIES: int = 3
|
||||
AI_CODE_MODE_TOOLS: list[str] = []
|
||||
AI_CONTEXT_WARNING_THRESHOLD: float = 0.8
|
||||
AI_HTTP_MAX_RETRIES: int = 5
|
||||
AI_MCP_MAX_RETRIES: int = 1
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def check_env(cls, values: Any) -> Any:
|
||||
|
||||
+38
-15
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from asyncio import create_task
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -24,14 +24,15 @@ from backend.common.observability.otel import init_otel
|
||||
from backend.common.response.response_code import StandardResponseCode
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import STATIC_DIR, UPLOAD_DIR
|
||||
from backend.database.db import create_tables
|
||||
from backend.database.db import create_tables, dispose_database
|
||||
from backend.database.redis import redis_client
|
||||
from backend.middleware.access_middleware import AccessMiddleware
|
||||
from backend.middleware.i18n_middleware import I18nMiddleware
|
||||
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
|
||||
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
||||
from backend.middleware.state_middleware import StateMiddleware
|
||||
from backend.plugin.core import build_final_router, setup_plugins
|
||||
from backend.plugin.hooks import init_plugin_otel_hooks, register_plugin_hooks
|
||||
from backend.plugin.router import build_final_router
|
||||
from backend.utils.demo_mode import demo_site
|
||||
from backend.utils.openapi import ensure_unique_route_names, simplify_operation_ids
|
||||
from backend.utils.serializers import MsgSpecJSONResponse
|
||||
@@ -59,22 +60,43 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
await snowflake.init()
|
||||
|
||||
# 创建操作日志任务
|
||||
create_task(OperaLogMiddleware.consumer())
|
||||
opera_log_task = asyncio.create_task(OperaLogMiddleware.consumer())
|
||||
|
||||
# 启动缓存 Pub/Sub 监听器
|
||||
cache_pubsub_manager.start_listener()
|
||||
|
||||
yield
|
||||
# 注册租户 SQLAlchemy 监听器
|
||||
if settings.TENANT_ENABLED:
|
||||
try:
|
||||
from backend.plugin.tenant.listener import register_tenant_sqlalchemy_listeners
|
||||
except ImportError:
|
||||
raise ImportError('租户插件监听器导入失败,请联系系统管理员')
|
||||
else:
|
||||
register_tenant_sqlalchemy_listeners()
|
||||
|
||||
# 停止缓存 Pub/Sub 监听器
|
||||
await cache_pubsub_manager.stop_listener()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 停止缓存 Pub/Sub 监听器
|
||||
await cache_pubsub_manager.stop_listener()
|
||||
|
||||
# 释放 snowflake 节点
|
||||
if settings.SNOWFLAKE_ENABLED or settings.DATABASE_PK_MODE == 'snowflake':
|
||||
await snowflake.shutdown()
|
||||
# 取消操作日志任务
|
||||
if not opera_log_task.done():
|
||||
opera_log_task.cancel()
|
||||
try:
|
||||
await opera_log_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 关闭 redis 连接
|
||||
await redis_client.aclose()
|
||||
# 释放 snowflake 节点
|
||||
if settings.SNOWFLAKE_ENABLED or settings.DATABASE_PK_MODE == 'snowflake':
|
||||
await snowflake.shutdown()
|
||||
|
||||
# 关闭 redis 连接
|
||||
await redis_client.aclose()
|
||||
|
||||
# 释放数据库连接池
|
||||
await dispose_database()
|
||||
|
||||
|
||||
def register_app() -> FastAPI:
|
||||
@@ -100,8 +122,8 @@ def register_app() -> FastAPI:
|
||||
register_page(app)
|
||||
register_exception(app)
|
||||
|
||||
# 初始化插件
|
||||
setup_plugins(app)
|
||||
# 注册插件钩子
|
||||
register_plugin_hooks(app)
|
||||
|
||||
if settings.GRAFANA_METRICS_ENABLE:
|
||||
register_metrics(app)
|
||||
@@ -237,6 +259,7 @@ def register_metrics(app: FastAPI) -> None:
|
||||
:return:
|
||||
"""
|
||||
metrics_app = make_asgi_app()
|
||||
app.mount('/metrics', metrics_app)
|
||||
app.mount(settings.GRAFANA_METRICS_PATH, metrics_app)
|
||||
|
||||
init_otel(app)
|
||||
init_plugin_otel_hooks(app)
|
||||
|
||||
+80
-37
@@ -1,18 +1,19 @@
|
||||
import sys
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from functools import partial
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, TypeAlias
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import URL, event
|
||||
from sqlalchemy import URL, Engine, event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.common.enums import DataBaseType
|
||||
from backend.common.log import log
|
||||
@@ -21,7 +22,7 @@ from backend.common.observability.prometheus.sqlalchemy import observe_sqlalchem
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
def create_database_url(*, unittest: bool = False, with_database: bool = True) -> URL:
|
||||
def get_database_url(*, unittest: bool = False, with_database: bool = True) -> URL:
|
||||
"""
|
||||
创建数据库链接
|
||||
|
||||
@@ -73,30 +74,56 @@ def create_database_async_engine(url: str | URL) -> AsyncEngine:
|
||||
sys.exit()
|
||||
|
||||
|
||||
def create_database_async_session(engine: AsyncEngine) -> async_sessionmaker[AsyncSession | Any]:
|
||||
"""
|
||||
创建数据库异步会话
|
||||
class DatabaseSession(Session):
|
||||
"""数据库数据源会话"""
|
||||
|
||||
:param engine: 数据库异步引擎
|
||||
:return:
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: str = 'default',
|
||||
source_binds: Mapping[str, Engine] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
source_binds = source_binds or {}
|
||||
try:
|
||||
engine = source_binds[source]
|
||||
except KeyError as e:
|
||||
raise ValueError(f'未知数据库数据源: {source}') from e
|
||||
|
||||
kwargs['bind'] = engine
|
||||
kwargs['binds'] = {MappedBase: engine}
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
def create_database_async_session(
|
||||
async_engine: AsyncEngine,
|
||||
*,
|
||||
source_binds: Mapping[str, AsyncEngine] | None = None,
|
||||
) -> async_sessionmaker[AsyncSession | Any]:
|
||||
"""创建支持命名数据源的数据库异步会话"""
|
||||
async_binds = dict(source_binds or {})
|
||||
async_binds.setdefault('default', async_engine)
|
||||
sync_binds = {source: bind.sync_engine for source, bind in async_binds.items()}
|
||||
return async_sessionmaker(
|
||||
bind=engine,
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
sync_session_class=DatabaseSession,
|
||||
source='default',
|
||||
source_binds=sync_binds,
|
||||
autoflush=False, # 禁用自动刷新
|
||||
expire_on_commit=False, # 禁用提交时过期
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取数据库会话"""
|
||||
async with async_db_session() as session:
|
||||
"""获取默认数据源会话"""
|
||||
async with async_db_session(source='default') as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_db_transaction() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取带有事务的数据库会话"""
|
||||
async with async_db_session.begin() as session:
|
||||
"""获取默认数据源事务会话"""
|
||||
async with async_db_session(source='default').begin() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@@ -117,30 +144,46 @@ def uuid4_str() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
# SQLA 数据库链接
|
||||
SQLALCHEMY_DATABASE_URL = create_database_url()
|
||||
|
||||
# SQLA 异步引擎和会话
|
||||
async_engine = create_database_async_engine(SQLALCHEMY_DATABASE_URL)
|
||||
async_db_session = create_database_async_session(async_engine)
|
||||
async_engine = create_database_async_engine(get_database_url())
|
||||
_database_engines: dict[str, AsyncEngine] = {'default': async_engine}
|
||||
for source, url in settings.DATABASE_SOURCES.items():
|
||||
if not source or source == 'default':
|
||||
raise ValueError('DATABASE_SOURCES 数据源名称不能为空且不能为 default')
|
||||
_database_engines[source] = create_database_async_engine(url)
|
||||
|
||||
async_db_session = create_database_async_session(async_engine, source_binds=_database_engines)
|
||||
|
||||
|
||||
def get_database_engines() -> Mapping[str, AsyncEngine]:
|
||||
"""获取所有数据库引擎"""
|
||||
return _database_engines
|
||||
|
||||
|
||||
async def dispose_database() -> None:
|
||||
"""释放所有数据库连接池"""
|
||||
for engine in _database_engines.values():
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# SQLA 连接池指标监听
|
||||
event.listen(
|
||||
async_engine.sync_engine.pool,
|
||||
'connect',
|
||||
partial(observe_sqlalchemy_pool_connections, pool=async_engine.sync_engine.pool),
|
||||
)
|
||||
event.listen(
|
||||
async_engine.sync_engine.pool,
|
||||
'checkout',
|
||||
partial(observe_sqlalchemy_pool_connections, pool=async_engine.sync_engine.pool),
|
||||
)
|
||||
event.listen(
|
||||
async_engine.sync_engine.pool,
|
||||
'checkin',
|
||||
partial(observe_sqlalchemy_pool_connections, pool=async_engine.sync_engine.pool),
|
||||
)
|
||||
for source, engine in _database_engines.items():
|
||||
event.listen(
|
||||
engine.sync_engine.pool,
|
||||
'connect',
|
||||
partial(observe_sqlalchemy_pool_connections, pool=engine.sync_engine.pool, source=source),
|
||||
)
|
||||
event.listen(
|
||||
engine.sync_engine.pool,
|
||||
'checkout',
|
||||
partial(observe_sqlalchemy_pool_connections, pool=engine.sync_engine.pool, source=source),
|
||||
)
|
||||
event.listen(
|
||||
engine.sync_engine.pool,
|
||||
'checkin',
|
||||
partial(observe_sqlalchemy_pool_connections, pool=engine.sync_engine.pool, source=source),
|
||||
)
|
||||
|
||||
# Session Annotated
|
||||
CurrentSession = Annotated[AsyncSession, Depends(get_db)]
|
||||
CurrentSessionTransaction = Annotated[AsyncSession, Depends(get_db_transaction)]
|
||||
CurrentSession: TypeAlias = Annotated[AsyncSession, Depends(get_db)]
|
||||
CurrentSessionTransaction: TypeAlias = Annotated[AsyncSession, Depends(get_db_transaction)]
|
||||
|
||||
@@ -16,7 +16,7 @@ class RedisCli(Redis):
|
||||
port: int = settings.REDIS_PORT,
|
||||
password: str = settings.REDIS_PASSWORD,
|
||||
db: int = settings.REDIS_DATABASE,
|
||||
socket_timeout: int = settings.REDIS_TIMEOUT,
|
||||
socket_timeout: int | None = settings.REDIS_TIMEOUT,
|
||||
socket_connect_timeout: int = settings.REDIS_TIMEOUT,
|
||||
*,
|
||||
socket_keepalive: bool = True,
|
||||
@@ -62,19 +62,33 @@ class RedisCli(Redis):
|
||||
log.error('Redis 服务器连接异常 {}', e)
|
||||
sys.exit()
|
||||
|
||||
async def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None, batch_size: int = 1000) -> None:
|
||||
async def delete_by_prefix(
|
||||
self,
|
||||
key_prefix: str,
|
||||
exclude_keys: str | list[str] | None = None,
|
||||
batch_size: int = 1000,
|
||||
) -> None:
|
||||
"""
|
||||
删除指定前缀的所有 key
|
||||
|
||||
:param prefix: 要删除的键前缀
|
||||
:param exclude: 要排除的键或键列表
|
||||
:param key_prefix: 要删除的键前缀
|
||||
:param exclude_keys: 要排除的键或键列表
|
||||
:param batch_size: 批量删除的大小,避免一次性删除过多键导致 Redis 阻塞
|
||||
:return:
|
||||
"""
|
||||
exclude_set = set(exclude) if isinstance(exclude, list) else {exclude} if isinstance(exclude, str) else set()
|
||||
exclude_set = (
|
||||
set(exclude_keys)
|
||||
if isinstance(exclude_keys, list)
|
||||
else {exclude_keys}
|
||||
if isinstance(exclude_keys, str)
|
||||
else set()
|
||||
)
|
||||
batch_keys = []
|
||||
|
||||
async for key in self.scan_iter(match=f'{prefix}*'):
|
||||
if key_prefix not in exclude_set and await self.exists(key_prefix):
|
||||
batch_keys.append(key_prefix)
|
||||
|
||||
async for key in self.scan_iter(match=f'{key_prefix}:*'):
|
||||
if key not in exclude_set:
|
||||
batch_keys.append(key)
|
||||
|
||||
@@ -85,15 +99,15 @@ class RedisCli(Redis):
|
||||
if batch_keys:
|
||||
await self.delete(*batch_keys)
|
||||
|
||||
async def get_prefix(self, prefix: str, count: int = 100) -> list[str]:
|
||||
async def get_by_prefix(self, key_prefix: str, count: int = 100) -> list[str]:
|
||||
"""
|
||||
获取指定前缀的所有 key
|
||||
|
||||
:param prefix: 要搜索的键前缀
|
||||
:param key_prefix: 要搜索的键前缀
|
||||
:param count: 每次扫描批次的数量,值越大扫描速度越快,但会占用更多服务器资源
|
||||
:return:
|
||||
"""
|
||||
return [key async for key in self.scan_iter(match=f'{prefix}*', count=count)]
|
||||
return [key async for key in self.scan_iter(match=f'{key_prefix}:*', count=count)]
|
||||
|
||||
|
||||
# 创建 redis 客户端单例
|
||||
|
||||
+29
-19
@@ -7,30 +7,40 @@ from backend.plugin.requirements import install_requirements
|
||||
from backend.utils.console import console
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
_log_prefix = f'{timezone.to_str(timezone.now(), "%Y-%m-%d %H:%M:%S.%M0")} | {"INFO": <8} | - | '
|
||||
|
||||
console.print(Text(f'{_log_prefix}检查必需插件...', style='bold cyan'))
|
||||
def _get_log_prefix() -> str:
|
||||
"""获取启动日志前缀"""
|
||||
return f'{timezone.to_str(timezone.now(), "%Y-%m-%d %H:%M:%S.%M0")} | {"INFO": <8} | - | '
|
||||
|
||||
check_required_plugins()
|
||||
|
||||
console.print(Text(f'{_log_prefix}检测插件依赖...', style='bold cyan'))
|
||||
def _prepare_plugins() -> None:
|
||||
"""检查必需插件并安装缺失依赖"""
|
||||
log_prefix = _get_log_prefix()
|
||||
|
||||
_plugins = get_plugins()
|
||||
console.print(Text(f'{log_prefix}检查必需插件...', style='bold cyan'))
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(finished_text=f'[bold green]{_log_prefix}插件准备就绪[/]'),
|
||||
TextColumn('{task.description}'),
|
||||
TextColumn('{task.completed}/{task.total}', style='bold green'),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task('安装插件依赖...', total=len(_plugins))
|
||||
for plugin in _plugins:
|
||||
progress.update(task, description=f'[bold magenta]安装插件 {plugin} 依赖...[/]')
|
||||
install_requirements(plugin)
|
||||
progress.advance(task)
|
||||
progress.update(task, description='[bold green]-[/]')
|
||||
check_required_plugins()
|
||||
|
||||
console.print(Text(f'{_log_prefix}启动服务...', style='bold magenta'))
|
||||
console.print(Text(f'{log_prefix}检测插件依赖...', style='bold cyan'))
|
||||
|
||||
plugins = get_plugins()
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(finished_text=f'[bold green]{log_prefix}插件准备就绪[/]'),
|
||||
TextColumn('{task.description}'),
|
||||
TextColumn('{task.completed}/{task.total}', style='bold green'),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task('安装插件依赖...', total=len(plugins))
|
||||
for plugin in plugins:
|
||||
progress.update(task, description=f'[bold magenta]安装插件 {plugin} 依赖...[/]')
|
||||
install_requirements(plugin)
|
||||
progress.advance(task)
|
||||
progress.update(task, description='[bold green]-[/]')
|
||||
|
||||
console.print(Text(f'{log_prefix}启动服务...', style='bold magenta'))
|
||||
|
||||
|
||||
_prepare_plugins()
|
||||
app = register_app()
|
||||
|
||||
@@ -6,17 +6,23 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
|
||||
from backend.common.context import ctx
|
||||
from backend.common.log import log
|
||||
from backend.common.observability.prometheus.fastapi import (
|
||||
dec_fastapi_request_in_progress,
|
||||
inc_fastapi_exception,
|
||||
inc_fastapi_request,
|
||||
inc_fastapi_request_in_progress,
|
||||
inc_fastapi_response,
|
||||
observe_fastapi_request_cost_time,
|
||||
)
|
||||
from backend.common.response.response_code import StandardResponseCode
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.timezone import timezone
|
||||
from backend.utils.trace_id import get_request_trace_id
|
||||
|
||||
|
||||
class AccessMiddleware(BaseHTTPMiddleware):
|
||||
"""访问日志中间件"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
处理请求并记录访问日志
|
||||
|
||||
@@ -36,10 +42,59 @@ class AccessMiddleware(BaseHTTPMiddleware):
|
||||
if method != 'OPTIONS':
|
||||
log.debug(f'--> 请求开始[{path if not request.url.query else request.url.path + "?" + request.url.query}]')
|
||||
|
||||
if path.startswith(settings.FASTAPI_API_V1_PATH):
|
||||
should_record_metrics = settings.GRAFANA_METRICS_ENABLE and path.startswith(settings.FASTAPI_API_V1_PATH)
|
||||
if should_record_metrics:
|
||||
inc_fastapi_request_in_progress(method=method, path=path)
|
||||
inc_fastapi_request(method=method, path=path)
|
||||
|
||||
response = await call_next(request)
|
||||
# 为每个请求上下文注入默认租户 ID,授权接口认证成功后会覆盖为真实值
|
||||
ctx.tenant_id = settings.TENANT_DEFAULT_ID
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as e:
|
||||
elapsed = round((time.perf_counter() - perf_time) * 1000, 3)
|
||||
if should_record_metrics:
|
||||
inc_fastapi_exception(method=method, path=path, exception_type=type(e).__name__)
|
||||
observe_fastapi_request_cost_time(
|
||||
method=method, path=path, elapsed=elapsed, trace_id=get_request_trace_id()
|
||||
)
|
||||
inc_fastapi_response(
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=getattr(e, 'code', StandardResponseCode.HTTP_500),
|
||||
)
|
||||
raise
|
||||
else:
|
||||
elapsed = round((time.perf_counter() - perf_time) * 1000, 3)
|
||||
if should_record_metrics:
|
||||
exception_type = None
|
||||
exception_code = None
|
||||
for exception_key, current_exception_type in {
|
||||
'__request_authentication_exception__': 'AuthenticationError',
|
||||
'__request_http_exception__': 'HTTPException',
|
||||
'__request_validation_exception__': 'RequestValidationError',
|
||||
'__request_assertion_error__': 'AssertionError',
|
||||
'__request_custom_exception__': 'BaseExceptionError',
|
||||
'__request_unknown_exception__': 'Exception',
|
||||
}.items():
|
||||
exception = ctx.get(exception_key)
|
||||
if exception:
|
||||
exception_type = current_exception_type
|
||||
exception_code = exception.get('code')
|
||||
break
|
||||
if exception_type is not None:
|
||||
inc_fastapi_exception(method=method, path=path, exception_type=exception_type)
|
||||
observe_fastapi_request_cost_time(
|
||||
method=method, path=path, elapsed=elapsed, trace_id=get_request_trace_id()
|
||||
)
|
||||
inc_fastapi_response(
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=exception_code or response.status_code,
|
||||
)
|
||||
finally:
|
||||
if should_record_metrics:
|
||||
dec_fastapi_request_in_progress(method=method, path=path)
|
||||
|
||||
return response
|
||||
|
||||
@@ -7,6 +7,7 @@ from starlette.authentication import AuthenticationError as StarletteAuthenticat
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||
from backend.common.context import ctx
|
||||
from backend.common.exception.errors import TokenError
|
||||
from backend.common.log import log
|
||||
from backend.common.security.jwt import jwt_authentication
|
||||
@@ -49,7 +50,9 @@ class JwtAuthMiddleware(AuthenticationBackend):
|
||||
:param exc: 认证错误对象
|
||||
:return:
|
||||
"""
|
||||
return MsgSpecJSONResponse(content={'code': exc.code, 'msg': exc.msg, 'data': None}, status_code=exc.code)
|
||||
content = {'code': exc.code, 'msg': exc.msg, 'data': None}
|
||||
ctx.__request_authentication_exception__ = content
|
||||
return MsgSpecJSONResponse(content=content, status_code=exc.code)
|
||||
|
||||
@staticmethod
|
||||
def extract_token(request: Request) -> str | None:
|
||||
@@ -90,7 +93,10 @@ class JwtAuthMiddleware(AuthenticationBackend):
|
||||
try:
|
||||
user = await jwt_authentication(token)
|
||||
except TokenError as exc:
|
||||
raise AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers)
|
||||
if settings.TOKEN_REQUEST_UNDERLYING_SECURITY:
|
||||
raise AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers)
|
||||
ctx.__request_jwt_authentication_exception__ = exc
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f'JWT 授权异常:{e}')
|
||||
raise AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
|
||||
|
||||
@@ -2,26 +2,22 @@ import json
|
||||
import time
|
||||
|
||||
from asyncio import Queue
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Response
|
||||
from starlette.datastructures import UploadFile
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette_context import request_cycle_context
|
||||
|
||||
from backend.app.admin.schema.opera_log import CreateOperaLogParam
|
||||
from backend.app.admin.service.opera_log_service import opera_log_service
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.log import log
|
||||
from backend.common.observability.prometheus.fastapi import (
|
||||
dec_fastapi_request_in_progress,
|
||||
inc_fastapi_exception,
|
||||
inc_fastapi_response,
|
||||
observe_fastapi_request_cost_time,
|
||||
)
|
||||
from backend.common.observability.prometheus.queue import observe_queue_size
|
||||
from backend.common.queue import batch_dequeue
|
||||
from backend.common.queue import batch_consume
|
||||
from backend.common.response.response_code import StandardResponseCode
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
@@ -32,9 +28,9 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
"""操作日志中间件"""
|
||||
|
||||
opera_log_queue_name = 'opera_log_queue'
|
||||
opera_log_queue: Queue = Queue(maxsize=settings.OPERA_LOG_QUEUE_MAXSIZE)
|
||||
opera_log_queue: Queue[tuple[dict[str, Any], CreateOperaLogParam]] = Queue(maxsize=settings.OPERA_LOG_QUEUE_MAXSIZE)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response: # noqa: C901
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
处理请求并记录操作日志
|
||||
|
||||
@@ -68,9 +64,6 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
msg = getattr(e, 'msg', str(e))
|
||||
status = StatusType.disable
|
||||
|
||||
if path.startswith(settings.FASTAPI_API_V1_PATH):
|
||||
inc_fastapi_exception(method=method, path=path, exception_type=type(e).__name__)
|
||||
|
||||
raise
|
||||
else:
|
||||
elapsed = round((time.perf_counter() - ctx.perf_time) * 1000, 3)
|
||||
@@ -82,6 +75,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
'__request_validation_exception__',
|
||||
'__request_assertion_error__',
|
||||
'__request_custom_exception__',
|
||||
'__request_unknown_exception__',
|
||||
]:
|
||||
exception = ctx.get(exception_key)
|
||||
if exception:
|
||||
@@ -90,11 +84,6 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
status = StatusType.disable
|
||||
log.error(f'请求异常: {msg}')
|
||||
break
|
||||
|
||||
if path.startswith(settings.FASTAPI_API_V1_PATH):
|
||||
observe_fastapi_request_cost_time(
|
||||
method=method, path=path, elapsed=elapsed, trace_id=get_request_trace_id()
|
||||
)
|
||||
finally:
|
||||
# summary 只能在请求后获取
|
||||
route = request.scope.get('route')
|
||||
@@ -111,37 +100,41 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
log.info(f'{ctx.ip: <15} | {method: <8} | {code!s: <6} | {path} | {elapsed:.3f}ms')
|
||||
|
||||
if should_log_opera and request.method != 'OPTIONS':
|
||||
opera_log_in = CreateOperaLogParam(
|
||||
trace_id=get_request_trace_id(),
|
||||
username=username,
|
||||
method=method,
|
||||
title=summary,
|
||||
path=path,
|
||||
ip=ctx.ip,
|
||||
country=ctx.country,
|
||||
region=ctx.region,
|
||||
city=ctx.city,
|
||||
user_agent=ctx.user_agent,
|
||||
os=ctx.os,
|
||||
browser=ctx.browser,
|
||||
device=ctx.device,
|
||||
args=args,
|
||||
status=status,
|
||||
code=str(code),
|
||||
msg=msg,
|
||||
cost_time=elapsed,
|
||||
opera_time=ctx.start_time,
|
||||
)
|
||||
await self.opera_log_queue.put(opera_log_in)
|
||||
observe_queue_size(self.opera_log_queue, queue_name=self.opera_log_queue_name)
|
||||
opera_log_data = {
|
||||
'trace_id': get_request_trace_id(),
|
||||
'username': username,
|
||||
'method': method,
|
||||
'title': summary,
|
||||
'path': path,
|
||||
'ip': ctx.ip,
|
||||
'country': ctx.country,
|
||||
'region': ctx.region,
|
||||
'city': ctx.city,
|
||||
'user_agent': ctx.user_agent,
|
||||
'os': ctx.os,
|
||||
'browser': ctx.browser,
|
||||
'device': ctx.device,
|
||||
'args': args,
|
||||
'status': status,
|
||||
'code': str(code),
|
||||
'msg': msg,
|
||||
'cost_time': elapsed,
|
||||
'opera_time': ctx.start_time,
|
||||
}
|
||||
if settings.TENANT_ENABLED:
|
||||
tenant_id = ctx.get('tenant_id')
|
||||
if tenant_id is None:
|
||||
raise RuntimeError('opera log context is missing tenant_id')
|
||||
opera_log_data['tenant_id'] = tenant_id
|
||||
|
||||
if path.startswith(settings.FASTAPI_API_V1_PATH):
|
||||
inc_fastapi_response(method=method, path=path, status_code=code)
|
||||
dec_fastapi_request_in_progress(method=method, path=path)
|
||||
opera_log_in = CreateOperaLogParam(**opera_log_data)
|
||||
await self.opera_log_queue.put((ctx.copy(), opera_log_in))
|
||||
if settings.GRAFANA_METRICS_ENABLE:
|
||||
observe_queue_size(self.opera_log_queue, queue_name=self.opera_log_queue_name)
|
||||
|
||||
return response
|
||||
|
||||
async def get_request_args(self, request: Request) -> dict[str, Any] | None: # noqa: C901
|
||||
async def get_request_args(self, request: Request) -> dict[str, Any] | None: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
获取请求参数
|
||||
|
||||
@@ -162,13 +155,23 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Tip: .body() 必须在 .form() 之前获取
|
||||
# https://github.com/encode/starlette/discussions/1933
|
||||
content_type = request.headers.get('Content-Type', '').split(';')
|
||||
content_types = [item.strip().lower() for item in request.headers.get('Content-Type', '').split(';')]
|
||||
is_multipart = 'multipart/form-data' in content_types
|
||||
is_form = is_multipart or 'application/x-www-form-urlencoded' in content_types
|
||||
content_length = self.get_content_length(request)
|
||||
if content_length is not None and content_length > settings.OPERA_LOG_BODY_MAX_SIZE:
|
||||
args['body'] = self.build_truncated_body(content_length, settings.OPERA_LOG_BODY_MAX_SIZE)
|
||||
return args or None
|
||||
|
||||
if is_multipart and content_length is None:
|
||||
args['body'] = self.build_truncated_body(None, settings.OPERA_LOG_BODY_MAX_SIZE)
|
||||
return args or None
|
||||
|
||||
# 请求体
|
||||
body_data = await request.body()
|
||||
if body_data:
|
||||
if body_data and not is_form:
|
||||
# 注意:非 json 数据默认使用 data 作为键
|
||||
if 'application/json' not in content_type:
|
||||
if 'application/json' not in content_types:
|
||||
args['data'] = body_data.decode('utf-8', 'ignore') if isinstance(body_data, bytes) else str(body_data)
|
||||
else:
|
||||
json_data = await request.json()
|
||||
@@ -177,56 +180,64 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
else:
|
||||
args['data'] = str(json_data)
|
||||
|
||||
# 表单参数
|
||||
form_data = await request.form()
|
||||
if len(form_data) > 0:
|
||||
serialized_form = {}
|
||||
for k, v in form_data.items():
|
||||
if isinstance(v, UploadFile):
|
||||
serialized_form[k] = {
|
||||
'filename': v.filename,
|
||||
'content_type': v.content_type,
|
||||
'size': v.size,
|
||||
}
|
||||
if is_form:
|
||||
# 表单参数
|
||||
form_data = await request.form()
|
||||
if len(form_data) > 0:
|
||||
serialized_form = {}
|
||||
for k, v in form_data.items():
|
||||
if isinstance(v, UploadFile):
|
||||
serialized_form[k] = {
|
||||
'filename': v.filename,
|
||||
'content_type': v.content_type,
|
||||
'size': v.size,
|
||||
}
|
||||
else:
|
||||
serialized_form[k] = v
|
||||
if not is_multipart:
|
||||
args['x-www-form-urlencoded'] = self.desensitization(serialized_form)
|
||||
else:
|
||||
serialized_form[k] = v
|
||||
if 'multipart/form-data' not in content_type:
|
||||
args['x-www-form-urlencoded'] = self.desensitization(serialized_form)
|
||||
else:
|
||||
args['form-data'] = self.desensitization(serialized_form)
|
||||
args['form-data'] = self.desensitization(serialized_form)
|
||||
|
||||
if args:
|
||||
args = self.truncate(args)
|
||||
try:
|
||||
args_str = json.dumps(args, ensure_ascii=False)
|
||||
args_size = len(args_str.encode('utf-8'))
|
||||
if args_size > settings.OPERA_LOG_BODY_MAX_SIZE:
|
||||
args = self.build_truncated_body(args_size, settings.OPERA_LOG_BODY_MAX_SIZE)
|
||||
except Exception as e:
|
||||
log.error(f'请求参数截断处理失败:{e}')
|
||||
|
||||
return args or None
|
||||
|
||||
@staticmethod
|
||||
def truncate(args: dict[str, Any]) -> dict[str, Any]:
|
||||
def get_content_length(request: Request) -> int | None:
|
||||
"""
|
||||
截断处理
|
||||
获取请求体大小
|
||||
|
||||
:param args: 需要截断的请求参数字典
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
max_size = 10240 # 数据最大大小(字节)
|
||||
content_length = request.headers.get('Content-Length')
|
||||
if not content_length:
|
||||
return None
|
||||
return int(content_length)
|
||||
|
||||
try:
|
||||
args_str = json.dumps(args, ensure_ascii=False)
|
||||
args_size = len(args_str.encode('utf-8'))
|
||||
@staticmethod
|
||||
def build_truncated_body(original_size: int | None, max_size: int) -> dict[str, Any]:
|
||||
"""
|
||||
构建请求体截断信息
|
||||
|
||||
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
|
||||
:param original_size: 原始请求体大小
|
||||
:param max_size: 最大允许记录大小
|
||||
:return:
|
||||
"""
|
||||
return {
|
||||
'_truncated': True,
|
||||
'_original_size': original_size,
|
||||
'_max_size': max_size,
|
||||
'_message': '请求体过大或大小未知,已跳过操作日志请求体记录',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def desensitization(args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -244,22 +255,29 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
||||
@classmethod
|
||||
async def consumer(cls) -> None:
|
||||
"""操作日志消费者"""
|
||||
while True:
|
||||
logs = await batch_dequeue(
|
||||
cls.opera_log_queue,
|
||||
max_items=settings.OPERA_LOG_QUEUE_BATCH_CONSUME_SIZE,
|
||||
timeout=settings.OPERA_LOG_QUEUE_TIMEOUT,
|
||||
queue_name=cls.opera_log_queue_name,
|
||||
)
|
||||
if logs:
|
||||
try:
|
||||
if settings.DATABASE_ECHO:
|
||||
log.info('自动执行【操作日志批量创建】任务...')
|
||||
async with async_db_session.begin() as db:
|
||||
await opera_log_service.bulk_create(db=db, objs=logs)
|
||||
except Exception as e:
|
||||
log.error(f'操作日志入库失败,丢失 {len(logs)} 条日志: {e}')
|
||||
finally:
|
||||
for _ in range(len(logs)):
|
||||
cls.opera_log_queue.task_done()
|
||||
observe_queue_size(cls.opera_log_queue, queue_name=cls.opera_log_queue_name)
|
||||
|
||||
async def bulk_create_opera_log(logs: list[tuple[dict[str, Any], CreateOperaLogParam]]) -> None:
|
||||
"""批量创建操作日志"""
|
||||
if settings.DATABASE_ECHO:
|
||||
log.info('自动执行【操作日志批量创建】任务...')
|
||||
logs_by_tenant = defaultdict(list)
|
||||
for context_data, log_in in logs:
|
||||
tenant_id = context_data.get('tenant_id')
|
||||
if tenant_id is None:
|
||||
raise RuntimeError('opera log context is missing tenant_id')
|
||||
logs_by_tenant[tenant_id].append((context_data, log_in))
|
||||
async with async_db_session.begin() as db:
|
||||
for tenant_logs in logs_by_tenant.values():
|
||||
request_context = dict(tenant_logs[0][0])
|
||||
with request_cycle_context(request_context):
|
||||
await opera_log_service.bulk_create(db=db, objs=[log_in for _, log_in in tenant_logs])
|
||||
|
||||
await batch_consume(
|
||||
cls.opera_log_queue,
|
||||
max_items=settings.OPERA_LOG_QUEUE_BATCH_CONSUME_SIZE,
|
||||
timeout=settings.OPERA_LOG_QUEUE_TIMEOUT,
|
||||
handler=bulk_create_opera_log,
|
||||
queue_name=cls.opera_log_queue_name,
|
||||
error_message='操作日志入库失败',
|
||||
item_name='日志',
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ async def generate_code(db: CurrentSession, pk: Annotated[int, Path(description=
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='下载代码', dependencies=[DependsJwtAuth])
|
||||
async def download_code(db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]): # noqa: ANN201
|
||||
async def download_code(db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]): # ruff:ignore[missing-return-type-undocumented-public-function]
|
||||
bio = await gen_service.download(db=db, pk=pk)
|
||||
return StreamingResponse(
|
||||
bio,
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.plugin.code_generator.model import GenBusiness
|
||||
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam, UpdateGenBusinessParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
@@ -19,7 +20,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
:param pk: 代码生成业务 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> GenBusiness | None:
|
||||
"""
|
||||
@@ -29,7 +30,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
:param name: 表名
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, table_name=name)
|
||||
return await self.select_model_by_column(db, table_name=name, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[GenBusiness]:
|
||||
"""
|
||||
@@ -38,7 +39,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_select(self, table_name: str | None) -> Select:
|
||||
"""
|
||||
@@ -47,7 +48,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
:param table_name: 业务表名
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if table_name is not None:
|
||||
filters['table_name__like'] = f'%{table_name}%'
|
||||
@@ -73,7 +74,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
:param obj: 更新代码生成业务参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, pk: int) -> int:
|
||||
"""
|
||||
@@ -83,7 +84,16 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
|
||||
:param pk: 代码生成业务 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model(db, pk)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=pk,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
gen_business_dao: CRUDGenBusiness = CRUDGenBusiness(GenBusiness)
|
||||
|
||||
@@ -4,7 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.plugin.code_generator.model import GenColumn
|
||||
from backend.plugin.code_generator.schema.column import CreateGenColumnParam, UpdateGenColumnParam
|
||||
from backend.plugin.code_generator.schema.column import (
|
||||
CreateGenColumnInternalParam,
|
||||
CreateGenColumnParam,
|
||||
UpdateGenColumnParam,
|
||||
)
|
||||
|
||||
|
||||
class CRUDGenColumn(CRUDPlus[GenColumn]):
|
||||
@@ -41,6 +45,16 @@ class CRUDGenColumn(CRUDPlus[GenColumn]):
|
||||
"""
|
||||
await self.create_model(db, obj, pd_type=pd_type)
|
||||
|
||||
async def bulk_create(self, db: AsyncSession, objs: list[CreateGenColumnInternalParam]) -> None:
|
||||
"""
|
||||
批量创建代码生成模型列
|
||||
|
||||
:param db: 数据库会话
|
||||
:param objs: 创建代码生成模型列参数列表
|
||||
:return:
|
||||
"""
|
||||
await self.create_models(db, objs)
|
||||
|
||||
async def update(self, db: AsyncSession, pk: int, obj: UpdateGenColumnParam, pd_type: str | None) -> int:
|
||||
"""
|
||||
更新代码生成模型列
|
||||
|
||||
@@ -126,6 +126,8 @@ class CRUDGen:
|
||||
column_name <> 'id'
|
||||
and column_name <> 'created_time'
|
||||
and column_name <> 'updated_time'
|
||||
and column_name <> 'deleted'
|
||||
and column_name <> 'deleted_time'
|
||||
and table_name = :table_name
|
||||
and table_schema = :table_schema
|
||||
order by
|
||||
@@ -180,6 +182,8 @@ class CRUDGen:
|
||||
and a.attname <> 'id'
|
||||
and a.attname <> 'created_time'
|
||||
and a.attname <> 'updated_time'
|
||||
and a.attname <> 'deleted'
|
||||
and a.attname <> 'deleted_time'
|
||||
and t.relname = :table_name
|
||||
and n.nspname = :table_schema
|
||||
order by
|
||||
|
||||
@@ -9,10 +9,14 @@ class GenBusiness(Base):
|
||||
"""代码生成业务表"""
|
||||
|
||||
__tablename__ = 'gen_business'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('table_name', 'deleted', name='uk_gen_business_table_name_deleted'),
|
||||
{'comment': '代码生成业务表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
app_name: Mapped[str] = mapped_column(sa.String(64), comment='应用名称')
|
||||
table_name: Mapped[str] = mapped_column(sa.String(256), unique=True, comment='表名称')
|
||||
table_name: Mapped[str] = mapped_column(sa.String(256), comment='表名称')
|
||||
doc_comment: Mapped[str] = mapped_column(sa.String(256), comment='文档注释')
|
||||
table_comment: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='表描述')
|
||||
class_name: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='基础类名')
|
||||
|
||||
@@ -9,6 +9,10 @@ class GenColumn(DataClassBase):
|
||||
"""代码生成模型列表"""
|
||||
|
||||
__tablename__ = 'gen_column'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('gen_business_id', 'name', name='uk_gen_column_business_id_name'),
|
||||
{'comment': '代码生成模型列表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='列名称')
|
||||
|
||||
@@ -28,6 +28,12 @@ class CreateGenColumnParam(GenColumnSchemaBase):
|
||||
"""创建代码生成模型列参数"""
|
||||
|
||||
|
||||
class CreateGenColumnInternalParam(CreateGenColumnParam):
|
||||
"""创建代码生成模型列内部参数"""
|
||||
|
||||
pd_type: str | None = Field(None, description='列类型对应的 pydantic 类型')
|
||||
|
||||
|
||||
class UpdateGenColumnParam(GenColumnSchemaBase):
|
||||
"""更新代码生成模型列参数"""
|
||||
|
||||
|
||||
@@ -77,6 +77,11 @@ class GenBusinessService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
business = await gen_business_dao.get(db, pk)
|
||||
if not business:
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
if business.table_name != obj.table_name and await gen_business_dao.get_by_name(db, obj.table_name):
|
||||
raise errors.ConflictError(msg='代码生成业务已存在')
|
||||
return await gen_business_dao.update(db, pk, obj)
|
||||
|
||||
@staticmethod
|
||||
@@ -89,6 +94,9 @@ class GenBusinessService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
business = await gen_business_dao.get(db, pk)
|
||||
if not business:
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
return await gen_business_dao.delete(db, pk)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.common.enums import DataBaseType
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.plugin.code_generator.crud.crud_business import gen_business_dao
|
||||
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
||||
from backend.plugin.code_generator.enums import GenMySQLColumnType, GenPostgreSQLColumnType
|
||||
from backend.plugin.code_generator.model import GenColumn
|
||||
@@ -28,6 +29,8 @@ class GenColumnService:
|
||||
column = await gen_column_dao.get(db, pk)
|
||||
if not column:
|
||||
raise errors.NotFoundError(msg='代码生成模型列不存在')
|
||||
if not await gen_business_dao.get(db, column.gen_business_id):
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
return column
|
||||
|
||||
@staticmethod
|
||||
@@ -50,6 +53,8 @@ class GenColumnService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not await gen_business_dao.get(db, business_id):
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
return await gen_column_dao.get_all_by_business(db, business_id)
|
||||
|
||||
@staticmethod
|
||||
@@ -62,6 +67,9 @@ class GenColumnService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not await gen_business_dao.get(db, obj.gen_business_id):
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
|
||||
gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id)
|
||||
if obj.name in [gen_column.name for gen_column in gen_columns]:
|
||||
raise errors.ForbiddenError(msg='模型列已存在')
|
||||
@@ -81,6 +89,12 @@ class GenColumnService:
|
||||
"""
|
||||
|
||||
column = await gen_column_dao.get(db, pk)
|
||||
if not column:
|
||||
raise errors.NotFoundError(msg='代码生成模型列不存在')
|
||||
if not await gen_business_dao.get(db, column.gen_business_id):
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
if not await gen_business_dao.get(db, obj.gen_business_id):
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
if obj.name != column.name:
|
||||
gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id)
|
||||
if obj.name in [gen_column.name for gen_column in gen_columns]:
|
||||
@@ -99,6 +113,11 @@ class GenColumnService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
column = await gen_column_dao.get(db, pk)
|
||||
if not column:
|
||||
raise errors.NotFoundError(msg='代码生成模型列不存在')
|
||||
if not await gen_business_dao.get(db, column.gen_business_id):
|
||||
raise errors.NotFoundError(msg='代码生成业务不存在')
|
||||
return await gen_column_dao.delete(db, pk)
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
||||
from backend.plugin.code_generator.crud.crud_gen import gen_dao
|
||||
from backend.plugin.code_generator.model import GenBusiness
|
||||
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam
|
||||
from backend.plugin.code_generator.schema.column import CreateGenColumnParam
|
||||
from backend.plugin.code_generator.schema.column import CreateGenColumnInternalParam
|
||||
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||
from backend.plugin.code_generator.service.column_service import gen_column_service
|
||||
from backend.plugin.code_generator.utils.format_code import format_python_code
|
||||
@@ -87,12 +87,12 @@ class GenService:
|
||||
await db.flush()
|
||||
|
||||
column_info = await gen_dao.get_all_columns(db, obj.table_schema, table_name)
|
||||
gen_columns = []
|
||||
for column in column_info:
|
||||
column_type = column['column_type'].split('(')[0].upper()
|
||||
pd_type = sql_type_to_pydantic(column_type)
|
||||
await gen_column_dao.create(
|
||||
db,
|
||||
CreateGenColumnParam(
|
||||
gen_columns.append(
|
||||
CreateGenColumnInternalParam(
|
||||
name=column['column_name'],
|
||||
comment=column['column_comment'],
|
||||
type=column_type,
|
||||
@@ -103,9 +103,10 @@ class GenService:
|
||||
is_pk=column['is_pk'],
|
||||
is_nullable=column['is_nullable'],
|
||||
gen_business_id=new_business.id,
|
||||
pd_type=pd_type,
|
||||
),
|
||||
pd_type=pd_type,
|
||||
)
|
||||
await gen_column_dao.bulk_create(db, gen_columns)
|
||||
|
||||
@staticmethod
|
||||
async def _render_tpl_code(*, db: AsyncSession, business: GenBusiness) -> dict[str, str]:
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.{{ app_name }}.model import {{ class_name }}
|
||||
from backend.app.{{ app_name }}.schema.{{ filename }} import Create{{ schema_name }}Param, Update{{ schema_name }}Param
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUD{{ class_name }}(CRUDPlus[{{ class_name }}]):
|
||||
@@ -17,11 +18,11 @@ class CRUD{{ class_name }}(CRUDPlus[{{ class_name }}]):
|
||||
:param pk: {{ doc_comment }} ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_select(self) -> Select:
|
||||
"""获取{{ doc_comment }}列表查询表达式"""
|
||||
return await self.select_order('id', 'desc')
|
||||
return await self.select_order('id', 'desc', deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[{{ class_name }}]:
|
||||
"""
|
||||
@@ -30,7 +31,7 @@ class CRUD{{ class_name }}(CRUDPlus[{{ class_name }}]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: Create{{ schema_name }}Param) -> None:
|
||||
"""
|
||||
@@ -51,7 +52,7 @@ class CRUD{{ class_name }}(CRUDPlus[{{ class_name }}]):
|
||||
:param obj: 更新 {{ doc_comment }}参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
@@ -61,7 +62,17 @@ class CRUD{{ class_name }}(CRUDPlus[{{ class_name }}]):
|
||||
:param pks: {{ doc_comment }} ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
{{ table_name }}_dao: CRUD{{ class_name }} = CRUD{{ class_name }}({{ class_name }})
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.plugin.config.model import Config
|
||||
from backend.plugin.config.schema.config import CreateConfigParam, UpdateConfigParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDConfig(CRUDPlus[Config]):
|
||||
@@ -19,9 +20,9 @@ class CRUDConfig(CRUDPlus[Config]):
|
||||
:param pk: 参数配置 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, id=pk)
|
||||
return await self.select_model_by_column(db, id=pk, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession, type: str) -> Sequence[Config | None]:
|
||||
async def get_all(self, db: AsyncSession, type: str | None) -> Sequence[Config | None]:
|
||||
"""
|
||||
通过键名获取参数配置
|
||||
|
||||
@@ -29,7 +30,32 @@ class CRUDConfig(CRUDPlus[Config]):
|
||||
:param type: 参数配置类型
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, type=type)
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if type is not None:
|
||||
filters['type'] = type
|
||||
|
||||
return await self.select_models(db, **filters)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[Config]:
|
||||
"""
|
||||
通过 ID 列表批量获取参数配置
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 参数配置 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=pks, deleted=0)
|
||||
|
||||
async def get_all_by_keys(self, db: AsyncSession, keys: list[str]) -> Sequence[Config]:
|
||||
"""
|
||||
通过键名列表批量获取参数配置
|
||||
|
||||
:param db: 数据库会话
|
||||
:param keys: 参数配置键名列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, key__in=keys, deleted=0)
|
||||
|
||||
async def get_by_key(self, db: AsyncSession, key: str) -> Config | None:
|
||||
"""
|
||||
@@ -39,7 +65,7 @@ class CRUDConfig(CRUDPlus[Config]):
|
||||
:param key: 参数配置键名
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, key=key)
|
||||
return await self.select_model_by_column(db, key=key, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, type: str | None) -> Select:
|
||||
"""
|
||||
@@ -49,7 +75,7 @@ class CRUDConfig(CRUDPlus[Config]):
|
||||
:param type: 参数配置类型
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -77,7 +103,7 @@ class CRUDConfig(CRUDPlus[Config]):
|
||||
:param obj: 更新参数配置参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
async def bulk_update(self, db: AsyncSession, objs: list[UpdateConfigParam]) -> int:
|
||||
"""
|
||||
@@ -97,7 +123,17 @@ class CRUDConfig(CRUDPlus[Config]):
|
||||
:param pks: 参数配置 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
config_dao: CRUDConfig = CRUDConfig(Config)
|
||||
|
||||
@@ -4,6 +4,7 @@ from backend.common.enums import StrEnum
|
||||
class ConfigType(StrEnum):
|
||||
"""配置类型"""
|
||||
|
||||
ai = 'AI'
|
||||
email = 'EMAIL'
|
||||
user_security = 'USER_SECURITY'
|
||||
login = 'LOGIN'
|
||||
|
||||
@@ -9,11 +9,15 @@ class Config(Base):
|
||||
"""参数配置表"""
|
||||
|
||||
__tablename__ = 'sys_config'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('key', 'deleted', name='uk_sys_config_key_deleted'),
|
||||
{'comment': '参数配置表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(32), comment='名称')
|
||||
type: Mapped[str | None] = mapped_column(sa.String(32), server_default=None, comment='类型')
|
||||
key: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='键名')
|
||||
key: Mapped[str] = mapped_column(sa.String(64), comment='键名')
|
||||
value: Mapped[str] = mapped_column(UniversalText, comment='键值')
|
||||
is_frontend: Mapped[bool] = mapped_column(default=False, comment='是否前端')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
|
||||
@@ -20,7 +20,7 @@ class ConfigService:
|
||||
"""参数配置服务类"""
|
||||
|
||||
@staticmethod
|
||||
@cached(settings.CACHE_CONFIG_REDIS_PREFIX, key='pk')
|
||||
@cached(namespace=settings.CACHE_CONFIG_REDIS_PREFIX, key='pk')
|
||||
async def get(*, db: AsyncSession, pk: int) -> Config:
|
||||
"""
|
||||
获取参数配置详情
|
||||
@@ -35,7 +35,7 @@ class ConfigService:
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
@cached(settings.CACHE_CONFIG_REDIS_PREFIX, key='type')
|
||||
@cached(namespace=settings.CACHE_CONFIG_REDIS_PREFIX, key='type')
|
||||
async def get_all(*, db: AsyncSession, type: str | None) -> Sequence[Config | None]:
|
||||
"""
|
||||
获取所有参数配置
|
||||
@@ -60,6 +60,7 @@ class ConfigService:
|
||||
return await paging_data(db, config_select)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(namespace=settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def create(*, db: AsyncSession, obj: CreateConfigParam) -> None:
|
||||
"""
|
||||
创建参数配置
|
||||
@@ -74,7 +75,7 @@ class ConfigService:
|
||||
await config_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
@cache_invalidate(namespace=settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateConfigParam) -> int:
|
||||
"""
|
||||
更新参数配置
|
||||
@@ -95,7 +96,7 @@ class ConfigService:
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
@cache_invalidate(namespace=settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def bulk_update(*, db: AsyncSession, objs: list[UpdateConfigsParam]) -> int:
|
||||
"""
|
||||
批量更新参数配置
|
||||
@@ -104,20 +105,27 @@ class ConfigService:
|
||||
:param objs: 参数配置批量更新参数
|
||||
:return:
|
||||
"""
|
||||
for _batch in range(0, len(objs), 1000):
|
||||
for obj in objs:
|
||||
config = await config_dao.get(db, obj.id)
|
||||
if not config:
|
||||
raise errors.NotFoundError(msg='参数配置不存在')
|
||||
if config.key != obj.key:
|
||||
config = await config_dao.get_by_key(db, obj.key)
|
||||
if config:
|
||||
raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
|
||||
configs = await config_dao.get_all_by_ids(db, list({obj.id for obj in objs}))
|
||||
config_map = {config.id: config for config in configs}
|
||||
for obj in objs:
|
||||
if obj.id not in config_map:
|
||||
raise errors.NotFoundError(msg='参数配置不存在')
|
||||
|
||||
changed_keys = [obj.key for obj in objs if config_map[obj.id].key != obj.key]
|
||||
if len(changed_keys) != len(set(changed_keys)):
|
||||
raise errors.ConflictError(msg='参数配置键名重复')
|
||||
|
||||
key_configs = await config_dao.get_all_by_keys(db, list(set(changed_keys)))
|
||||
key_owner = {config.key: config.id for config in key_configs}
|
||||
for obj in objs:
|
||||
if config_map[obj.id].key != obj.key and obj.key in key_owner and key_owner[obj.key] != obj.id:
|
||||
raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
|
||||
|
||||
count = await config_dao.bulk_update(db, objs)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
@cache_invalidate(namespace=settings.CACHE_CONFIG_REDIS_PREFIX)
|
||||
async def delete(*, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除参数配置
|
||||
|
||||
+27
-299
@@ -1,28 +1,22 @@
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import rtoml
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, Request
|
||||
|
||||
from backend.common.dataclasses import PluginEntry
|
||||
from backend.common.enums import DataBaseType, LifespanStage, PluginLevelType, PrimaryKeyType, StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.lifespan import lifespan_manager
|
||||
from backend.common.enums import PluginLevelType, StatusType
|
||||
from backend.common.log import log
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.database.redis import RedisCli, redis_client
|
||||
from backend.database.redis import RedisCli
|
||||
from backend.plugin.errors import PluginConfigError, PluginInjectError
|
||||
from backend.plugin.status import get_plugin_enable
|
||||
from backend.plugin.validator import validate_plugin_config
|
||||
from backend.utils.async_helper import run_await
|
||||
from backend.utils.dynamic_import import get_model_objects, import_module_cached
|
||||
from backend.utils.dynamic_import import get_model_objects
|
||||
|
||||
|
||||
def check_plugin_installed(plugin_name: str) -> bool:
|
||||
@@ -38,6 +32,8 @@ def check_plugin_installed(plugin_name: str) -> bool:
|
||||
def get_required_plugins() -> tuple[str, ...]:
|
||||
"""获取必需插件列表"""
|
||||
required_plugins = list(settings.PLUGIN_REQUIRED)
|
||||
if settings.TENANT_ENABLED and 'tenant' not in required_plugins:
|
||||
required_plugins.append('tenant')
|
||||
if not settings.RBAC_ROLE_MENU_MODE and 'casbin_rbac' not in required_plugins:
|
||||
required_plugins.append('casbin_rbac')
|
||||
return tuple(required_plugins)
|
||||
@@ -93,23 +89,6 @@ def get_enabled_plugins(plugins: tuple[str, ...] | None = None) -> set[str]:
|
||||
return enabled_plugins
|
||||
|
||||
|
||||
def get_plugin_enable(plugin_info: str | None, default_status: int) -> str:
|
||||
"""
|
||||
解析插件启用状态
|
||||
|
||||
:param plugin_info: 插件缓存信息
|
||||
:param default_status: 默认状态值
|
||||
:return:
|
||||
"""
|
||||
if not plugin_info:
|
||||
return str(default_status)
|
||||
|
||||
try:
|
||||
return json.loads(plugin_info)['plugin']['enable']
|
||||
except Exception:
|
||||
return str(default_status)
|
||||
|
||||
|
||||
def load_plugin_config(plugin: str) -> dict[str, Any]:
|
||||
"""
|
||||
加载插件配置
|
||||
@@ -138,9 +117,9 @@ def parse_plugin_config() -> tuple[list[PluginEntry], list[PluginEntry]]:
|
||||
try:
|
||||
# 清理未知插件信息
|
||||
exclude_keys = [f'{settings.PLUGIN_REDIS_PREFIX}:{key}' for key in plugins]
|
||||
run_await(current_redis_client.delete_prefix)(
|
||||
run_await(current_redis_client.delete_by_prefix)(
|
||||
settings.PLUGIN_REDIS_PREFIX,
|
||||
exclude=exclude_keys,
|
||||
exclude_keys=exclude_keys,
|
||||
)
|
||||
|
||||
for plugin in plugins:
|
||||
@@ -153,18 +132,23 @@ def parse_plugin_config() -> tuple[list[PluginEntry], list[PluginEntry]]:
|
||||
plugin_cache_info = run_await(current_redis_client.get)(plugin_cache_key)
|
||||
plugin_config['plugin']['enable'] = get_plugin_enable(plugin_cache_info, StatusType.enable.value)
|
||||
|
||||
plugin_entry = PluginEntry(
|
||||
name=plugin,
|
||||
depends_on=plugin_config['plugin'].get('depends_on'),
|
||||
extend=plugin_config['app']['extend'] if plugin_type == PluginLevelType.extend else None,
|
||||
routers=plugin_config['app']['router'] if plugin_type == PluginLevelType.app else None,
|
||||
api=plugin_config['api'] if plugin_type == PluginLevelType.extend else None,
|
||||
)
|
||||
|
||||
if plugin_type == PluginLevelType.extend:
|
||||
extend_plugins.append(plugin_entry)
|
||||
else:
|
||||
app_plugins.append(plugin_entry)
|
||||
extend_plugins.append(
|
||||
PluginEntry(
|
||||
name=plugin,
|
||||
depends_on=plugin_config['plugin'].get('depends_on'),
|
||||
extend=plugin_config['app']['extend'],
|
||||
api=plugin_config['api'],
|
||||
)
|
||||
)
|
||||
elif plugin_type == PluginLevelType.app:
|
||||
app_plugins.append(
|
||||
PluginEntry(
|
||||
name=plugin,
|
||||
depends_on=plugin_config['plugin'].get('depends_on'),
|
||||
routers=plugin_config['app']['router'],
|
||||
)
|
||||
)
|
||||
|
||||
# 缓存最新插件信息
|
||||
run_await(current_redis_client.set)(plugin_cache_key, json.dumps(plugin_config, ensure_ascii=False))
|
||||
@@ -215,195 +199,18 @@ def resolve_plugin_order(plugins: list[PluginEntry]) -> list[PluginEntry]:
|
||||
return ordered_plugins
|
||||
|
||||
|
||||
def build_final_router() -> APIRouter:
|
||||
"""构建最终路由"""
|
||||
extend_plugins, app_plugins = parse_plugin_config()
|
||||
plugins = extend_plugins + app_plugins
|
||||
ordered_plugins = resolve_plugin_order(plugins)
|
||||
|
||||
for plugin in ordered_plugins:
|
||||
if plugin.api is not None:
|
||||
inject_extend_router(plugin)
|
||||
|
||||
# 主路由,必须在扩展级插件路由注入后,应用级插件路由注入前导入
|
||||
from backend.app.router import router as main_router
|
||||
|
||||
for plugin in ordered_plugins:
|
||||
if plugin.routers is not None:
|
||||
inject_app_router(plugin, main_router)
|
||||
|
||||
return main_router
|
||||
|
||||
|
||||
def inject_extend_router(plugin: PluginEntry) -> None:
|
||||
"""
|
||||
扩展级插件路由注入
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_api_path = PLUGIN_DIR / plugin.name / 'api'
|
||||
if not os.path.exists(plugin_api_path):
|
||||
raise PluginConfigError(f'插件 {plugin.name} 缺少 api 目录,请检查插件文件是否完整')
|
||||
|
||||
for root, _, api_files in os.walk(plugin_api_path):
|
||||
for file in api_files:
|
||||
if not (file.endswith('.py') and file != '__init__.py'):
|
||||
continue
|
||||
|
||||
# 解析插件路由配置
|
||||
file_config = plugin.api[file[:-3]]
|
||||
prefix = file_config['prefix']
|
||||
tags = file_config['tags']
|
||||
|
||||
# 获取插件路由模块
|
||||
file_path = os.path.join(root, file)
|
||||
path_to_module_str = os.path.relpath(file_path, PLUGIN_DIR).replace(os.sep, '.')[:-3]
|
||||
module_path = f'backend.plugin.{path_to_module_str}'
|
||||
|
||||
try:
|
||||
module = import_module_cached(module_path)
|
||||
plugin_router = getattr(module, 'router', None)
|
||||
if not plugin_router:
|
||||
warnings.warn(
|
||||
f'扩展级插件 {plugin.name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整',
|
||||
FutureWarning,
|
||||
)
|
||||
continue
|
||||
|
||||
# 获取目标 app 路由
|
||||
relative_path = os.path.relpath(root, plugin_api_path)
|
||||
app_name = plugin.extend
|
||||
target_module_path = f'backend.app.{app_name}.api.{relative_path.replace(os.sep, ".")}'
|
||||
target_module = import_module_cached(target_module_path)
|
||||
target_router = getattr(target_module, 'router', None)
|
||||
|
||||
if not target_router or not isinstance(target_router, APIRouter):
|
||||
raise PluginInjectError(
|
||||
f'扩展级插件 {plugin.name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整',
|
||||
)
|
||||
|
||||
# 将插件路由注入到目标路由中
|
||||
target_router.include_router(
|
||||
router=plugin_router,
|
||||
prefix=prefix,
|
||||
tags=[tags] if tags else [],
|
||||
dependencies=[Depends(PluginStatusChecker(plugin.name))],
|
||||
)
|
||||
except Exception as e:
|
||||
raise PluginInjectError(f'扩展级插件 {plugin.name} 路由注入失败:{e!s}') from e
|
||||
|
||||
|
||||
def inject_app_router(plugin: PluginEntry, target_router: APIRouter) -> None:
|
||||
"""
|
||||
应用级插件路由注入
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param target_router: FastAPI 路由器
|
||||
:return:
|
||||
"""
|
||||
module_path = f'backend.plugin.{plugin.name}.api.router'
|
||||
try:
|
||||
module = import_module_cached(module_path)
|
||||
routers = plugin.routers
|
||||
if not routers or not isinstance(routers, list):
|
||||
raise PluginConfigError(f'应用级插件 {plugin.name} 配置文件存在错误,请检查')
|
||||
|
||||
for router in routers:
|
||||
plugin_router = getattr(module, router, None)
|
||||
if not plugin_router or not isinstance(plugin_router, APIRouter):
|
||||
raise PluginInjectError(
|
||||
f'应用级插件 {plugin.name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整',
|
||||
)
|
||||
|
||||
# 将插件路由注入到目标路由中
|
||||
target_router.include_router(plugin_router, dependencies=[Depends(PluginStatusChecker(plugin.name))])
|
||||
except Exception as e:
|
||||
raise PluginInjectError(f'应用级插件 {plugin.name} 路由注入失败:{e!s}') from e
|
||||
|
||||
|
||||
def register_plugin_lifespan_hook(plugin: str, module: Any) -> None:
|
||||
"""
|
||||
注册插件 lifespan hook
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param module: 插件 hooks 模块
|
||||
:return:
|
||||
"""
|
||||
lifespan_hook = getattr(module, 'lifespan', None)
|
||||
if lifespan_hook is None:
|
||||
return
|
||||
|
||||
if not callable(lifespan_hook):
|
||||
log.warning(f'插件 {plugin} 的 lifespan 不是可调用对象,已跳过')
|
||||
return
|
||||
|
||||
lifespan_manager.register(lifespan_hook, stage=LifespanStage.plugin) # type: ignore[call-overload]
|
||||
log.info(f'插件 {plugin} lifespan hook 注册成功')
|
||||
|
||||
|
||||
def run_plugin_setup_hook(plugin: str, module: Any, app: FastAPI) -> None:
|
||||
"""
|
||||
执行插件 setup hook
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param module: 插件 hooks 模块
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
setup_hook = getattr(module, 'setup', None)
|
||||
if setup_hook is None:
|
||||
return
|
||||
|
||||
if not callable(setup_hook):
|
||||
log.warning(f'插件 {plugin} 的 setup 不是可调用对象,已跳过')
|
||||
return
|
||||
|
||||
setup_result = setup_hook(app)
|
||||
if inspect.isawaitable(setup_result):
|
||||
run_await(lambda: setup_result)() # type: ignore
|
||||
log.info(f'插件 {plugin} setup hook 执行成功')
|
||||
|
||||
|
||||
def setup_plugins(app: FastAPI) -> None:
|
||||
"""
|
||||
注册并执行插件 hooks
|
||||
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
def get_ordered_enabled_plugins() -> list[PluginEntry]:
|
||||
"""获取按依赖排序后的已启用插件"""
|
||||
enabled_plugins = get_enabled_plugins()
|
||||
extend_plugins, app_plugins = parse_plugin_config()
|
||||
plugins: list[PluginEntry] = [plugin for plugin in extend_plugins + app_plugins if plugin.name in enabled_plugins]
|
||||
|
||||
# 按插件依赖关系排序
|
||||
try:
|
||||
ordered_plugins = resolve_plugin_order(plugins)
|
||||
return resolve_plugin_order(plugins)
|
||||
except PluginConfigError as e:
|
||||
log.error(f'插件依赖解析失败: {e}')
|
||||
raise
|
||||
|
||||
# 注册并执行 hooks
|
||||
for plugin in ordered_plugins:
|
||||
module_path = f'backend.plugin.{plugin.name}.hooks'
|
||||
try:
|
||||
module = import_module_cached(module_path)
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name == module_path:
|
||||
continue
|
||||
log.warning(f'插件 {plugin.name} hooks 加载失败: {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
log.warning(f'插件 {plugin.name} hooks 加载失败: {e}')
|
||||
continue
|
||||
|
||||
try:
|
||||
register_plugin_lifespan_hook(plugin.name, module)
|
||||
run_plugin_setup_hook(plugin.name, module, app)
|
||||
except Exception as e:
|
||||
log.exception(f'插件 {plugin.name} hooks 执行失败: {e}')
|
||||
raise PluginInjectError(f'插件 {plugin.name} hooks 执行失败:{e!s}') from e
|
||||
|
||||
|
||||
def get_plugin_models() -> list[object]:
|
||||
"""获取插件所有模型类"""
|
||||
@@ -416,82 +223,3 @@ def get_plugin_models() -> list[object]:
|
||||
objs.extend(model_objs)
|
||||
|
||||
return objs
|
||||
|
||||
|
||||
def build_sql_filename(
|
||||
prefix: str,
|
||||
pk_type: PrimaryKeyType,
|
||||
*,
|
||||
suffix: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建插件 SQL 脚本文件名
|
||||
|
||||
:param prefix: SQL 脚本文件名前缀,例如 init 或 destroy
|
||||
:param pk_type: 主键类型,雪花 ID 模式会追加 snowflake 标识
|
||||
:param suffix: 可选文件名后缀,追加在主键类型标识之后
|
||||
:return:
|
||||
"""
|
||||
parts = [prefix]
|
||||
if pk_type == PrimaryKeyType.snowflake:
|
||||
parts.append('snowflake')
|
||||
if suffix:
|
||||
parts.append(suffix)
|
||||
return f'{"_".join(parts)}.sql'
|
||||
|
||||
|
||||
async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
|
||||
"""
|
||||
获取插件 SQL 脚本
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param db_type: 数据库类型
|
||||
:param pk_type: 主键类型
|
||||
:return:
|
||||
"""
|
||||
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
|
||||
default_filename = build_sql_filename('init', pk_type)
|
||||
default_sql_file = sql_dir / default_filename
|
||||
return str(default_sql_file) if await anyio.Path(default_sql_file).exists() else None
|
||||
|
||||
|
||||
async def get_plugin_destroy_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
|
||||
"""
|
||||
获取插件销毁 SQL 脚本
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param db_type: 数据库类型
|
||||
:param pk_type: 主键类型
|
||||
:return:
|
||||
"""
|
||||
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
|
||||
sql_file = sql_dir / build_sql_filename('destroy', pk_type)
|
||||
return str(sql_file) if await anyio.Path(sql_file).exists() else None
|
||||
|
||||
|
||||
class PluginStatusChecker:
|
||||
"""插件状态检查器"""
|
||||
|
||||
def __init__(self, plugin: str) -> None:
|
||||
"""
|
||||
初始化插件状态检查器
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
self.plugin = plugin
|
||||
|
||||
async def __call__(self, request: Request) -> None:
|
||||
"""
|
||||
验证插件状态
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{self.plugin}')
|
||||
if not plugin_info:
|
||||
log.error('插件状态未初始化或丢失,需重启服务自动修复')
|
||||
raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员')
|
||||
|
||||
if get_plugin_enable(plugin_info, StatusType.disable.value) != str(StatusType.enable.value):
|
||||
raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
from backend.common.enums import StatusType
|
||||
from backend.plugin.dict.model import DictData
|
||||
from backend.plugin.dict.schema.dict_data import CreateDictDataParam, UpdateDictDataParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDictData(CRUDPlus[DictData]):
|
||||
@@ -20,7 +21,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
:param pk: 字典数据 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_by_type_code(self, db: AsyncSession, type_code: str) -> Sequence[DictData]:
|
||||
"""
|
||||
@@ -36,6 +37,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
sort_orders='desc',
|
||||
type_code=type_code,
|
||||
status=StatusType.enable.value,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DictData]:
|
||||
@@ -45,7 +47,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_select(
|
||||
self,
|
||||
@@ -65,7 +67,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
:param type_id: 字典类型 ID
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if type_code is not None:
|
||||
filters['type_code'] = type_code
|
||||
@@ -89,7 +91,11 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
:param type_code: 字典类型编码
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, and_(self.model.label == label, self.model.type_code == type_code))
|
||||
return await self.select_model_by_column(
|
||||
db,
|
||||
and_(self.model.label == label, self.model.type_code == type_code),
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDictDataParam, type_code: str) -> None:
|
||||
"""
|
||||
@@ -117,7 +123,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
"""
|
||||
dict_obj = obj.model_dump()
|
||||
dict_obj.update({'type_code': type_code})
|
||||
return await self.update_model(db, pk, dict_obj)
|
||||
return await self.update_model_by_column(db, dict_obj, id=pk, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
@@ -127,7 +133,17 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
:param pks: 字典数据 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def delete_by_type_id(self, db: AsyncSession, type_ids: list[int]) -> int:
|
||||
"""
|
||||
@@ -137,7 +153,17 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
:param type_ids: 字典类型 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, type_id__in=type_ids)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
type_id__in=type_ids,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
dict_data_dao: CRUDDictData = CRUDDictData(DictData)
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy_crud_plus import CRUDPlus
|
||||
from backend.plugin.dict.crud.crud_dict_data import dict_data_dao
|
||||
from backend.plugin.dict.model import DictType
|
||||
from backend.plugin.dict.schema.dict_type import CreateDictTypeParam, UpdateDictTypeParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDictType(CRUDPlus[DictType]):
|
||||
@@ -20,7 +21,7 @@ class CRUDDictType(CRUDPlus[DictType]):
|
||||
:param pk: 字典类型 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DictType]:
|
||||
"""
|
||||
@@ -29,7 +30,7 @@ class CRUDDictType(CRUDPlus[DictType]):
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, code: str | None) -> Select:
|
||||
"""
|
||||
@@ -39,7 +40,7 @@ class CRUDDictType(CRUDPlus[DictType]):
|
||||
:param code: 字典类型编码
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
@@ -56,7 +57,7 @@ class CRUDDictType(CRUDPlus[DictType]):
|
||||
:param code: 字典编码
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, code=code)
|
||||
return await self.select_model_by_column(db, code=code, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDictTypeParam) -> None:
|
||||
"""
|
||||
@@ -77,7 +78,7 @@ class CRUDDictType(CRUDPlus[DictType]):
|
||||
:param obj: 更新字典类型参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
@@ -88,7 +89,17 @@ class CRUDDictType(CRUDPlus[DictType]):
|
||||
:return:
|
||||
"""
|
||||
await dict_data_dao.delete_by_type_id(db, pks)
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
dict_type_dao: CRUDDictType = CRUDDictType(DictType)
|
||||
|
||||
@@ -9,6 +9,10 @@ class DictData(Base):
|
||||
"""字典数据表"""
|
||||
|
||||
__tablename__ = 'sys_dict_data'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('type_code', 'label', 'deleted', name='uk_sys_dict_data_type_code_label_deleted'),
|
||||
{'comment': '字典数据表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
type_code: Mapped[str] = mapped_column(sa.String(32), comment='对应的字典类型编码')
|
||||
|
||||
@@ -9,8 +9,12 @@ class DictType(Base):
|
||||
"""字典类型表"""
|
||||
|
||||
__tablename__ = 'sys_dict_type'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('code', 'deleted', name='uk_sys_dict_type_code_deleted'),
|
||||
{'comment': '字典类型表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(32), comment='字典类型名称')
|
||||
code: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='字典类型编码')
|
||||
code: Mapped[str] = mapped_column(sa.String(32), comment='字典类型编码')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
|
||||
@@ -17,7 +17,7 @@ class DictDataService:
|
||||
"""字典数据服务类"""
|
||||
|
||||
@staticmethod
|
||||
@cached(settings.CACHE_DICT_REDIS_PREFIX, key='pk')
|
||||
@cached(namespace=settings.CACHE_DICT_REDIS_PREFIX, key='pk')
|
||||
async def get(*, db: AsyncSession, pk: int) -> DictData:
|
||||
"""
|
||||
获取字典数据详情
|
||||
@@ -33,7 +33,7 @@ class DictDataService:
|
||||
|
||||
@staticmethod
|
||||
@cached(
|
||||
settings.CACHE_DICT_REDIS_PREFIX,
|
||||
namespace=settings.CACHE_DICT_REDIS_PREFIX,
|
||||
key_builder=lambda *, db, code: f'type:{code}',
|
||||
)
|
||||
async def get_by_type_code(*, db: AsyncSession, code: str) -> Sequence[DictData]:
|
||||
@@ -91,6 +91,7 @@ class DictDataService:
|
||||
return await paging_data(db, dict_data_select)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(namespace=settings.CACHE_DICT_REDIS_PREFIX)
|
||||
async def create(*, db: AsyncSession, obj: CreateDictDataParam) -> None:
|
||||
"""
|
||||
创建字典数据
|
||||
@@ -108,7 +109,7 @@ class DictDataService:
|
||||
await dict_data_dao.create(db, obj, dict_type.code)
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_DICT_REDIS_PREFIX)
|
||||
@cache_invalidate(namespace=settings.CACHE_DICT_REDIS_PREFIX)
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateDictDataParam) -> int:
|
||||
"""
|
||||
更新字典数据
|
||||
@@ -124,15 +125,15 @@ class DictDataService:
|
||||
dict_type = await dict_type_dao.get(db, obj.type_id)
|
||||
if not dict_type:
|
||||
raise errors.NotFoundError(msg='字典类型不存在')
|
||||
if dict_data.label != obj.label and await dict_data_dao.get_by_label_and_type_code(
|
||||
db, obj.label, dict_type.code
|
||||
):
|
||||
raise errors.ConflictError(msg='字典数据已存在')
|
||||
if dict_data.label != obj.label or dict_data.type_code != dict_type.code:
|
||||
new_dict_data = await dict_data_dao.get_by_label_and_type_code(db, obj.label, dict_type.code)
|
||||
if new_dict_data and new_dict_data.id != pk:
|
||||
raise errors.ConflictError(msg='字典数据已存在')
|
||||
count = await dict_data_dao.update(db, pk, obj, dict_type.code)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@cache_invalidate(settings.CACHE_DICT_REDIS_PREFIX)
|
||||
@cache_invalidate(namespace=settings.CACHE_DICT_REDIS_PREFIX)
|
||||
async def delete(*, db: AsyncSession, obj: DeleteDictDataParam) -> int:
|
||||
"""
|
||||
批量删除字典数据
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.utils.dynamic_config import load_config, str_to_bool
|
||||
|
||||
|
||||
async def load_email_config(db: AsyncSession) -> None:
|
||||
"""
|
||||
获取邮箱配置
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
mapping = {
|
||||
'EMAIL_HOST': str,
|
||||
'EMAIL_PORT': int,
|
||||
'EMAIL_SSL': str_to_bool,
|
||||
'EMAIL_USERNAME': str,
|
||||
'EMAIL_PASSWORD': str,
|
||||
}
|
||||
await load_config(db, 'email', mapping, 'EMAIL_CONFIG_STATUS')
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.common.log import log
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.utils.dynamic_config import load_email_config
|
||||
from backend.plugin.email.utils.dynamic_config import load_email_config
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import inspect
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from backend.common.enums import LifespanStage
|
||||
from backend.common.lifespan import lifespan_manager
|
||||
from backend.common.log import log
|
||||
from backend.plugin.core import get_ordered_enabled_plugins
|
||||
from backend.plugin.errors import PluginInjectError
|
||||
from backend.utils.async_helper import run_await
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
|
||||
|
||||
def register_plugin_lifespan_hook(plugin: str, module: Any) -> None:
|
||||
"""
|
||||
注册插件 lifespan hook
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param module: 插件 hooks 模块
|
||||
:return:
|
||||
"""
|
||||
lifespan_hook = getattr(module, 'lifespan', None)
|
||||
if lifespan_hook is None:
|
||||
return
|
||||
|
||||
if not callable(lifespan_hook):
|
||||
log.warning(f'插件 {plugin} 的 lifespan 不是可调用对象,已跳过')
|
||||
return
|
||||
|
||||
lifespan_manager.register(lifespan_hook, stage=LifespanStage.plugin) # type: ignore[call-overload]
|
||||
log.info(f'插件 {plugin} lifespan hook 注册成功')
|
||||
|
||||
|
||||
def run_plugin_setup_hook(plugin: str, module: Any, app: FastAPI) -> None:
|
||||
"""
|
||||
执行插件 setup hook
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param module: 插件 hooks 模块
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
setup_hook = getattr(module, 'setup', None)
|
||||
if setup_hook is None:
|
||||
return
|
||||
|
||||
if not callable(setup_hook):
|
||||
log.warning(f'插件 {plugin} 的 setup 不是可调用对象,已跳过')
|
||||
return
|
||||
|
||||
setup_result = setup_hook(app)
|
||||
if inspect.isawaitable(setup_result):
|
||||
run_await(lambda: setup_result)() # type: ignore
|
||||
log.info(f'插件 {plugin} setup hook 执行成功')
|
||||
|
||||
|
||||
def run_plugin_otel_hook(plugin: str, module: Any, app: FastAPI) -> None:
|
||||
"""
|
||||
执行插件 OpenTelemetry hook
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param module: 插件 hooks 模块
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
otel_hook = getattr(module, 'otel', None)
|
||||
if otel_hook is None:
|
||||
return
|
||||
|
||||
if not callable(otel_hook):
|
||||
log.warning(f'插件 {plugin} 的 otel 不是可调用对象,已跳过')
|
||||
return
|
||||
|
||||
otel_result = otel_hook(app)
|
||||
if inspect.isawaitable(otel_result):
|
||||
run_await(lambda: otel_result)() # type: ignore
|
||||
log.info(f'插件 {plugin} otel hook 执行成功')
|
||||
|
||||
|
||||
def _get_plugin_hook_modules() -> list[tuple[str, Any]]:
|
||||
"""
|
||||
获取插件 hooks 模块
|
||||
|
||||
:return:
|
||||
"""
|
||||
plugin_hook_modules: list[tuple[str, Any]] = []
|
||||
|
||||
for plugin in get_ordered_enabled_plugins():
|
||||
module_path = f'backend.plugin.{plugin.name}.hooks'
|
||||
try:
|
||||
module = import_module_cached(module_path)
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name == module_path:
|
||||
continue
|
||||
log.warning(f'插件 {plugin.name} hooks 加载失败: {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
log.warning(f'插件 {plugin.name} hooks 加载失败: {e}')
|
||||
continue
|
||||
|
||||
plugin_hook_modules.append((plugin.name, module))
|
||||
|
||||
return plugin_hook_modules
|
||||
|
||||
|
||||
def register_plugin_hooks(app: FastAPI) -> None:
|
||||
"""
|
||||
注册并执行插件 hooks
|
||||
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
|
||||
def run_setup_hook(plugin: str, module: Any) -> None:
|
||||
try:
|
||||
register_plugin_lifespan_hook(plugin, module)
|
||||
except Exception as e:
|
||||
log.exception(f'插件 {plugin} lifespan hooks 执行失败: {e}')
|
||||
raise PluginInjectError(f'插件 {plugin} lifespan hooks 执行失败:{e!s}') from e
|
||||
try:
|
||||
run_plugin_setup_hook(plugin, module, app)
|
||||
except Exception as e:
|
||||
log.exception(f'插件 {plugin} setup hooks 执行失败: {e}')
|
||||
raise PluginInjectError(f'插件 {plugin} setup hooks 执行失败:{e!s}') from e
|
||||
|
||||
for plugin, module in _get_plugin_hook_modules():
|
||||
run_setup_hook(plugin, module)
|
||||
|
||||
|
||||
def init_plugin_otel_hooks(app: FastAPI) -> None:
|
||||
"""
|
||||
初始化插件 OpenTelemetry hooks
|
||||
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
|
||||
def run_otel_hook(plugin: str, module: Any) -> None:
|
||||
try:
|
||||
run_plugin_otel_hook(plugin, module, app)
|
||||
except Exception as e:
|
||||
log.exception(f'插件 {plugin} otel hook 执行失败: {e}')
|
||||
raise PluginInjectError(f'插件 {plugin} otel hook 执行失败:{e!s}') from e
|
||||
|
||||
for plugin, module in _get_plugin_hook_modules():
|
||||
run_otel_hook(plugin, module)
|
||||
@@ -51,7 +51,7 @@ async def _append_env_example(plugin_path: anyio.Path) -> None:
|
||||
await f.write(new_content)
|
||||
|
||||
|
||||
async def install_zip_plugin(file: UploadFile | str) -> str: # noqa: C901
|
||||
async def install_zip_plugin(file: UploadFile | str) -> str: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
安装 ZIP 插件
|
||||
|
||||
@@ -209,7 +209,7 @@ def remove_plugin(plugin_dir: os.PathLike) -> None:
|
||||
"""
|
||||
import shutil
|
||||
|
||||
def _on_error(func, path, _exc_info) -> None: # noqa: ANN001
|
||||
def _on_error(func, path, _exc_info) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user