mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 05:02:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b412dceb9e | ||
|
|
1de4201b52 | ||
|
|
bcdaf3f3c9 | ||
|
|
565800d8cf | ||
|
|
966779bcd7 | ||
|
|
3f06baffed | ||
|
|
6de3eee4e0 | ||
|
|
e227eb1768 | ||
|
|
b6875a3c37 | ||
|
|
c521d2ef36 | ||
|
|
4d457b2215 | ||
|
|
95aebe1ceb | ||
|
|
f5af411339 | ||
|
|
d1fd5b6694 | ||
|
|
ab1f60120a | ||
|
|
220de51aad | ||
|
|
646a0ec2fc | ||
|
|
6f1c27786d | ||
|
|
093788acea | ||
|
|
4bab3c2cb0 | ||
|
|
b82f9e8c1c | ||
|
|
89f8fe141e | ||
|
|
ac19f8480f | ||
|
|
d397a0d985 | ||
|
|
eb27b5bfed | ||
|
|
08674ab95f | ||
|
|
fbdc3f2dc5 | ||
|
|
19d8a2964f | ||
|
|
a496932138 | ||
|
|
a6da0bfd41 | ||
|
|
ced2b8d269 | ||
|
|
f2b246089d | ||
|
|
0aa63ed6e6 | ||
|
|
c8ebd1e9f6 | ||
|
|
68d962f2f8 | ||
|
|
1a6aba6105 | ||
|
|
b2785dd46f | ||
|
|
dc73f09c55 | ||
|
|
b4d984cb4e | ||
|
|
4d19cd2f5a | ||
|
|
d9d0c839ed | ||
|
|
ca51c2f8c5 | ||
|
|
88b46c1b72 | ||
|
|
383620c899 | ||
|
|
f876162456 | ||
|
|
362a559236 | ||
|
|
8ae1a43581 | ||
|
|
ee849f0854 | ||
|
|
5c9a27cc16 | ||
|
|
1b68854b84 | ||
|
|
b73585ebd2 | ||
|
|
28a6228556 | ||
|
|
dfce2ca094 | ||
|
|
326bdf9a17 | ||
|
|
cbe4e5ebe0 | ||
|
|
2d666e375f | ||
|
|
ff40c41549 | ||
|
|
7619670521 | ||
|
|
85ba942f35 | ||
|
|
88f3173f8f | ||
|
|
fd87dfb25a | ||
|
|
ad7f725e4c | ||
|
|
2ef8810376 | ||
|
|
f0e6ed9985 | ||
|
|
809879997c | ||
|
|
b153b7ac18 | ||
|
|
308ccf71ae | ||
|
|
c8f6d9da72 | ||
|
|
3f2c3e44ba | ||
|
|
b5e2a9e591 | ||
|
|
cdabe91092 | ||
|
|
69a59993e3 | ||
|
|
b6defc671e | ||
|
|
8cbea57a8a | ||
|
|
7845e1ff49 | ||
|
|
2b76b64e85 | ||
|
|
dd3165a082 | ||
|
|
407d12760b | ||
|
|
f8b244c3a9 | ||
|
|
dfc715aad8 | ||
|
|
4bc5ba53e6 | ||
|
|
6b4fd93e5f | ||
|
|
0c5956f828 | ||
|
|
122d17ab57 | ||
|
|
4fb14e480a | ||
|
|
ea4ac6ff41 | ||
|
|
bb5bcdf430 | ||
|
|
aad9afa9bb | ||
|
|
a0e6e12d85 | ||
|
|
5d680ff93f | ||
|
|
866b0e6ba4 | ||
|
|
5a49d20c9b | ||
|
|
7641d5993a | ||
|
|
07d66137cb | ||
|
|
f918dcd9c2 | ||
|
|
ed4e312cb1 | ||
|
|
0b539f868e | ||
|
|
8899832de9 | ||
|
|
d844aa323a | ||
|
|
ef640b0b5c | ||
|
|
4d43c6a3ea | ||
|
|
407c848145 | ||
|
|
d58260a0c0 | ||
|
|
16bcaf963d | ||
|
|
551dc51c52 | ||
|
|
0729061438 | ||
|
|
636d867544 | ||
|
|
cf9e5dc4f4 | ||
|
|
2c0acb1103 | ||
|
|
425bc202c2 | ||
|
|
3b24dca935 | ||
|
|
98db5f4984 | ||
|
|
2b56168ad0 | ||
|
|
b9255815e1 | ||
|
|
93e2f0f5f9 | ||
|
|
437c026da9 | ||
|
|
316079045f | ||
|
|
5ae9f8b288 | ||
|
|
bef65e1baa | ||
|
|
87a3cd8a9c | ||
|
|
be7b742891 | ||
|
|
1f98797a60 | ||
|
|
8505e32757 | ||
|
|
91297d1ccd | ||
|
|
0ea213edfd | ||
|
|
292f5d7e48 | ||
|
|
ef871f3b5c | ||
|
|
6b17da59f8 | ||
|
|
86d580edd8 | ||
|
|
cdbe37de7d | ||
|
|
f46974ddd9 | ||
|
|
a14d4243d9 | ||
|
|
90588ebae8 | ||
|
|
00bc02f49c | ||
|
|
7e9ce2e691 | ||
|
|
81ec0f0bd9 | ||
|
|
37f9716c4f | ||
|
|
4540cd9338 | ||
|
|
3c39d2f2f3 | ||
|
|
1e01ffc3d9 | ||
|
|
f4b1c43b55 | ||
|
|
02254938b4 | ||
|
|
054ff0cab2 | ||
|
|
4f898a0264 | ||
|
|
70d90c2c01 | ||
|
|
964143b30d | ||
|
|
ce9f59e7d6 |
@@ -3,7 +3,11 @@ __pycache__/
|
||||
.github/
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
venv/
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.claude/
|
||||
.serena/
|
||||
.logs/
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
name: Release changelog
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_run:
|
||||
workflows: ['Check version']
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Check version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: check version
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Check the package version
|
||||
uses: samuelcolvin/check-python-version@v5
|
||||
with:
|
||||
version_file_path: backend/__init__.py
|
||||
@@ -7,3 +7,7 @@ venv/
|
||||
.python-version
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.claude/
|
||||
.serena/
|
||||
.agents/
|
||||
.logs/
|
||||
|
||||
+14
-2
@@ -1,13 +1,25 @@
|
||||
default_language_version:
|
||||
python: '>= 3.10'
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-json
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
|
||||
- repo: https://github.com/tombi-toml/tombi-pre-commit
|
||||
rev: v0.7.28
|
||||
hooks:
|
||||
- id: tombi-lint
|
||||
args: ["--offline"]
|
||||
- id: tombi-format
|
||||
args: ["--offline"]
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.14.0
|
||||
rev: v0.15.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
@@ -17,7 +29,7 @@ repos:
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.9.0
|
||||
rev: 0.10.2
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
- id: uv-export
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
line-length = 120
|
||||
preview = true
|
||||
fix = true
|
||||
unsafe-fixes = true
|
||||
show-fixes = true
|
||||
required-version = ">=0.13.0"
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"FAST",
|
||||
"ANN001",
|
||||
"ANN201",
|
||||
"ANN202",
|
||||
"ANN204",
|
||||
"ANN205",
|
||||
"ANN206",
|
||||
"ASYNC110",
|
||||
"ASYNC116",
|
||||
"ASYNC210",
|
||||
"ASYNC212",
|
||||
"ASYNC230",
|
||||
"ASYNC240",
|
||||
"ASYNC250",
|
||||
"ASYNC251",
|
||||
"S310",
|
||||
"FBT001",
|
||||
"FBT002",
|
||||
"B002",
|
||||
"B005",
|
||||
"B006",
|
||||
"B007",
|
||||
"B008",
|
||||
"B009",
|
||||
"B010",
|
||||
"B013",
|
||||
"B014",
|
||||
"B019",
|
||||
"B020",
|
||||
"B021",
|
||||
"B024",
|
||||
"B025",
|
||||
"B026",
|
||||
"B027",
|
||||
"B039",
|
||||
"COM",
|
||||
"C402",
|
||||
"C403",
|
||||
"C404",
|
||||
"C408",
|
||||
"C410",
|
||||
"C411",
|
||||
"C414",
|
||||
"C416",
|
||||
"C417",
|
||||
"C418",
|
||||
"C419",
|
||||
"C420",
|
||||
"DTZ",
|
||||
"EXE",
|
||||
"ISC001",
|
||||
"ISC002",
|
||||
"ISC003",
|
||||
"PIE",
|
||||
"PYI009",
|
||||
"PYI010",
|
||||
"PYI011",
|
||||
"PYI012",
|
||||
"PYI013",
|
||||
"PYI016",
|
||||
"PYI017",
|
||||
"PYI019",
|
||||
"PYI020",
|
||||
"PYI021",
|
||||
"PYI024",
|
||||
"PYI026",
|
||||
"PYI030",
|
||||
"PYI033",
|
||||
"PYI034",
|
||||
"PYI036",
|
||||
"PYI041",
|
||||
"PYI042",
|
||||
"PYI055",
|
||||
"PYI061",
|
||||
"PYI062",
|
||||
"PYI063",
|
||||
"Q001",
|
||||
"Q002",
|
||||
"RSE102",
|
||||
"RET501",
|
||||
"RET505",
|
||||
"RET506",
|
||||
"RET507",
|
||||
"RET508",
|
||||
"SIM101",
|
||||
"SIM102",
|
||||
"SIM103",
|
||||
"SIM107",
|
||||
"SIM108",
|
||||
"SIM109",
|
||||
"SIM110",
|
||||
"SIM114",
|
||||
"SIM115",
|
||||
"SIM201",
|
||||
"SIM202",
|
||||
"SIM210",
|
||||
"SIM211",
|
||||
"SIM212",
|
||||
"SIM300",
|
||||
"SIM401",
|
||||
"SIM910",
|
||||
"TID252",
|
||||
"TC",
|
||||
"FLY",
|
||||
"I",
|
||||
"C901",
|
||||
"N",
|
||||
"PERF",
|
||||
"E",
|
||||
"W",
|
||||
"D404",
|
||||
"D417",
|
||||
"D419",
|
||||
"F",
|
||||
"PGH",
|
||||
"PLC1901",
|
||||
"UP",
|
||||
"FURB",
|
||||
"RUF",
|
||||
"TRY",
|
||||
]
|
||||
ignore = [
|
||||
"COM812",
|
||||
"PGH003",
|
||||
"RUF001",
|
||||
"RUF002",
|
||||
"RUF003",
|
||||
"RUF006",
|
||||
"RUF012",
|
||||
"TRY400",
|
||||
"TRY003",
|
||||
"TRY301"
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"**/model/*.py" = ["TC003"]
|
||||
"backend/common/socketio/server.py" = ["ANN001"]
|
||||
"backend/common/exception/exception_handler.py" = ["ANN202","RUF029"]
|
||||
|
||||
[lint.flake8-pytest-style]
|
||||
parametrize-names-type = "list"
|
||||
parametrize-values-row-type = "list"
|
||||
parametrize-values-type = "list"
|
||||
|
||||
[lint.flake8-quotes]
|
||||
inline-quotes = "single"
|
||||
|
||||
[lint.flake8-type-checking]
|
||||
runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"]
|
||||
|
||||
[lint.flake8-unused-arguments]
|
||||
ignore-variadic-names = true
|
||||
|
||||
[lint.isort]
|
||||
case-sensitive = true
|
||||
lines-between-types = 1
|
||||
order-by-type = true
|
||||
|
||||
[lint.pylint]
|
||||
allow-dunder-method-names = ["__tablename__", "__table_args__"]
|
||||
|
||||
[format]
|
||||
docstring-code-format = true
|
||||
preview = true
|
||||
quote-style = "single"
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "FBA Plugin Manifest Schema",
|
||||
"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"],
|
||||
"additionalProperties": false,
|
||||
|
||||
"properties": {
|
||||
"plugin": {
|
||||
"type": "object",
|
||||
"description": "Plugin metadata",
|
||||
"required": ["summary", "version", "description", "author"],
|
||||
"additionalProperties": false,
|
||||
"x-tombi-table-keys-order": "schema",
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Icon path (plugin repository icon path or URL)"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
"description": "Brief summary (1-100 characters)"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"description": "Plugin version (semver format: x.y.z)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "Detailed description (1-500 characters)"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 50,
|
||||
"description": "Plugin author (1-50 characters)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "Plugin tags for categorization (will be required in next major version)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["ai", "mcp", "agent", "auth", "storage", "notification", "task", "payment", "other"]
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
},
|
||||
"database": {
|
||||
"type": "array",
|
||||
"description": "Supported databases (will be required in next major version)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql"]
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"app": {
|
||||
"type": "object",
|
||||
"description": "Application configuration. For app-level plugins: use 'router'. For extend-level plugins: use 'extend'.",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"x-tombi-table-keys-order": "schema",
|
||||
"properties": {
|
||||
"extend": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Parent app folder name (for extension-level plugins)"
|
||||
},
|
||||
"router": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Router instances (for application-level plugins)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"x-tombi-array-values-order": "version-sort"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": "Plugin base configuration (hot-pluggable, uppercase keys only)",
|
||||
"x-tombi-additional-key-label": "SETTING_NAME",
|
||||
"x-tombi-table-keys-order": "ascending",
|
||||
"propertyNames": {
|
||||
"pattern": "^[A-Z][A-Z0-9_]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "number" },
|
||||
{ "type": "boolean" }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"api": {
|
||||
"type": "object",
|
||||
"description": "API endpoint configurations (for extension-level plugins). The key (e.g., 'xxx' in api.xxx) corresponds to the API filename without extension.",
|
||||
"x-tombi-additional-key-label": "api_filename",
|
||||
"x-tombi-table-keys-order": "ascending",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["prefix", "tags"],
|
||||
"additionalProperties": false,
|
||||
"x-tombi-table-keys-order": {"properties": "schema"},
|
||||
"properties": {
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^/[a-zA-Z0-9_/-]*$",
|
||||
"description": "URL prefix for the API (must start with '/', allowed chars: a-z, A-Z, 0-9, _, -, /)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "OpenAPI tags for Swagger documentation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+276
-1
@@ -1,3 +1,268 @@
|
||||
<a id="v1.12.3"></a>
|
||||
# [v1.12.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.3) - 2026-01-13
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.12.2 by [@wu-clan](https://github.com/wu-clan) in [#995](https://github.com/fastapi-practices/fastapi_best_architecture/pull/995)
|
||||
* Update login log request header column length by [@wu-clan](https://github.com/wu-clan) in [#996](https://github.com/fastapi-practices/fastapi_best_architecture/pull/996)
|
||||
* Fix opera log non-json data overload by [@shj366](https://github.com/shj366) in [#998](https://github.com/fastapi-practices/fastapi_best_architecture/pull/998)
|
||||
* Remove the opera log desensitization asynchronous by [@wu-clan](https://github.com/wu-clan) in [#999](https://github.com/fastapi-practices/fastapi_best_architecture/pull/999)
|
||||
* Update redis and server monitor implementations by [@wu-clan](https://github.com/wu-clan) in [#1000](https://github.com/fastapi-practices/fastapi_best_architecture/pull/1000)
|
||||
* Optimize definitions of multiple utility functions by [@wu-clan](https://github.com/wu-clan) in [#1001](https://github.com/fastapi-practices/fastapi_best_architecture/pull/1001)
|
||||
* Update code generation part file naming by [@wu-clan](https://github.com/wu-clan) in [#1002](https://github.com/fastapi-practices/fastapi_best_architecture/pull/1002)
|
||||
* Update nickname generation when create user by [@wu-clan](https://github.com/wu-clan) in [#1004](https://github.com/fastapi-practices/fastapi_best_architecture/pull/1004)
|
||||
* Update the plugin dependency install method by [@wu-clan](https://github.com/wu-clan) in [#1007](https://github.com/fastapi-practices/fastapi_best_architecture/pull/1007)
|
||||
* Update i18n language storage and loading by [@wu-clan](https://github.com/wu-clan) in [#1008](https://github.com/fastapi-practices/fastapi_best_architecture/pull/1008)
|
||||
|
||||
## New Contributors
|
||||
* [@shj366](https://github.com/shj366) made their first contribution in [#998](https://github.com/fastapi-practices/fastapi_best_architecture/pull/998)
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.2...v1.12.3
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/shj366"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fshj366.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@shj366"></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.12.3]
|
||||
|
||||
|
||||
<a id="v1.12.2"></a>
|
||||
# [v1.12.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.2) - 2026-01-07
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.12.1 by [@wu-clan](https://github.com/wu-clan) in [#983](https://github.com/fastapi-practices/fastapi_best_architecture/pull/983)
|
||||
* Fix environment variable file auto init by [@wu-clan](https://github.com/wu-clan) in [#985](https://github.com/fastapi-practices/fastapi_best_architecture/pull/985)
|
||||
* Simplify the desensitization of operation log data by [@wu-clan](https://github.com/wu-clan) in [#987](https://github.com/fastapi-practices/fastapi_best_architecture/pull/987)
|
||||
* Remove invalid configs of operation log by [@wu-clan](https://github.com/wu-clan) in [#988](https://github.com/fastapi-practices/fastapi_best_architecture/pull/988)
|
||||
* Fix operation log queue status management by [@wu-clan](https://github.com/wu-clan) in [#989](https://github.com/fastapi-practices/fastapi_best_architecture/pull/989)
|
||||
* Fix SQL scripts error in config plugin by [@wu-clan](https://github.com/wu-clan) in [#991](https://github.com/fastapi-practices/fastapi_best_architecture/pull/991)
|
||||
* Fix the key of the refresh token removed by [@wu-clan](https://github.com/wu-clan) in [#993](https://github.com/fastapi-practices/fastapi_best_architecture/pull/993)
|
||||
* Remove Linux Do OAuth2 login by [@wu-clan](https://github.com/wu-clan) in [#994](https://github.com/fastapi-practices/fastapi_best_architecture/pull/994)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.1...v1.12.2
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.12.2]
|
||||
|
||||
|
||||
<a id="v1.12.1"></a>
|
||||
# [v1.12.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.1) - 2025-12-31
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.12.0 by [@wu-clan](https://github.com/wu-clan) in [#963](https://github.com/fastapi-practices/fastapi_best_architecture/pull/963)
|
||||
* Update Grafana security and user default config by [@wu-clan](https://github.com/wu-clan) in [#964](https://github.com/fastapi-practices/fastapi_best_architecture/pull/964)
|
||||
* Rename the pre start script to migrate by [@wu-clan](https://github.com/wu-clan) in [#965](https://github.com/fastapi-practices/fastapi_best_architecture/pull/965)
|
||||
* Add code generation and notice SQL scripts by [@wu-clan](https://github.com/wu-clan) in [#966](https://github.com/fastapi-practices/fastapi_best_architecture/pull/966)
|
||||
* Fix support for special character passwords by [@wu-clan](https://github.com/wu-clan) in [#968](https://github.com/fastapi-practices/fastapi_best_architecture/pull/968)
|
||||
* Add an independent contribution document by [@wu-clan](https://github.com/wu-clan) in [#971](https://github.com/fastapi-practices/fastapi_best_architecture/pull/971)
|
||||
* Fix i18n target language error when concurrent by [@wu-clan](https://github.com/wu-clan) in [#970](https://github.com/fastapi-practices/fastapi_best_architecture/pull/970)
|
||||
* Add observability instrument for redis client by [@wu-clan](https://github.com/wu-clan) in [#972](https://github.com/fastapi-practices/fastapi_best_architecture/pull/972)
|
||||
* Add OTEL semantic specification metrics config by [@wu-clan](https://github.com/wu-clan) in [#973](https://github.com/fastapi-practices/fastapi_best_architecture/pull/973)
|
||||
* Fix case where the user agent was empty by [@wu-clan](https://github.com/wu-clan) in [#976](https://github.com/fastapi-practices/fastapi_best_architecture/pull/976)
|
||||
* Optimize login log database session calls by [@wu-clan](https://github.com/wu-clan) in [#977](https://github.com/fastapi-practices/fastapi_best_architecture/pull/977)
|
||||
* Add the auto init project CLI command by [@wu-clan](https://github.com/wu-clan) in [#978](https://github.com/fastapi-practices/fastapi_best_architecture/pull/978)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#979](https://github.com/fastapi-practices/fastapi_best_architecture/pull/979)
|
||||
* Add observability instrument for httpx request by [@wu-clan](https://github.com/wu-clan) in [#980](https://github.com/fastapi-practices/fastapi_best_architecture/pull/980)
|
||||
* Update git and docker ignore files by [@wu-clan](https://github.com/wu-clan) in [#981](https://github.com/fastapi-practices/fastapi_best_architecture/pull/981)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.0...v1.12.1
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.12.1]
|
||||
|
||||
|
||||
<a id="v1.12.0"></a>
|
||||
# [v1.12.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.0) - 2025-12-15
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.11.2 by [@wu-clan](https://github.com/wu-clan) in [#942](https://github.com/fastapi-practices/fastapi_best_architecture/pull/942)
|
||||
* Update celery related docker container independence by [@wu-clan](https://github.com/wu-clan) in [#943](https://github.com/fastapi-practices/fastapi_best_architecture/pull/943)
|
||||
* Fix super value in update user permissions by [@wu-clan](https://github.com/wu-clan) in [#948](https://github.com/fastapi-practices/fastapi_best_architecture/pull/948)
|
||||
* Optimize data permission logic and usage by [@wu-clan](https://github.com/wu-clan) in [#947](https://github.com/fastapi-practices/fastapi_best_architecture/pull/947)
|
||||
* Update pre-commit to prek in pre-commit script by [@wu-clan](https://github.com/wu-clan) in [#949](https://github.com/fastapi-practices/fastapi_best_architecture/pull/949)
|
||||
* Optimize the coupling of user social plugin by [@wu-clan](https://github.com/wu-clan) in [#950](https://github.com/fastapi-practices/fastapi_best_architecture/pull/950)
|
||||
* Add the database primary key mode config by [@wu-clan](https://github.com/wu-clan) in [#953](https://github.com/fastapi-practices/fastapi_best_architecture/pull/953)
|
||||
* Optimize the coupling of code generation CLI by [@wu-clan](https://github.com/wu-clan) in [#951](https://github.com/fastapi-practices/fastapi_best_architecture/pull/951)
|
||||
* Add CLI init project database support by [@wu-clan](https://github.com/wu-clan) in [#952](https://github.com/fastapi-practices/fastapi_best_architecture/pull/952)
|
||||
* Update the init project database CLI to subcommand by [@wu-clan](https://github.com/wu-clan) in [#954](https://github.com/fastapi-practices/fastapi_best_architecture/pull/954)
|
||||
* Fix CLI command for code generation by [@wu-clan](https://github.com/wu-clan) in [#956](https://github.com/fastapi-practices/fastapi_best_architecture/pull/956)
|
||||
* Fix the IP address in the request log by [@wuyuemushi](https://github.com/wuyuemushi) in [#959](https://github.com/fastapi-practices/fastapi_best_architecture/pull/959)
|
||||
* Add the Grafana observability suite by [@wu-clan](https://github.com/wu-clan) in [#961](https://github.com/fastapi-practices/fastapi_best_architecture/pull/961)
|
||||
* Update the version number to 1.12.0 by [@wu-clan](https://github.com/wu-clan) in [#962](https://github.com/fastapi-practices/fastapi_best_architecture/pull/962)
|
||||
|
||||
## New Contributors
|
||||
* [@wuyuemushi](https://github.com/wuyuemushi) made their first contribution in [#959](https://github.com/fastapi-practices/fastapi_best_architecture/pull/959)
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
<a href="https://github.com/wuyuemushi"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwuyuemushi.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wuyuemushi"></a>
|
||||
|
||||
[Changes][v1.12.0]
|
||||
|
||||
|
||||
<a id="v1.11.2"></a>
|
||||
# [v1.11.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.2) - 2025-11-28
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.11.1 by [@wu-clan](https://github.com/wu-clan) in [#923](https://github.com/fastapi-practices/fastapi_best_architecture/pull/923)
|
||||
* Fix typos in data rule sql scripts by [@wu-clan](https://github.com/wu-clan) in [#926](https://github.com/fastapi-practices/fastapi_best_architecture/pull/926)
|
||||
* Restore captcha uuid naming in the login params by [@wu-clan](https://github.com/wu-clan) in [#928](https://github.com/fastapi-practices/fastapi_best_architecture/pull/928)
|
||||
* Add distributed deployment support for snowflake ID by [@downdawn](https://github.com/downdawn) in [#927](https://github.com/fastapi-practices/fastapi_best_architecture/pull/927)
|
||||
* Add env reqs for plugin install and uninstall by [@wu-clan](https://github.com/wu-clan) in [#929](https://github.com/fastapi-practices/fastapi_best_architecture/pull/929)
|
||||
* Optimize the use of some LRU caches by [@wu-clan](https://github.com/wu-clan) in [#932](https://github.com/fastapi-practices/fastapi_best_architecture/pull/932)
|
||||
* Update the i18n language file init location by [@wu-clan](https://github.com/wu-clan) in [#934](https://github.com/fastapi-practices/fastapi_best_architecture/pull/934)
|
||||
* Fix get column types in code generation by [@wu-clan](https://github.com/wu-clan) in [#935](https://github.com/fastapi-practices/fastapi_best_architecture/pull/935)
|
||||
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#936](https://github.com/fastapi-practices/fastapi_best_architecture/pull/936)
|
||||
* Update the files interface filename to file by [@wu-clan](https://github.com/wu-clan) in [#937](https://github.com/fastapi-practices/fastapi_best_architecture/pull/937)
|
||||
* Update task application interface definitions by [@wu-clan](https://github.com/wu-clan) in [#938](https://github.com/fastapi-practices/fastapi_best_architecture/pull/938)
|
||||
* Update code generation interface definitions by [@wu-clan](https://github.com/wu-clan) in [#939](https://github.com/fastapi-practices/fastapi_best_architecture/pull/939)
|
||||
* Update the fba run CLI command output by [@wu-clan](https://github.com/wu-clan) in [#941](https://github.com/fastapi-practices/fastapi_best_architecture/pull/941)
|
||||
* Update the version number to 1.11.2 by [@wu-clan](https://github.com/wu-clan) in [#940](https://github.com/fastapi-practices/fastapi_best_architecture/pull/940)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.11.2]
|
||||
|
||||
|
||||
<a id="v1.11.1"></a>
|
||||
# [v1.11.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.1) - 2025-11-16
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.11.0 by [@wu-clan](https://github.com/wu-clan) in [#917](https://github.com/fastapi-practices/fastapi_best_architecture/pull/917)
|
||||
* Fix missing table in alembic migration by [@wu-clan](https://github.com/wu-clan) in [#920](https://github.com/fastapi-practices/fastapi_best_architecture/pull/920)
|
||||
* Add user social binding and unbinding by [@wu-clan](https://github.com/wu-clan) in [#919](https://github.com/fastapi-practices/fastapi_best_architecture/pull/919)
|
||||
* Fix the user list query serialization by [@linrong](https://github.com/linrong) in [#921](https://github.com/fastapi-practices/fastapi_best_architecture/pull/921)
|
||||
* Update user and login security configs by [@wu-clan](https://github.com/wu-clan) in [#922](https://github.com/fastapi-practices/fastapi_best_architecture/pull/922)
|
||||
|
||||
## New Contributors
|
||||
* [@linrong](https://github.com/linrong) made their first contribution in [#921](https://github.com/fastapi-practices/fastapi_best_architecture/pull/921)
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.0...v1.11.1
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/linrong"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Flinrong.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@linrong"></a>
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.11.1]
|
||||
|
||||
|
||||
<a id="v1.11.0"></a>
|
||||
# [v1.11.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.0) - 2025-11-12
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.10.4 by [@wu-clan](https://github.com/wu-clan) in [#916](https://github.com/fastapi-practices/fastapi_best_architecture/pull/916)
|
||||
* Refactor foreign keys and relationships to pure logic by [@wu-clan](https://github.com/wu-clan) in [#901](https://github.com/fastapi-practices/fastapi_best_architecture/pull/901)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.4...v1.11.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.11.0]
|
||||
|
||||
|
||||
<a id="v1.10.4"></a>
|
||||
# [v1.10.4](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.4) - 2025-11-12
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.10.3 by [@wu-clan](https://github.com/wu-clan) in [#895](https://github.com/fastapi-practices/fastapi_best_architecture/pull/895)
|
||||
* Bump fastapi oauth2 from 0.0.1 to 0.0.2 by [@wu-clan](https://github.com/wu-clan) in [#896](https://github.com/fastapi-practices/fastapi_best_architecture/pull/896)
|
||||
* Update the interface timing accuracy in log by [@wu-clan](https://github.com/wu-clan) in [#897](https://github.com/fastapi-practices/fastapi_best_architecture/pull/897)
|
||||
* Optimize redis batch get and delete operations by [@wu-clan](https://github.com/wu-clan) in [#899](https://github.com/fastapi-practices/fastapi_best_architecture/pull/899)
|
||||
* Update the time column type in the task by [@wu-clan](https://github.com/wu-clan) in [#900](https://github.com/fastapi-practices/fastapi_best_architecture/pull/900)
|
||||
* Add the user social independent enum file by [@wu-clan](https://github.com/wu-clan) in [#902](https://github.com/fastapi-practices/fastapi_best_architecture/pull/902)
|
||||
* Optimize the request params of the service layer by [@wu-clan](https://github.com/wu-clan) in [#903](https://github.com/fastapi-practices/fastapi_best_architecture/pull/903)
|
||||
* Optimize the data permission condition build by [@wu-clan](https://github.com/wu-clan) in [#904](https://github.com/fastapi-practices/fastapi_best_architecture/pull/904)
|
||||
* Fix response status codes in the request logs by [@wu-clan](https://github.com/wu-clan) in [#905](https://github.com/fastapi-practices/fastapi_best_architecture/pull/905)
|
||||
* Add dept validation to user updates by [@wu-clan](https://github.com/wu-clan) in [#906](https://github.com/fastapi-practices/fastapi_best_architecture/pull/906)
|
||||
* Update the version number to 1.10.4 by [@wu-clan](https://github.com/wu-clan) in [#907](https://github.com/fastapi-practices/fastapi_best_architecture/pull/907)
|
||||
* Fix typo in the data permission prompt by [@wu-clan](https://github.com/wu-clan) in [#909](https://github.com/fastapi-practices/fastapi_best_architecture/pull/909)
|
||||
* Fix user cache cleanup when operating data rules by [@wu-clan](https://github.com/wu-clan) in [#910](https://github.com/fastapi-practices/fastapi_best_architecture/pull/910)
|
||||
* Fix create and delete department validations by [@wu-clan](https://github.com/wu-clan) in [#911](https://github.com/fastapi-practices/fastapi_best_architecture/pull/911)
|
||||
* Fix the user menu sidebar parsing by [@wu-clan](https://github.com/wu-clan) in [#912](https://github.com/fastapi-practices/fastapi_best_architecture/pull/912)
|
||||
* Add user social unbinding account interface by [@wu-clan](https://github.com/wu-clan) in [#913](https://github.com/fastapi-practices/fastapi_best_architecture/pull/913)
|
||||
* Fix user cache cleanup when updating data scope by [@wu-clan](https://github.com/wu-clan) in [#915](https://github.com/fastapi-practices/fastapi_best_architecture/pull/915)
|
||||
* Fix dept and menu table subqueries by [@wu-clan](https://github.com/wu-clan) in [#914](https://github.com/fastapi-practices/fastapi_best_architecture/pull/914)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.10.4]
|
||||
|
||||
|
||||
<a id="v1.10.3"></a>
|
||||
# [v1.10.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.3) - 2025-10-30
|
||||
|
||||
## What's Changed
|
||||
* Update changelog for v1.10.2 by [@wu-clan](https://github.com/wu-clan) in [#873](https://github.com/fastapi-practices/fastapi_best_architecture/pull/873)
|
||||
* Fix docker default database env variables by [@wu-clan](https://github.com/wu-clan) in [#874](https://github.com/fastapi-practices/fastapi_best_architecture/pull/874)
|
||||
* Add port for fba sever in docker compose by [@wu-clan](https://github.com/wu-clan) in [#875](https://github.com/fastapi-practices/fastapi_best_architecture/pull/875)
|
||||
* Update the container naming in docker scripts by [@wu-clan](https://github.com/wu-clan) in [#876](https://github.com/fastapi-practices/fastapi_best_architecture/pull/876)
|
||||
* Fix the httpurl type compatibility with postgresql by [@wu-clan](https://github.com/wu-clan) in [#877](https://github.com/fastapi-practices/fastapi_best_architecture/pull/877)
|
||||
* Update the default length of user email column by [@wu-clan](https://github.com/wu-clan) in [#878](https://github.com/fastapi-practices/fastapi_best_architecture/pull/878)
|
||||
* Update the serializer of httpurl type by [@wu-clan](https://github.com/wu-clan) in [#879](https://github.com/fastapi-practices/fastapi_best_architecture/pull/879)
|
||||
* Fix the OAuth2 link acquisition in HTTPS by [@wu-clan](https://github.com/wu-clan) in [#881](https://github.com/fastapi-practices/fastapi_best_architecture/pull/881)
|
||||
* Add Google OAuth2 callback to opera log exclusion by [@wu-clan](https://github.com/wu-clan) in [#882](https://github.com/fastapi-practices/fastapi_best_architecture/pull/882)
|
||||
* Update the length style of the model columns by [@wu-clan](https://github.com/wu-clan) in [#883](https://github.com/fastapi-practices/fastapi_best_architecture/pull/883)
|
||||
* Bump dependencies to the latest version by [@wu-clan](https://github.com/wu-clan) in [#890](https://github.com/fastapi-practices/fastapi_best_architecture/pull/890)
|
||||
* Fix import in code generation api template by [@wu-clan](https://github.com/wu-clan) in [#891](https://github.com/fastapi-practices/fastapi_best_architecture/pull/891)
|
||||
* Fix celery compatibility with psycopg version by [@wu-clan](https://github.com/wu-clan) in [#892](https://github.com/fastapi-practices/fastapi_best_architecture/pull/892)
|
||||
* Fix the venv pip availability in Linux by [@wu-clan](https://github.com/wu-clan) in [#893](https://github.com/fastapi-practices/fastapi_best_architecture/pull/893)
|
||||
* Add the celery rabbitmq vhost config by [@wu-clan](https://github.com/wu-clan) in [#894](https://github.com/fastapi-practices/fastapi_best_architecture/pull/894)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.2...v1.10.3
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.10.3]
|
||||
|
||||
|
||||
<a id="v1.10.2"></a>
|
||||
# [v1.10.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.2) - 2025-10-21
|
||||
|
||||
## What's Changed
|
||||
* Update the changelog for v1.10.1 by [@wu-clan](https://github.com/wu-clan) in [#869](https://github.com/fastapi-practices/fastapi_best_architecture/pull/869)
|
||||
* Bump rtoml and uvicorn to support python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#871](https://github.com/fastapi-practices/fastapi_best_architecture/pull/871)
|
||||
* Optimize sqlalchemy types to simplify compatibility by [@wu-clan](https://github.com/wu-clan) in [#870](https://github.com/fastapi-practices/fastapi_best_architecture/pull/870)
|
||||
* Bump fastapi to remove warning for python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#872](https://github.com/fastapi-practices/fastapi_best_architecture/pull/872)
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.1...v1.10.2
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||
|
||||
[Changes][v1.10.2]
|
||||
|
||||
|
||||
<a id="v1.10.1"></a>
|
||||
# [v1.10.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.1) - 2025-10-18
|
||||
|
||||
@@ -1039,6 +1304,16 @@
|
||||
[Changes][v1.0.0]
|
||||
|
||||
|
||||
[v1.12.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.2...v1.12.3
|
||||
[v1.12.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.1...v1.12.2
|
||||
[v1.12.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.0...v1.12.1
|
||||
[v1.12.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
|
||||
[v1.11.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
|
||||
[v1.11.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.0...v1.11.1
|
||||
[v1.11.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.4...v1.11.0
|
||||
[v1.10.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
|
||||
[v1.10.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.2...v1.10.3
|
||||
[v1.10.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.1...v1.10.2
|
||||
[v1.10.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.0...v1.10.1
|
||||
[v1.10.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.9.0...v1.10.0
|
||||
[v1.9.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.3...v1.9.0
|
||||
@@ -1067,4 +1342,4 @@
|
||||
[v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
|
||||
[v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0
|
||||
|
||||
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.0 -->
|
||||
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
## Backend
|
||||
|
||||
1. Prerequisites
|
||||
|
||||
- Python >= 3.10
|
||||
- Git
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Fork this repository to your GitHub account
|
||||
|
||||
2. Installation and setup
|
||||
|
||||
Clone your forked repository:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/<your account>/fastapi_best_architecture.git
|
||||
```
|
||||
|
||||
Go to the root directory of the project, open the terminal, and run the following command:
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. Checkout
|
||||
|
||||
Checkout a new branch and make your changes
|
||||
|
||||
```shell
|
||||
git checkout -b your-new-feature-branch
|
||||
```
|
||||
|
||||
4. Format and Lint
|
||||
|
||||
Auto-formatting and lint via `prek`
|
||||
|
||||
```shell
|
||||
prek run --all-files
|
||||
```
|
||||
|
||||
5. Commit and push
|
||||
|
||||
Commit your changes and push your branch to the GitHub.
|
||||
|
||||
6. PR
|
||||
|
||||
Create a PR via GitHub
|
||||
|
||||
## Scripts
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> The following script may not apply to the Windows platform
|
||||
>
|
||||
> It is recommended to execute under the backend directory, and chmod authorization may be required
|
||||
|
||||
- `migrate.sh`: Perform automatic database migration
|
||||
|
||||
- `scripts/format.sh`: Perform ruff format check
|
||||
|
||||
- `scripts/lint.sh`: Perform prek formatting
|
||||
|
||||
- `scripts/export.sh`: Execute uv export dependency package
|
||||
+40
-18
@@ -1,5 +1,5 @@
|
||||
# Select the image to build based on SERVER_TYPE, defaulting to fastapi_server, or docker-compose build args
|
||||
ARG SERVER_TYPE=fastapi_server
|
||||
# Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args
|
||||
ARG SERVER_TYPE=fba_server
|
||||
|
||||
# === Python environment from uv ===
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
|
||||
@@ -22,47 +22,69 @@ ENV UV_COMPILE_BYTECODE=1 \
|
||||
|
||||
# Install dependencies with cache
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-default-groups --group server
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-default-groups --group server --no-install-project
|
||||
|
||||
# === Runtime base server image ===
|
||||
FROM python:3.10-slim AS base_server
|
||||
FROM python:3.10-slim-bookworm 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 supervisor \
|
||||
&& 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
|
||||
|
||||
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
WORKDIR /fba/backend
|
||||
COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
# === FastAPI server image ===
|
||||
FROM base_server AS fastapi_server
|
||||
FROM base_server AS fba_server
|
||||
|
||||
COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/
|
||||
COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fastapi_server
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# === Celery server image ===
|
||||
FROM base_server AS celery
|
||||
# === Celery Worker image ===
|
||||
FROM base_server AS fba_celery_worker
|
||||
|
||||
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
|
||||
COPY deploy/backend/supervisor/fba_celery_worker.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/celery
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
RUN chmod +x celery-start.sh
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# === Celery Beat image ===
|
||||
FROM base_server AS fba_celery_beat
|
||||
|
||||
COPY deploy/backend/supervisor/fba_celery_beat.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# === Celery Flower image ===
|
||||
FROM base_server AS fba_celery_flower
|
||||
|
||||
COPY deploy/backend/supervisor/fba_celery_flower.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
EXPOSE 8555
|
||||
|
||||
CMD ["./celery-start.sh"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# Build image
|
||||
FROM ${SERVER_TYPE}
|
||||
|
||||
@@ -13,8 +13,6 @@ REDIS_PASSWORD=''
|
||||
REDIS_DATABASE=0
|
||||
# Token
|
||||
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
|
||||
# Opera Log
|
||||
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
|
||||
# [ App ] task
|
||||
# Celery
|
||||
CELERY_BROKER_REDIS_DATABASE=1
|
||||
@@ -28,8 +26,6 @@ OAUTH2_GITHUB_CLIENT_ID='test'
|
||||
OAUTH2_GITHUB_CLIENT_SECRET='test'
|
||||
OAUTH2_GOOGLE_CLIENT_ID='test'
|
||||
OAUTH2_GOOGLE_CLIENT_SECRET='test'
|
||||
OAUTH2_LINUX_DO_CLIENT_ID='test'
|
||||
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
|
||||
# [ Plugin ] email
|
||||
EMAIL_USERNAME=''
|
||||
EMAIL_PASSWORD=''
|
||||
|
||||
@@ -16,68 +16,3 @@
|
||||
```shell
|
||||
docker run -d -p 8000:8000 --name fba_server fba_backend_independent
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Prerequisites
|
||||
|
||||
- Python >= 3.10
|
||||
- Git
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Fork this repository to your GitHub account
|
||||
|
||||
2. Installation and setup
|
||||
|
||||
Clone your forked repository:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/<your account>/fastapi_best_architecture.git
|
||||
```
|
||||
|
||||
Go to the root directory of the project, open the terminal, and run the following command:
|
||||
|
||||
```sh
|
||||
uv sync --frozen
|
||||
```
|
||||
|
||||
3. Checkout
|
||||
|
||||
Checkout a new branch and make your changes
|
||||
|
||||
```shell
|
||||
git checkout -b your-new-feature-branch
|
||||
```
|
||||
|
||||
4. Format and Lint
|
||||
|
||||
Auto-formatting and lint via `pre-commit`
|
||||
|
||||
```shell
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
5. Commit and push
|
||||
|
||||
Commit your changes and push your branch to the GitHub.
|
||||
|
||||
6. PR
|
||||
|
||||
Create a PR via GitHub
|
||||
|
||||
## Scripts
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> The following script may not apply to the Windows platform
|
||||
>
|
||||
> It is recommended to execute under the backend directory, and chmod authorization may be required
|
||||
|
||||
- `pre_start.sh`: Perform automatic database migration
|
||||
|
||||
- `celery-start.sh`: For celery docker script, implementation is not recommended
|
||||
|
||||
- `scripts/format.sh`: Perform ruff format check
|
||||
|
||||
- `scripts/lint.sh`: Perform pre-commit formatting
|
||||
|
||||
- `scripts/export.sh`: Execute uv export dependency package
|
||||
|
||||
+14
-4
@@ -1,7 +1,17 @@
|
||||
from backend.common.i18n import i18n
|
||||
import sqlalchemy as sa
|
||||
|
||||
__version__ = '1.10.2'
|
||||
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
|
||||
|
||||
|
||||
# 初始化 i18n
|
||||
i18n.load_locales()
|
||||
__version__ = '1.13.0'
|
||||
|
||||
@@ -1,84 +1,3 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d_%%(minute).2d_%%(second).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
+13
-17
@@ -8,36 +8,32 @@ from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from backend.app import get_app_models
|
||||
from backend.common.model import MappedBase
|
||||
from backend.core import path_conf
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.database.db import SQLALCHEMY_DATABASE_URL
|
||||
from backend.plugin.tools import get_plugin_models
|
||||
|
||||
# import models
|
||||
for cls in get_app_models() + get_plugin_models():
|
||||
class_name = cls.__name__
|
||||
if class_name not in globals():
|
||||
globals()[class_name] = cls
|
||||
|
||||
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
|
||||
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
alembic_config = context.config
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if alembic_config.config_file_name is not None:
|
||||
fileConfig(alembic_config.config_file_name)
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(BASE_PATH / config.config_file_name)
|
||||
|
||||
# model's MetaData object
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = MappedBase.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
alembic_config.set_main_option(
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%'),
|
||||
)
|
||||
@@ -55,7 +51,7 @@ def run_migrations_offline() -> None:
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = alembic_config.get_main_option('sqlalchemy.url')
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
@@ -71,9 +67,9 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
# 当迁移无变化时,不生成迁移记录
|
||||
def process_revision_directives(context, revision, directives) -> None: # noqa: ANN001
|
||||
if alembic_config.cmd_opts.autogenerate:
|
||||
"""当迁移无变化时,不生成迁移记录"""
|
||||
if config.cmd_opts.autogenerate:
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
@@ -99,7 +95,7 @@ async def run_async_migrations() -> None:
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
alembic_config.get_section(alembic_config.config_ini_section, {}),
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import os.path
|
||||
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.utils.import_parse import get_model_objects
|
||||
|
||||
|
||||
def get_app_models() -> list[type]:
|
||||
"""获取 app 所有模型类"""
|
||||
app_path = BASE_PATH / 'app'
|
||||
list_dirs = os.listdir(app_path)
|
||||
|
||||
apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
|
||||
|
||||
objs = []
|
||||
for app in apps:
|
||||
module_path = f'backend.app.{app}.model'
|
||||
obj = get_model_objects(module_path)
|
||||
if obj:
|
||||
objs.extend(obj)
|
||||
|
||||
return objs
|
||||
|
||||
|
||||
# import all app models for auto create db tables
|
||||
for cls in get_app_models():
|
||||
class_name = cls.__name__
|
||||
if class_name not in globals():
|
||||
globals()[class_name] = cls
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.security import HTTPBasicCredentials
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
from pyrate_limiter import Duration, Rate
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken
|
||||
@@ -11,6 +11,7 @@ from backend.app.admin.service.auth_service import auth_service
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
from backend.utils.limiter import RateLimiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -20,14 +21,14 @@ async def login_swagger(
|
||||
db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
|
||||
) -> GetSwaggerToken:
|
||||
token, user = await auth_service.swagger_login(db=db, obj=obj)
|
||||
return GetSwaggerToken(access_token=token, user=user)
|
||||
return GetSwaggerToken(access_token=token, user=user) # type: ignore
|
||||
|
||||
|
||||
@router.post(
|
||||
'/login',
|
||||
summary='用户登录',
|
||||
description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman',
|
||||
dependencies=[Depends(RateLimiter(times=5, minutes=1))],
|
||||
dependencies=[Depends(RateLimiter(Rate(5, Duration.MINUTE)))],
|
||||
)
|
||||
async def login(
|
||||
db: CurrentSessionTransaction,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from uuid import uuid4
|
||||
import uuid
|
||||
|
||||
from fast_captcha import img_captcha
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
from pyrate_limiter import Duration, Rate
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.app.admin.schema.captcha import GetCaptchaDetail
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import CurrentSession
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_login_config
|
||||
from backend.utils.limiter import RateLimiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -16,19 +19,21 @@ router = APIRouter()
|
||||
@router.get(
|
||||
'/captcha',
|
||||
summary='获取登录验证码',
|
||||
dependencies=[Depends(RateLimiter(times=5, seconds=10))],
|
||||
dependencies=[Depends(RateLimiter(Rate(5, Duration.SECOND * 30)))],
|
||||
)
|
||||
async def get_captcha() -> ResponseSchemaModel[GetCaptchaDetail]:
|
||||
"""
|
||||
此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗
|
||||
"""
|
||||
img_type: str = 'base64'
|
||||
img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
|
||||
uuid = str(uuid4())
|
||||
async def get_captcha(db: CurrentSession) -> ResponseSchemaModel[GetCaptchaDetail]:
|
||||
await load_login_config(db)
|
||||
img, code = await run_in_threadpool(img_captcha, img_byte='base64')
|
||||
captcha_uuid = str(uuid.uuid4())
|
||||
await redis_client.set(
|
||||
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}',
|
||||
f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{captcha_uuid}',
|
||||
code,
|
||||
ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
|
||||
ex=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
|
||||
)
|
||||
data = GetCaptchaDetail(
|
||||
is_enabled=settings.LOGIN_CAPTCHA_ENABLED,
|
||||
expire_seconds=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
|
||||
uuid=captcha_uuid,
|
||||
image=img,
|
||||
)
|
||||
data = GetCaptchaDetail(uuid=uuid, img_type=img_type, image=img)
|
||||
return response_base.success(data=data)
|
||||
|
||||
@@ -18,7 +18,7 @@ router = APIRouter()
|
||||
async def get_sessions(
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
) -> ResponseSchemaModel[list[GetTokenDetail]]:
|
||||
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
|
||||
token_keys = await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:*')
|
||||
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
|
||||
data: list[GetTokenDetail] = []
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.common.response.response_schema import ResponseModel, response_base
|
||||
from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.utils.redis_info import redis_info
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.format import fmt_seconds
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
|
||||
async def get_redis_info() -> ResponseModel:
|
||||
data = {
|
||||
'info': await redis_info.get_info(),
|
||||
'stats': await redis_info.get_stats(),
|
||||
}
|
||||
@router.get('', summary='Redis 监控', dependencies=[DependsJwtAuth])
|
||||
async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
|
||||
info = await redis_client.info()
|
||||
db_size = await redis_client.dbsize()
|
||||
|
||||
server_info = RedisServerInfo(
|
||||
redis_version=str(info.get('redis_version', '')),
|
||||
redis_mode=str(info.get('redis_mode', '')),
|
||||
role=str(info.get('role', '')),
|
||||
tcp_port=str(info.get('tcp_port', '')),
|
||||
uptime=str(fmt_seconds(int(info.get('uptime_in_seconds', 0)))),
|
||||
connected_clients=str(info.get('connected_clients', '')),
|
||||
blocked_clients=str(info.get('blocked_clients', '')),
|
||||
used_memory_human=str(info.get('used_memory_human', '')),
|
||||
used_memory_rss_human=str(info.get('used_memory_rss_human', '')),
|
||||
maxmemory_human=str(info.get('maxmemory_human', '0B')),
|
||||
mem_fragmentation_ratio=str(info.get('mem_fragmentation_ratio', '0')),
|
||||
instantaneous_ops_per_sec=str(info.get('instantaneous_ops_per_sec', '')),
|
||||
total_commands_processed=str(info.get('total_commands_processed', '')),
|
||||
rejected_connections=str(info.get('rejected_connections', '')),
|
||||
keys_num=str(db_size),
|
||||
)
|
||||
|
||||
command_stats = await redis_client.info('commandstats')
|
||||
stats_list = []
|
||||
for key, value in command_stats.items():
|
||||
if isinstance(value, dict):
|
||||
stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0'))))
|
||||
|
||||
data = RedisMonitorInfo(info=server_info, stats=stats_list)
|
||||
return response_base.success(data=data)
|
||||
|
||||
@@ -1,21 +1,134 @@
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone as tz
|
||||
|
||||
import psutil
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.common.response.response_schema import ResponseModel, response_base
|
||||
from backend.app.admin.schema.monitor import (
|
||||
CpuInfo,
|
||||
DiskInfo,
|
||||
MemInfo,
|
||||
ServerMonitorInfo,
|
||||
ServiceInfo,
|
||||
SysInfo,
|
||||
)
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.utils.server_info import server_info
|
||||
from backend.utils.format import fmt_bytes, fmt_seconds
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
|
||||
async def get_server_info() -> ResponseModel:
|
||||
data = {
|
||||
# 扔到线程池,避免阻塞
|
||||
'cpu': await run_in_threadpool(server_info.get_cpu_info),
|
||||
'mem': await run_in_threadpool(server_info.get_mem_info),
|
||||
'sys': await run_in_threadpool(server_info.get_sys_info),
|
||||
'disk': await run_in_threadpool(server_info.get_disk_info),
|
||||
'service': await run_in_threadpool(server_info.get_service_info),
|
||||
@router.get('', summary='Server 监控', dependencies=[DependsJwtAuth])
|
||||
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901
|
||||
def get_all_info() -> ServerMonitorInfo: # noqa: C901
|
||||
# CPU 信息
|
||||
cpu_data = {
|
||||
'physical_num': psutil.cpu_count(logical=False) or 0,
|
||||
'logical_num': psutil.cpu_count(logical=True) or 0,
|
||||
'max_freq': 0.0,
|
||||
'min_freq': 0.0,
|
||||
'current_freq': 0.0,
|
||||
'usage': round(psutil.cpu_percent(interval=0.1), 2),
|
||||
}
|
||||
|
||||
try:
|
||||
if hasattr(psutil, 'cpu_freq'):
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
if cpu_freq:
|
||||
cpu_data.update({
|
||||
'max_freq': round(cpu_freq.max, 2),
|
||||
'min_freq': round(cpu_freq.min, 2),
|
||||
'current_freq': round(cpu_freq.current, 2),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cpu = CpuInfo(**cpu_data)
|
||||
|
||||
# 内存信息
|
||||
mem = psutil.virtual_memory()
|
||||
gb_factor = 1024**3
|
||||
mem_info = MemInfo(
|
||||
total=round(mem.total / gb_factor, 2),
|
||||
used=round(mem.used / gb_factor, 2),
|
||||
free=round(mem.available / gb_factor, 2),
|
||||
usage=round(mem.percent, 2),
|
||||
)
|
||||
|
||||
# 系统信息
|
||||
hostname = socket.gethostname()
|
||||
ip = '127.0.0.1'
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(0.5)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
except (TimeoutError, socket.gaierror, OSError):
|
||||
pass
|
||||
sys_info = SysInfo(name=hostname, os=platform.system(), ip=ip, arch=platform.machine())
|
||||
|
||||
# 磁盘信息
|
||||
disk_list = []
|
||||
exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'}
|
||||
seen_devices = set()
|
||||
for partition in psutil.disk_partitions(all=False):
|
||||
# 跳过虚拟文件系统
|
||||
if partition.fstype.lower() in exclude_fstypes:
|
||||
continue
|
||||
# 跳过重复设备(同一设备的不同挂载点)
|
||||
if partition.device in seen_devices:
|
||||
continue
|
||||
try:
|
||||
usage = psutil.disk_usage(partition.mountpoint)
|
||||
if usage:
|
||||
seen_devices.add(partition.device)
|
||||
disk_list.append(
|
||||
DiskInfo(
|
||||
dir=partition.mountpoint,
|
||||
device=partition.device,
|
||||
type=partition.fstype,
|
||||
total=fmt_bytes(usage.total),
|
||||
used=fmt_bytes(usage.used),
|
||||
free=fmt_bytes(usage.free),
|
||||
usage=f'{usage.percent:.2f}%',
|
||||
)
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
# 服务信息
|
||||
process = psutil.Process(os.getpid())
|
||||
proc_mem = process.memory_info()
|
||||
try:
|
||||
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
|
||||
start_time = timezone.from_datetime(create_time)
|
||||
except (psutil.NoSuchProcess, OSError):
|
||||
start_time = timezone.now()
|
||||
|
||||
elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds()))
|
||||
|
||||
service = ServiceInfo(
|
||||
name='Python3',
|
||||
version=platform.python_version(),
|
||||
home=sys.executable,
|
||||
startup=timezone.to_str(start_time),
|
||||
elapsed=elapsed,
|
||||
cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%',
|
||||
mem_vms=fmt_bytes(proc_mem.vms),
|
||||
mem_rss=fmt_bytes(proc_mem.rss),
|
||||
mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss),
|
||||
)
|
||||
|
||||
return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service)
|
||||
|
||||
data = await run_in_threadpool(get_all_info)
|
||||
return response_base.success(data=data)
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter
|
||||
from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router
|
||||
from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router
|
||||
from backend.app.admin.api.v1.sys.dept import router as dept_router
|
||||
from backend.app.admin.api.v1.sys.files import router as file_router
|
||||
from backend.app.admin.api.v1.sys.file import router as file_router
|
||||
from backend.app.admin.api.v1.sys.menu import router as menu_router
|
||||
from backend.app.admin.api.v1.sys.plugin import router as plugin_router
|
||||
from backend.app.admin.api.v1.sys.role import router as role_router
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy import ColumnElement
|
||||
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
|
||||
from backend.app.admin.service.dept_service import dept_service
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.permission import DataPermissionFilter, RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
@@ -24,13 +26,15 @@ async def get_dept(
|
||||
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
|
||||
async def get_dept_tree(
|
||||
db: CurrentSession,
|
||||
request: Request,
|
||||
data_filter: Annotated[ColumnElement[bool], Depends(DataPermissionFilter(Dept))],
|
||||
name: Annotated[str | None, Query(description='部门名称')] = None,
|
||||
leader: Annotated[str | None, Query(description='部门负责人')] = None,
|
||||
phone: Annotated[str | None, Query(description='联系电话')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[list[GetDeptTree]]:
|
||||
dept = await dept_service.get_tree(db=db, request=request, name=name, leader=leader, phone=phone, status=status)
|
||||
dept = await dept_service.get_tree(
|
||||
db=db, data_filter=data_filter, name=name, leader=leader, phone=phone, status=status
|
||||
)
|
||||
return response_base.success(data=dept)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ router = APIRouter()
|
||||
|
||||
@router.post(
|
||||
'/upload',
|
||||
summary='文件上传',
|
||||
summary='本地文件上传',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:file:upload')),
|
||||
DependsRBAC,
|
||||
@@ -30,7 +30,7 @@ async def plugin_changed() -> ResponseSchemaModel[bool]:
|
||||
@router.post(
|
||||
'',
|
||||
summary='安装插件',
|
||||
description='使用插件 zip 压缩包或 git 仓库地址进行安装',
|
||||
description='使用插件 zip 压缩包或 git 仓库地址进行安装(仅开发环境)',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:plugin:install')),
|
||||
DependsRBAC,
|
||||
@@ -53,7 +53,7 @@ async def install_plugin(
|
||||
@router.delete(
|
||||
'/{plugin}',
|
||||
summary='卸载插件',
|
||||
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
|
||||
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录(仅开发环境)',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:plugin:uninstall')),
|
||||
DependsRBAC,
|
||||
|
||||
@@ -102,7 +102,7 @@ async def update_user_permission(
|
||||
async def update_user_password(
|
||||
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_password(db=db, request=request, obj=obj)
|
||||
count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -126,7 +126,7 @@ async def update_user_nickname(
|
||||
request: Request,
|
||||
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_nickname(db=db, request=request, nickname=nickname)
|
||||
count = await user_service.update_nickname(db=db, user_id=request.user.id, nickname=nickname)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -138,7 +138,7 @@ async def update_user_avatar(
|
||||
request: Request,
|
||||
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_avatar(db=db, request=request, avatar=avatar)
|
||||
count = await user_service.update_avatar(db=db, user_id=request.user.id, avatar=avatar)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -151,7 +151,7 @@ async def update_user_email(
|
||||
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
|
||||
email: Annotated[str, Body(embed=True, description='用户邮箱')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_email(db=db, request=request, captcha=captcha, email=email)
|
||||
count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
@@ -33,7 +33,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
|
||||
return await self.select_order('id', load_strategies={'scopes': 'noload'}, **filters)
|
||||
return await self.select_order('id', **filters)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy import Select, delete, insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import DataRule, DataScope
|
||||
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam
|
||||
from backend.app.admin.model import DataRule, DataScope, data_scope_rule
|
||||
from backend.app.admin.schema.data_scope import (
|
||||
CreateDataScopeParam,
|
||||
CreateDataScopeRuleParam,
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
|
||||
|
||||
class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
@@ -31,7 +38,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
|
||||
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope:
|
||||
async def get_join(self, db: AsyncSession, pk: int) -> Any:
|
||||
"""
|
||||
获取数据范围关联数据
|
||||
|
||||
@@ -39,7 +46,16 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk, load_strategies=['rules'])
|
||||
result = await self.select_models(
|
||||
db,
|
||||
id=pk,
|
||||
join_conditions=[
|
||||
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == self.model.id),
|
||||
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
|
||||
],
|
||||
)
|
||||
|
||||
return select_join_serialize(result, relationships=['DataScope-m2m-DataRule:rules'])
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
|
||||
"""
|
||||
@@ -65,7 +81,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters)
|
||||
return await self.select_order('id', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
|
||||
"""
|
||||
@@ -88,7 +104,8 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
|
||||
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
@staticmethod
|
||||
async def update_rules(db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
"""
|
||||
更新数据范围规则
|
||||
|
||||
@@ -97,11 +114,18 @@ class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
:param rule_ids: 数据规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
current_data_scope = await self.get_with_relation(db, pk)
|
||||
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules))
|
||||
rules = await db.execute(stmt)
|
||||
current_data_scope.rules = rules.scalars().all()
|
||||
return len(current_data_scope.rules)
|
||||
data_scope_rule_stmt = delete(data_scope_rule).where(data_scope_rule.c.data_scope_id == pk)
|
||||
await db.execute(data_scope_rule_stmt)
|
||||
|
||||
if rule_ids.rules:
|
||||
data_scope_rule_data = [
|
||||
CreateDataScopeRuleParam(data_scope_id=pk, data_rule_id=rule_id).model_dump()
|
||||
for rule_id in rule_ids.rules
|
||||
]
|
||||
data_scope_rule_stmt = insert(data_scope_rule)
|
||||
await db.execute(data_scope_rule_stmt, data_scope_rule_data)
|
||||
|
||||
return len(rule_ids.rules)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import ColumnElement
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.model import Dept, User
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.common.security.permission import filter_data_permission
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
|
||||
|
||||
class CRUDDept(CRUDPlus[Dept]):
|
||||
@@ -34,8 +35,8 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
request: Request,
|
||||
db: AsyncSession,
|
||||
data_filter: ColumnElement[bool],
|
||||
name: str | None,
|
||||
leader: str | None,
|
||||
phone: str | None,
|
||||
@@ -44,8 +45,8 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
"""
|
||||
获取所有部门
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param data_filter: 请求用户
|
||||
:param name: 部门名称
|
||||
:param leader: 负责人
|
||||
:param phone: 联系电话
|
||||
@@ -63,8 +64,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
data_filtered = await filter_data_permission(db, request)
|
||||
return await self.select_models_order(db, 'sort', 'desc', data_filtered, **filters)
|
||||
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
|
||||
"""
|
||||
@@ -97,7 +97,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
"""
|
||||
return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag')
|
||||
|
||||
async def get_with_relation(self, db: AsyncSession, dept_id: int) -> Dept | None:
|
||||
async def get_join(self, db: AsyncSession, dept_id: int) -> Any | None:
|
||||
"""
|
||||
获取部门及关联数据
|
||||
|
||||
@@ -105,7 +105,12 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, dept_id, load_strategies=['users'])
|
||||
result = await self.select_model(
|
||||
db,
|
||||
dept_id,
|
||||
join_conditions=[JoinConfig(model=User, join_on=User.dept_id == self.model.id, fill_result=True)],
|
||||
)
|
||||
return select_join_serialize(result, relationships=['Dept-o2m-User'])
|
||||
|
||||
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
|
||||
"""
|
||||
@@ -115,7 +120,7 @@ class CRUDDept(CRUDPlus[Dept]):
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, parent_id=dept_id, del_flag=0)
|
||||
return await self.select_models(db, parent_id=dept_id, del_flag=False)
|
||||
|
||||
|
||||
dept_dao: CRUDDept = CRUDDept(Dept)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import Menu
|
||||
from backend.app.admin.model import Menu, role_menu
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||
|
||||
|
||||
@@ -92,9 +93,12 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
role_menu_stmt = delete(role_menu).where(role_menu.c.menu_id == menu_id)
|
||||
await db.execute(role_menu_stmt)
|
||||
|
||||
return await self.delete_model(db, menu_id)
|
||||
|
||||
async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]:
|
||||
async def get_children(self, db: AsyncSession, menu_id: int) -> Sequence[Menu | None]:
|
||||
"""
|
||||
获取子菜单列表
|
||||
|
||||
@@ -102,8 +106,7 @@ class CRUDMenu(CRUDPlus[Menu]):
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
menu = await self.select_model(db, menu_id, load_strategies=['children'])
|
||||
return menu.children
|
||||
return await self.select_models(db, parent_id=menu_id)
|
||||
|
||||
|
||||
menu_dao: CRUDMenu = CRUDMenu(Menu)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy import Select, delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import DataScope, Menu, Role
|
||||
from backend.app.admin.model import DataScope, Menu, Role, role_data_scope, role_menu
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleMenuParam,
|
||||
CreateRoleParam,
|
||||
CreateRoleScopeParam,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
|
||||
|
||||
class CRUDRole(CRUDPlus[Role]):
|
||||
@@ -26,7 +30,20 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
"""
|
||||
return await self.select_model(db, role_id)
|
||||
|
||||
async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None:
|
||||
@staticmethod
|
||||
async def get_menus(db: AsyncSession, role_id: int) -> Sequence[Menu] | None:
|
||||
"""
|
||||
获取角色菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
menu_stmt = select(Menu).join(role_menu, Menu.id == role_menu.c.menu_id).where(role_menu.c.role_id == role_id)
|
||||
result = await db.execute(menu_stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_join(self, db: AsyncSession, role_id: int) -> Any:
|
||||
"""
|
||||
获取角色及关联数据
|
||||
|
||||
@@ -34,7 +51,18 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, role_id, load_strategies=['menus', 'scopes'])
|
||||
result = await self.select_models(
|
||||
db,
|
||||
id=role_id,
|
||||
join_conditions=[
|
||||
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == self.model.id),
|
||||
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
|
||||
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == self.model.id),
|
||||
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
|
||||
],
|
||||
)
|
||||
|
||||
return select_join_serialize(result, relationships=['Role-m2m-Menu', 'Role-m2m-DataScope:scopes'])
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
|
||||
"""
|
||||
@@ -61,15 +89,7 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order(
|
||||
'id',
|
||||
load_strategies={
|
||||
'users': 'noload',
|
||||
'menus': 'noload',
|
||||
'scopes': 'noload',
|
||||
},
|
||||
**filters,
|
||||
)
|
||||
return await self.select_order('id', **filters)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
|
||||
"""
|
||||
@@ -102,7 +122,8 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
"""
|
||||
return await self.update_model(db, role_id, obj)
|
||||
|
||||
async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
@staticmethod
|
||||
async def update_menus(db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
"""
|
||||
更新角色菜单
|
||||
|
||||
@@ -111,13 +132,20 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
current_role = await self.get_with_relation(db, role_id)
|
||||
stmt = select(Menu).where(Menu.id.in_(menu_ids.menus))
|
||||
menus = await db.execute(stmt)
|
||||
current_role.menus = menus.scalars().all()
|
||||
return len(current_role.menus)
|
||||
role_menu_stmt = delete(role_menu).where(role_menu.c.role_id == role_id)
|
||||
await db.execute(role_menu_stmt)
|
||||
|
||||
async def update_scopes(self, db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||
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_stmt = insert(role_menu)
|
||||
await db.execute(role_menu_stmt, role_menu_data)
|
||||
|
||||
return len(menu_ids.menus)
|
||||
|
||||
@staticmethod
|
||||
async def update_scopes(db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||
"""
|
||||
更新角色数据范围
|
||||
|
||||
@@ -126,11 +154,18 @@ class CRUDRole(CRUDPlus[Role]):
|
||||
:param scope_ids: 权限范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
current_role = await self.get_with_relation(db, role_id)
|
||||
stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes))
|
||||
scopes = await db.execute(stmt)
|
||||
current_role.scopes = scopes.scalars().all()
|
||||
return len(current_role.scopes)
|
||||
role_scope_stmt = delete(role_data_scope).where(role_data_scope.c.role_id == role_id)
|
||||
await db.execute(role_scope_stmt)
|
||||
|
||||
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_stmt = insert(role_data_scope)
|
||||
await db.execute(role_scope_stmt, role_scope_data)
|
||||
|
||||
return len(scope_ids.scopes)
|
||||
|
||||
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
|
||||
"""
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import Select, delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload, selectinload
|
||||
from sqlalchemy.sql import Select
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import Dept, Role, User
|
||||
from backend.app.admin.model import (
|
||||
DataRule,
|
||||
DataScope,
|
||||
Dept,
|
||||
Menu,
|
||||
Role,
|
||||
User,
|
||||
data_scope_rule,
|
||||
role_data_scope,
|
||||
role_menu,
|
||||
user_role,
|
||||
)
|
||||
from backend.app.admin.schema.user import (
|
||||
AddOAuth2UserParam,
|
||||
AddUserParam,
|
||||
AddUserRoleParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.common.security.jwt import get_hash_password
|
||||
from backend.app.admin.utils.password_security import get_hash_password
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -49,15 +63,47 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
return await self.select_model_by_column(db, nickname=nickname)
|
||||
|
||||
async def update_login_time(self, db: AsyncSession, username: str) -> int:
|
||||
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
||||
"""
|
||||
更新用户最后登录时间
|
||||
检查邮箱是否已被绑定
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:param email: 电子邮箱
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
|
||||
return await self.select_model_by_column(db, email=email)
|
||||
|
||||
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取用户列表查询表达式
|
||||
|
||||
:param dept: 部门 ID
|
||||
:param username: 用户名
|
||||
:param phone: 电话号码
|
||||
:param status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if dept:
|
||||
filters['dept_id'] = dept
|
||||
if username:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if phone:
|
||||
filters['phone__like'] = f'%{phone}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order(
|
||||
'id',
|
||||
'desc',
|
||||
join_conditions=[
|
||||
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
|
||||
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
|
||||
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
|
||||
async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
|
||||
"""
|
||||
@@ -69,15 +115,21 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
obj.password = get_hash_password(obj.password, salt)
|
||||
|
||||
dict_obj = obj.model_dump(exclude={'roles'})
|
||||
dict_obj.update({'salt': salt})
|
||||
new_user = self.model(**dict_obj)
|
||||
|
||||
stmt = select(Role).where(Role.id.in_(obj.roles))
|
||||
roles = await db.execute(stmt)
|
||||
new_user.roles = roles.scalars().all()
|
||||
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
if obj.roles:
|
||||
role_stmt = select(Role).where(Role.id.in_(obj.roles))
|
||||
result = await db.execute(role_stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
user_role_data = [AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump() for role in roles]
|
||||
user_role_stmt = insert(user_role)
|
||||
await db.execute(user_role_stmt, user_role_data)
|
||||
|
||||
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
|
||||
"""
|
||||
@@ -90,31 +142,64 @@ class CRUDUser(CRUDPlus[User]):
|
||||
dict_obj = obj.model_dump()
|
||||
dict_obj.update({'is_staff': True, 'salt': None})
|
||||
new_user = self.model(**dict_obj)
|
||||
|
||||
stmt = select(Role)
|
||||
role = await db.execute(stmt)
|
||||
new_user.roles = [role.scalars().first()] # 默认绑定第一个角色
|
||||
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
|
||||
role_stmt = select(Role)
|
||||
result = await db.execute(role_stmt)
|
||||
role = result.scalars().first() # 默认绑定第一个角色
|
||||
|
||||
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
:param db: 数据库会话
|
||||
:param input_user: 用户 ID
|
||||
:param user_id: 用户 ID
|
||||
:param obj: 更新用户参数
|
||||
:return:
|
||||
"""
|
||||
role_ids = obj.roles
|
||||
del obj.roles
|
||||
count = await self.update_model(db, input_user.id, obj)
|
||||
|
||||
stmt = select(Role).where(Role.id.in_(role_ids))
|
||||
roles = await db.execute(stmt)
|
||||
input_user.roles = roles.scalars().all()
|
||||
count = await self.update_model(db, user_id, obj)
|
||||
|
||||
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))
|
||||
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_stmt = insert(user_role)
|
||||
await db.execute(user_role_stmt, user_role_data)
|
||||
|
||||
return count
|
||||
|
||||
async def update_login_time(self, db: AsyncSession, username: str) -> int:
|
||||
"""
|
||||
更新用户上次登录时间
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
|
||||
|
||||
async def update_password_changed_time(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
更新用户上次密码变更时间
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'last_password_changed_time': timezone.now()})
|
||||
|
||||
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
更新用户昵称
|
||||
@@ -148,26 +233,6 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'email': email})
|
||||
|
||||
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model(db, user_id)
|
||||
|
||||
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
||||
"""
|
||||
检查邮箱是否已被绑定
|
||||
|
||||
:param db: 数据库会话
|
||||
:param email: 电子邮箱
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, email=email)
|
||||
|
||||
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
|
||||
"""
|
||||
重置用户密码
|
||||
@@ -179,38 +244,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})
|
||||
|
||||
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取用户列表查询表达式
|
||||
|
||||
:param dept: 部门 ID
|
||||
:param username: 用户名
|
||||
:param phone: 电话号码
|
||||
:param status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if dept:
|
||||
filters['dept_id'] = dept
|
||||
if username:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if phone:
|
||||
filters['phone__like'] = f'%{phone}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order(
|
||||
'id',
|
||||
'desc',
|
||||
load_options=[
|
||||
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
|
||||
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}, flush=True)
|
||||
|
||||
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
|
||||
"""
|
||||
@@ -256,13 +290,34 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
|
||||
|
||||
async def get_with_relation(
|
||||
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
try:
|
||||
user_social = import_module_cached('backend.plugin.oauth2.crud.crud_user_social')
|
||||
user_social_dao = user_social.user_social_dao
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
else:
|
||||
await user_social_dao.delete_by_user_id(db, user_id)
|
||||
|
||||
return await self.delete_model(db, user_id)
|
||||
|
||||
async def get_join(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> User | None:
|
||||
) -> Any | None:
|
||||
"""
|
||||
获取用户关联信息
|
||||
|
||||
@@ -278,12 +333,32 @@ class CRUDUser(CRUDPlus[User]):
|
||||
if username:
|
||||
filters['username'] = username
|
||||
|
||||
return await self.select_model_by_column(
|
||||
result = await self.select_models(
|
||||
db,
|
||||
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))],
|
||||
load_strategies=['dept'],
|
||||
join_conditions=[
|
||||
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
|
||||
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
|
||||
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
|
||||
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == Role.id),
|
||||
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
|
||||
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == Role.id),
|
||||
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
|
||||
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == DataScope.id),
|
||||
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
|
||||
return select_join_serialize(
|
||||
result,
|
||||
relationships=[
|
||||
'User-m2o-Dept',
|
||||
'User-m2m-Role',
|
||||
'Role-m2m-Menu',
|
||||
'Role-m2m-DataScope:scopes',
|
||||
'DataScope-m2m-DataRule:rules',
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
user_dao: CRUDUser = CRUDUser(User)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model.user_password_history import UserPasswordHistory
|
||||
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
|
||||
|
||||
|
||||
class CRUDUserPasswordHistory(CRUDPlus[UserPasswordHistory]):
|
||||
"""用户密码历史记录数据库操作类"""
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
|
||||
"""
|
||||
创建密码历史记录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建密码历史记录参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserPasswordHistory]:
|
||||
"""
|
||||
获取用户的密码历史记录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models_order(db, 'id', 'desc', self.model.user_id == user_id)
|
||||
|
||||
|
||||
user_password_history_dao: CRUDUserPasswordHistory = CRUDUserPasswordHistory(UserPasswordHistory)
|
||||
@@ -2,7 +2,12 @@ from backend.app.admin.model.data_rule import DataRule as DataRule
|
||||
from backend.app.admin.model.data_scope import DataScope as DataScope
|
||||
from backend.app.admin.model.dept import Dept as Dept
|
||||
from backend.app.admin.model.login_log import LoginLog as LoginLog
|
||||
from backend.app.admin.model.m2m import data_scope_rule as data_scope_rule
|
||||
from backend.app.admin.model.m2m import role_data_scope as role_data_scope
|
||||
from backend.app.admin.model.m2m import role_menu as role_menu
|
||||
from backend.app.admin.model.m2m import user_role as user_role
|
||||
from backend.app.admin.model.menu import Menu as Menu
|
||||
from backend.app.admin.model.opera_log import OperaLog as OperaLog
|
||||
from backend.app.admin.model.role import Role as Role
|
||||
from backend.app.admin.model.user import User as User
|
||||
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.admin.model.m2m import sys_data_scope_rule
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import DataScope
|
||||
|
||||
|
||||
class DataRule(Base):
|
||||
"""数据规则表"""
|
||||
@@ -19,14 +11,11 @@ class DataRule(Base):
|
||||
__tablename__ = 'sys_data_rule'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(500), unique=True, comment='名称')
|
||||
model: Mapped[str] = mapped_column(sa.String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名')
|
||||
column: Mapped[str] = mapped_column(sa.String(20), comment='模型字段名')
|
||||
name: Mapped[str] = mapped_column(sa.String(512), unique=True, comment='名称')
|
||||
model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
|
||||
column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
|
||||
operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)')
|
||||
expression: Mapped[int] = mapped_column(
|
||||
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)',
|
||||
)
|
||||
value: Mapped[str] = mapped_column(sa.String(255), comment='规则值')
|
||||
|
||||
# 数据范围规则多对多
|
||||
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
|
||||
value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.admin.model.m2m import sys_data_scope_rule, sys_role_data_scope
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import DataRule, Role
|
||||
|
||||
|
||||
class DataScope(Base):
|
||||
"""数据范围表"""
|
||||
@@ -19,11 +11,5 @@ class DataScope(Base):
|
||||
__tablename__ = 'sys_data_scope'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(50), unique=True, comment='名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
|
||||
|
||||
# 数据范围规则多对多
|
||||
rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='scopes')
|
||||
|
||||
# 角色数据范围多对多
|
||||
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import User
|
||||
|
||||
|
||||
class Dept(Base):
|
||||
"""部门表"""
|
||||
@@ -18,20 +11,13 @@ class Dept(Base):
|
||||
__tablename__ = 'sys_dept'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(50), comment='部门名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='部门名称')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
leader: Mapped[str | None] = mapped_column(sa.String(20), default=None, comment='负责人')
|
||||
leader: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='负责人')
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(50), default=None, comment='邮箱')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='邮箱')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
|
||||
del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)')
|
||||
|
||||
# 父级部门一对多
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
sa.BigInteger, sa.ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
|
||||
)
|
||||
parent: Mapped[Dept | None] = relationship(init=False, back_populates='children', remote_side=[id])
|
||||
children: Mapped[list[Dept] | None] = relationship(init=False, back_populates='parent')
|
||||
|
||||
# 部门用户一对多
|
||||
users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
|
||||
# 父级部门
|
||||
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
|
||||
|
||||
@@ -14,17 +14,17 @@ class LoginLog(DataClassBase):
|
||||
__tablename__ = 'sys_login_log'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
user_uuid: Mapped[str] = mapped_column(sa.String(50), comment='用户UUID')
|
||||
username: Mapped[str] = mapped_column(sa.String(20), comment='用户名')
|
||||
user_uuid: Mapped[str] = mapped_column(sa.String(64), comment='用户UUID')
|
||||
username: Mapped[str] = mapped_column(sa.String(64), comment='用户名')
|
||||
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
|
||||
ip: Mapped[str] = mapped_column(sa.String(50), comment='登录IP地址')
|
||||
country: Mapped[str | None] = mapped_column(sa.String(50), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(sa.String(50), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(sa.String(50), comment='城市')
|
||||
user_agent: Mapped[str] = mapped_column(sa.String(255), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(sa.String(50), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(sa.String(50), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(sa.String(50), comment='设备')
|
||||
ip: Mapped[str] = mapped_column(sa.String(64), comment='登录IP地址')
|
||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||
msg: Mapped[str] = mapped_column(UniversalText, comment='提示消息')
|
||||
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -2,62 +2,38 @@ import sqlalchemy as sa
|
||||
|
||||
from backend.common.model import MappedBase
|
||||
|
||||
sys_user_role = sa.Table(
|
||||
# 用户角色表
|
||||
user_role = sa.Table(
|
||||
'sys_user_role',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column(
|
||||
'user_id', sa.BigInteger, sa.ForeignKey('sys_user.id', ondelete='CASCADE'), primary_key=True, comment='用户ID'
|
||||
),
|
||||
sa.Column(
|
||||
'role_id', sa.BigInteger, sa.ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'
|
||||
),
|
||||
sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||
)
|
||||
|
||||
sys_role_menu = sa.Table(
|
||||
# 角色菜单表
|
||||
role_menu = sa.Table(
|
||||
'sys_role_menu',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column(
|
||||
'role_id', sa.BigInteger, sa.ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'
|
||||
),
|
||||
sa.Column(
|
||||
'menu_id', sa.BigInteger, sa.ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'
|
||||
),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||
sa.Column('menu_id', sa.BigInteger, primary_key=True, comment='菜单ID'),
|
||||
)
|
||||
|
||||
sys_role_data_scope = sa.Table(
|
||||
# 角色数据范围表
|
||||
role_data_scope = sa.Table(
|
||||
'sys_role_data_scope',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
|
||||
sa.Column(
|
||||
'role_id', sa.BigInteger, sa.ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'
|
||||
),
|
||||
sa.Column(
|
||||
'data_scope_id',
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
comment='数据范围 ID',
|
||||
),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
|
||||
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||
)
|
||||
|
||||
sys_data_scope_rule = sa.Table(
|
||||
# 数据范围规则表
|
||||
data_scope_rule = sa.Table(
|
||||
'sys_data_scope_rule',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column(
|
||||
'data_scope_id',
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
comment='数据范围 ID',
|
||||
),
|
||||
sa.Column(
|
||||
'data_rule_id',
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey('sys_data_rule.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
comment='数据规则 ID',
|
||||
),
|
||||
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||
sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 ID'),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.admin.model.m2m import sys_role_menu
|
||||
from backend.common.model import Base, UniversalText, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import Role
|
||||
|
||||
|
||||
class Menu(Base):
|
||||
"""菜单表"""
|
||||
@@ -19,26 +11,19 @@ class Menu(Base):
|
||||
__tablename__ = 'sys_menu'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
title: Mapped[str] = mapped_column(sa.String(50), comment='菜单标题')
|
||||
name: Mapped[str] = mapped_column(sa.String(50), comment='菜单名称')
|
||||
title: Mapped[str] = mapped_column(sa.String(64), comment='菜单标题')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='菜单名称')
|
||||
path: Mapped[str | None] = mapped_column(sa.String(200), comment='路由地址')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
icon: Mapped[str | None] = mapped_column(sa.String(100), default=None, comment='菜单图标')
|
||||
icon: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='菜单图标')
|
||||
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
||||
component: Mapped[str | None] = mapped_column(sa.String(255), default=None, comment='组件路径')
|
||||
perms: Mapped[str | None] = mapped_column(sa.String(100), default=None, comment='权限标识')
|
||||
component: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='组件路径')
|
||||
perms: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='权限标识')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
|
||||
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
|
||||
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
|
||||
link: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='外链地址')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
|
||||
# 父级菜单一对多
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
sa.BigInteger, sa.ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
|
||||
)
|
||||
parent: Mapped[Menu | None] = relationship(init=False, back_populates='children', remote_side=[id])
|
||||
children: Mapped[list[Menu] | None] = relationship(init=False, back_populates='parent')
|
||||
|
||||
# 菜单角色多对多
|
||||
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
|
||||
# 父级菜单
|
||||
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父菜单ID')
|
||||
|
||||
@@ -15,21 +15,21 @@ class OperaLog(DataClassBase):
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
|
||||
username: Mapped[str | None] = mapped_column(sa.String(20), comment='用户名')
|
||||
method: Mapped[str] = mapped_column(sa.String(20), comment='请求类型')
|
||||
title: Mapped[str] = mapped_column(sa.String(255), comment='操作模块')
|
||||
path: Mapped[str] = mapped_column(sa.String(500), comment='请求路径')
|
||||
ip: Mapped[str] = mapped_column(sa.String(50), comment='IP地址')
|
||||
country: Mapped[str | None] = mapped_column(sa.String(50), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(sa.String(50), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(sa.String(50), comment='城市')
|
||||
user_agent: Mapped[str] = mapped_column(sa.String(255), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(sa.String(50), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(sa.String(50), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(sa.String(50), comment='设备')
|
||||
username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
|
||||
method: Mapped[str] = mapped_column(sa.String(32), comment='请求类型')
|
||||
title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
|
||||
path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
|
||||
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP地址')
|
||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='请求参数')
|
||||
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
|
||||
code: Mapped[str] = mapped_column(sa.String(20), insert_default='200', comment='操作状态码')
|
||||
code: Mapped[str] = mapped_column(sa.String(32), insert_default='200', comment='操作状态码')
|
||||
msg: Mapped[str | None] = mapped_column(UniversalText, comment='提示消息')
|
||||
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms)')
|
||||
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role
|
||||
from backend.common.model import Base, UniversalText, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import DataScope, Menu, User
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
@@ -19,16 +11,7 @@ class Role(Base):
|
||||
__tablename__ = 'sys_role'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(20), unique=True, comment='角色名称')
|
||||
name: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='角色名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
|
||||
is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
|
||||
# 角色用户多对多
|
||||
users: Mapped[list[User]] = relationship(init=False, secondary=sys_user_role, back_populates='roles')
|
||||
|
||||
# 角色菜单多对多
|
||||
menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles')
|
||||
|
||||
# 角色数据范围多对多
|
||||
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.admin.model.m2m import sys_user_role
|
||||
from backend.common.model import Base, TimeZone, id_key
|
||||
from backend.database.db import uuid4_str
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import Dept, Role
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
@@ -22,28 +15,25 @@ class User(Base):
|
||||
__tablename__ = 'sys_user'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
uuid: Mapped[str] = mapped_column(sa.String(50), init=False, default_factory=uuid4_str, unique=True)
|
||||
username: Mapped[str] = mapped_column(sa.String(20), unique=True, index=True, comment='用户名')
|
||||
nickname: Mapped[str] = mapped_column(sa.String(20), comment='昵称')
|
||||
password: Mapped[str | None] = mapped_column(sa.String(255), comment='密码')
|
||||
uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
|
||||
username: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='用户名')
|
||||
nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
|
||||
password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
|
||||
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(255), comment='加密盐')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(50), default=None, unique=True, index=True, comment='邮箱')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, unique=True, index=True, comment='邮箱')
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
|
||||
avatar: Mapped[str | None] = mapped_column(sa.String(255), default=None, comment='头像')
|
||||
avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
|
||||
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
|
||||
is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
|
||||
is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
|
||||
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
|
||||
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
|
||||
last_login_time: Mapped[datetime | None] = mapped_column(
|
||||
TimeZone, init=False, onupdate=timezone.now, comment='上次登录'
|
||||
TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
|
||||
)
|
||||
last_password_changed_time: Mapped[datetime | None] = mapped_column(
|
||||
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
|
||||
)
|
||||
|
||||
# 部门用户一对多
|
||||
dept_id: Mapped[int | None] = mapped_column(
|
||||
sa.BigInteger, sa.ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID'
|
||||
)
|
||||
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
|
||||
|
||||
# 用户角色多对多
|
||||
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
|
||||
# 逻辑外键
|
||||
dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='部门关联ID')
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class UserPasswordHistory(DataClassBase):
|
||||
"""用户密码历史记录表"""
|
||||
|
||||
__tablename__ = 'sys_user_password_history'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
user_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='用户 ID')
|
||||
password: Mapped[str] = mapped_column(sa.String(256), comment='历史密码')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
TimeZone,
|
||||
init=False,
|
||||
default_factory=timezone.now,
|
||||
comment='创建时间',
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from backend.common.schema import SchemaBase
|
||||
class GetCaptchaDetail(SchemaBase):
|
||||
"""验证码详情"""
|
||||
|
||||
is_enabled: bool = Field(description='是否启用')
|
||||
expire_seconds: int = Field(description='过期秒数')
|
||||
uuid: str = Field(description='图片唯一标识')
|
||||
img_type: str = Field(description='图片类型')
|
||||
image: str = Field(description='图片内容')
|
||||
|
||||
@@ -34,7 +34,7 @@ class DeleteDataRuleParam(SchemaBase):
|
||||
class GetDataRuleDetail(DataRuleSchemaBase):
|
||||
"""数据规则详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
model_config = ConfigDict(from_attributes=True, frozen=True)
|
||||
|
||||
id: int = Field(description='规则 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
@@ -45,4 +45,4 @@ class GetDataRuleColumnDetail(SchemaBase):
|
||||
"""数据规则可用模型字段详情"""
|
||||
|
||||
key: str = Field(description='字段名')
|
||||
comment: str = Field(description='字段评论')
|
||||
comment: str | None = Field(description='字段评论')
|
||||
|
||||
@@ -22,6 +22,13 @@ class UpdateDataScopeParam(DataScopeBase):
|
||||
"""更新数据范围参数"""
|
||||
|
||||
|
||||
class CreateDataScopeRuleParam(SchemaBase):
|
||||
"""创建数据范围规则参数"""
|
||||
|
||||
data_scope_id: int = Field(description='数据范围 ID')
|
||||
data_rule_id: int = Field(description='数据规则 ID')
|
||||
|
||||
|
||||
class UpdateDataScopeRuleParam(SchemaBase):
|
||||
"""更新数据范围规则参数"""
|
||||
|
||||
@@ -47,4 +54,4 @@ class GetDataScopeDetail(DataScopeBase):
|
||||
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
|
||||
"""数据范围关联详情"""
|
||||
|
||||
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
|
||||
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
|
||||
|
||||
@@ -15,7 +15,7 @@ class LoginLogSchemaBase(SchemaBase):
|
||||
country: str | None = Field(None, description='国家')
|
||||
region: str | None = Field(None, description='地区')
|
||||
city: str | None = Field(None, description='城市')
|
||||
user_agent: str = Field(description='用户代理')
|
||||
user_agent: str | None = Field(description='用户代理')
|
||||
browser: str | None = Field(None, description='浏览器')
|
||||
os: str | None = Field(None, description='操作系统')
|
||||
device: str | None = Field(None, description='设备')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from pydantic import Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class CpuInfo(SchemaBase):
|
||||
"""CPU 信息"""
|
||||
|
||||
physical_num: int = Field(description='物理核心数')
|
||||
logical_num: int = Field(description='逻辑核心数')
|
||||
max_freq: float = Field(description='最大频率(MHz)')
|
||||
min_freq: float = Field(description='最小频率(MHz)')
|
||||
current_freq: float = Field(description='当前频率(MHz)')
|
||||
usage: float = Field(description='使用率(%)')
|
||||
|
||||
|
||||
class MemInfo(SchemaBase):
|
||||
"""内存信息"""
|
||||
|
||||
total: float = Field(description='总容量(GB)')
|
||||
used: float = Field(description='已使用(GB)')
|
||||
free: float = Field(description='可用(GB)')
|
||||
usage: float = Field(description='使用率(%)')
|
||||
|
||||
|
||||
class SysInfo(SchemaBase):
|
||||
"""系统信息"""
|
||||
|
||||
name: str = Field(description='主机名')
|
||||
os: str = Field(description='操作系统')
|
||||
ip: str = Field(description='IP 地址')
|
||||
arch: str = Field(description='系统架构')
|
||||
|
||||
|
||||
class DiskInfo(SchemaBase):
|
||||
"""磁盘信息"""
|
||||
|
||||
dir: str = Field(description='挂载点')
|
||||
device: str = Field(description='设备名称')
|
||||
type: str = Field(description='文件系统类型')
|
||||
total: str = Field(description='总容量')
|
||||
used: str = Field(description='已使用')
|
||||
free: str = Field(description='可用')
|
||||
usage: str = Field(description='使用率(%)')
|
||||
|
||||
|
||||
class ServiceInfo(SchemaBase):
|
||||
"""服务进程信息"""
|
||||
|
||||
name: str = Field(description='服务名称')
|
||||
version: str = Field(description='版本')
|
||||
home: str = Field(description='安装路径')
|
||||
startup: str = Field(description='启动时间')
|
||||
elapsed: str = Field(description='运行时长')
|
||||
cpu_usage: str = Field(description='CPU 使用率')
|
||||
mem_vms: str = Field(description='虚拟内存')
|
||||
mem_rss: str = Field(description='物理内存')
|
||||
mem_free: str = Field(description='可用内存')
|
||||
|
||||
|
||||
class ServerMonitorInfo(SchemaBase):
|
||||
"""服务器监控信息"""
|
||||
|
||||
cpu: CpuInfo = Field(description='CPU 信息')
|
||||
mem: MemInfo = Field(description='内存信息')
|
||||
sys: SysInfo = Field(description='系统信息')
|
||||
disk: list[DiskInfo] = Field(description='磁盘信息')
|
||||
service: ServiceInfo = Field(description='服务信息')
|
||||
|
||||
|
||||
class RedisServerInfo(SchemaBase):
|
||||
"""Redis 服务器信息"""
|
||||
|
||||
redis_version: str = Field(description='版本号')
|
||||
redis_mode: str = Field(description='运行模式')
|
||||
role: str = Field(description='节点角色')
|
||||
tcp_port: str = Field(description='监听端口')
|
||||
uptime: str = Field(description='运行时长')
|
||||
connected_clients: str = Field(description='已连接客户端数')
|
||||
blocked_clients: str = Field(description='阻塞客户端数')
|
||||
used_memory_human: str = Field(description='已使用内存')
|
||||
used_memory_rss_human: str = Field(description='RSS 内存')
|
||||
maxmemory_human: str = Field(description='最大内存限制')
|
||||
mem_fragmentation_ratio: str = Field(description='内存碎片率')
|
||||
instantaneous_ops_per_sec: str = Field(description='每秒操作数')
|
||||
total_commands_processed: str = Field(description='命令处理总数')
|
||||
rejected_connections: str = Field(description='拒绝连接数')
|
||||
keys_num: str = Field(description='键总数')
|
||||
|
||||
|
||||
class RedisCommandStat(SchemaBase):
|
||||
"""Redis 命令统计"""
|
||||
|
||||
name: str = Field(description='命令名称')
|
||||
value: str = Field(description='调用次数')
|
||||
|
||||
|
||||
class RedisMonitorInfo(SchemaBase):
|
||||
"""Redis 监控信息"""
|
||||
|
||||
info: RedisServerInfo = Field(description='服务器信息')
|
||||
stats: list[RedisCommandStat] = Field(description='命令统计')
|
||||
@@ -19,7 +19,7 @@ class OperaLogSchemaBase(SchemaBase):
|
||||
country: str | None = Field(None, description='国家')
|
||||
region: str | None = Field(None, description='地区')
|
||||
city: str | None = Field(None, description='城市')
|
||||
user_agent: str = Field(description='用户代理')
|
||||
user_agent: str | None = Field(description='用户代理')
|
||||
os: str | None = Field(None, description='操作系统')
|
||||
browser: str | None = Field(None, description='浏览器')
|
||||
device: str | None = Field(None, description='设备')
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.app.admin.schema.data_scope import GetDataScopeDetail
|
||||
from backend.app.admin.schema.data_scope import GetDataScopeWithRelationDetail
|
||||
from backend.app.admin.schema.menu import GetMenuDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
@@ -31,12 +31,26 @@ class DeleteRoleParam(SchemaBase):
|
||||
pks: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class CreateRoleMenuParam(SchemaBase):
|
||||
"""创建角色菜单参数"""
|
||||
|
||||
role_id: int = Field(description='角色 ID')
|
||||
menu_id: int = Field(description='菜单 ID')
|
||||
|
||||
|
||||
class UpdateRoleMenuParam(SchemaBase):
|
||||
"""更新角色菜单参数"""
|
||||
|
||||
menus: list[int] = Field(description='菜单 ID 列表')
|
||||
|
||||
|
||||
class CreateRoleScopeParam(SchemaBase):
|
||||
"""创建角色数据范围参数"""
|
||||
|
||||
role_id: int = Field(description='角色 ID')
|
||||
data_scope_id: int = Field(description='数据范围 ID')
|
||||
|
||||
|
||||
class UpdateRoleScopeParam(SchemaBase):
|
||||
"""更新角色数据范围参数"""
|
||||
|
||||
@@ -57,4 +71,4 @@ class GetRoleWithRelationDetail(GetRoleDetail):
|
||||
"""角色关联详情"""
|
||||
|
||||
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
|
||||
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
|
||||
scopes: list[GetDataScopeWithRelationDetail | None] = Field([], description='数据范围列表')
|
||||
|
||||
@@ -30,6 +30,7 @@ class GetNewToken(AccessTokenBase):
|
||||
class GetLoginToken(AccessTokenBase):
|
||||
"""获取登录令牌"""
|
||||
|
||||
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
|
||||
user: GetUserInfoDetail = Field(description='用户信息')
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import ConfigDict, Field, HttpUrl, model_validator
|
||||
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from backend.app.admin.schema.dept import GetDeptDetail
|
||||
from backend.app.admin.schema.role import GetRoleWithRelationDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
|
||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
|
||||
|
||||
|
||||
class AuthSchemaBase(SchemaBase):
|
||||
@@ -20,8 +20,8 @@ class AuthSchemaBase(SchemaBase):
|
||||
class AuthLoginParam(AuthSchemaBase):
|
||||
"""用户登录参数"""
|
||||
|
||||
uuid: str = Field(description='验证码 UUID')
|
||||
captcha: str = Field(description='验证码')
|
||||
uuid: str | None = Field(None, description='验证码 UUID')
|
||||
captcha: str | None = Field(None, description='验证码')
|
||||
|
||||
|
||||
class AddUserParam(AuthSchemaBase):
|
||||
@@ -34,13 +34,20 @@ class AddUserParam(AuthSchemaBase):
|
||||
roles: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class AddUserRoleParam(SchemaBase):
|
||||
"""添加用户角色"""
|
||||
|
||||
user_id: int = Field(description='用户 ID')
|
||||
role_id: int = Field(description='角色 ID')
|
||||
|
||||
|
||||
class AddOAuth2UserParam(AuthSchemaBase):
|
||||
"""添加 OAuth2 用户参数"""
|
||||
|
||||
password: str | None = Field(None, description='密码')
|
||||
nickname: str | None = Field(None, description='昵称')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
avatar: HttpUrl | None = Field(None, description='头像地址')
|
||||
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
|
||||
|
||||
|
||||
class ResetPasswordParam(SchemaBase):
|
||||
@@ -57,7 +64,7 @@ class UserInfoSchemaBase(SchemaBase):
|
||||
dept_id: int | None = Field(None, description='部门 ID')
|
||||
username: str = Field(description='用户名')
|
||||
nickname: str = Field(description='昵称')
|
||||
avatar: HttpUrl | None = Field(None, description='头像地址')
|
||||
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
phone: CustomPhoneNumber | None = Field(None, description='手机号')
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class UserPasswordHistoryBase(SchemaBase):
|
||||
"""用户历史密码记录基础模型"""
|
||||
|
||||
user_id: int = Field(description='用户 ID')
|
||||
password: str = Field(description='历史密码')
|
||||
|
||||
|
||||
class CreateUserPasswordHistoryParam(UserPasswordHistoryBase):
|
||||
"""创建用户历史密码记录"""
|
||||
@@ -9,6 +9,8 @@ from backend.app.admin.model import User
|
||||
from backend.app.admin.schema.token import GetLoginToken, GetNewToken
|
||||
from backend.app.admin.schema.user import AuthLoginParam
|
||||
from backend.app.admin.service.login_log_service import login_log_service
|
||||
from backend.app.admin.service.user_password_history_service import password_security_service
|
||||
from backend.app.admin.utils.password_security import password_verify
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import LoginLogStatusType
|
||||
from backend.common.exception import errors
|
||||
@@ -21,11 +23,11 @@ from backend.common.security.jwt import (
|
||||
create_refresh_token,
|
||||
get_token,
|
||||
jwt_decode,
|
||||
password_verify,
|
||||
)
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import uuid4_str
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_login_config
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -33,7 +35,7 @@ class AuthService:
|
||||
"""认证服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def user_verify(db: AsyncSession, username: str, password: str) -> User:
|
||||
async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
|
||||
"""
|
||||
验证用户名和密码
|
||||
|
||||
@@ -46,15 +48,19 @@ class AuthService:
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户名或密码有误')
|
||||
|
||||
if user.password is None:
|
||||
raise errors.AuthorizationError(msg='用户名或密码有误')
|
||||
if not password_verify(password, user.password):
|
||||
await password_security_service.check_status(user.id, user.status)
|
||||
|
||||
if user.password is None or not password_verify(password, user.password):
|
||||
await password_security_service.handle_login_failure(db, user.id)
|
||||
raise errors.AuthorizationError(msg='用户名或密码有误')
|
||||
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
days_remaining = await password_security_service.check_password_expiry_status(
|
||||
db, user.last_password_changed_time
|
||||
)
|
||||
|
||||
return user
|
||||
await password_security_service.handle_login_success(user.id)
|
||||
|
||||
return user, days_remaining
|
||||
|
||||
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
|
||||
"""
|
||||
@@ -64,15 +70,15 @@ class AuthService:
|
||||
:param obj: 登录凭证
|
||||
:return:
|
||||
"""
|
||||
user = await self.user_verify(db, obj.username, obj.password)
|
||||
user, _ = await self.user_verify(db, obj.username, obj.password)
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
access_token = await create_access_token(
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
swagger=True,
|
||||
)
|
||||
return access_token.access_token, user
|
||||
return access_token_data.access_token, user
|
||||
|
||||
async def login(
|
||||
self,
|
||||
@@ -86,7 +92,6 @@ class AuthService:
|
||||
用户登录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: 请求对象
|
||||
:param response: 响应对象
|
||||
:param obj: 登录参数
|
||||
:param background_tasks: 后台任务
|
||||
@@ -94,16 +99,21 @@ class AuthService:
|
||||
"""
|
||||
user = None
|
||||
try:
|
||||
user = await self.user_verify(db, obj.username, obj.password)
|
||||
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
|
||||
await load_login_config(db)
|
||||
if settings.LOGIN_CAPTCHA_ENABLED:
|
||||
if not obj.uuid or not obj.captcha:
|
||||
raise errors.RequestError(msg=t('error.captcha.invalid'))
|
||||
captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
|
||||
if not captcha_code:
|
||||
raise errors.RequestError(msg=t('error.captcha.expired'))
|
||||
if captcha_code.lower() != obj.captcha.lower():
|
||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
|
||||
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
|
||||
|
||||
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 = await create_access_token(
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
@@ -115,16 +125,16 @@ class AuthService:
|
||||
browser=ctx.browser,
|
||||
device=ctx.device,
|
||||
)
|
||||
refresh_token = await create_refresh_token(
|
||||
access_token.session_uuid,
|
||||
refresh_token_data = await create_refresh_token(
|
||||
access_token_data.session_uuid,
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=settings.COOKIE_REFRESH_TOKEN_KEY,
|
||||
value=refresh_token.refresh_token,
|
||||
value=refresh_token_data.refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(refresh_token.refresh_token_expire_time),
|
||||
expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
except errors.NotFoundError as e:
|
||||
@@ -135,7 +145,6 @@ class AuthService:
|
||||
log.error('登陆错误: 用户密码有误')
|
||||
task = BackgroundTask(
|
||||
login_log_service.create,
|
||||
db=db,
|
||||
user_uuid=user.uuid if user else uuid4_str(),
|
||||
username=obj.username,
|
||||
login_time=timezone.now(),
|
||||
@@ -149,7 +158,6 @@ class AuthService:
|
||||
else:
|
||||
background_tasks.add_task(
|
||||
login_log_service.create,
|
||||
db=db,
|
||||
user_uuid=user.uuid,
|
||||
username=obj.username,
|
||||
login_time=timezone.now(),
|
||||
@@ -157,9 +165,10 @@ class AuthService:
|
||||
msg=t('success.login.success'),
|
||||
)
|
||||
data = GetLoginToken(
|
||||
access_token=access_token.access_token,
|
||||
access_token_expire_time=access_token.access_token_expire_time,
|
||||
session_uuid=access_token.session_uuid,
|
||||
access_token=access_token_data.access_token,
|
||||
access_token_expire_time=access_token_data.access_token_expire_time,
|
||||
session_uuid=access_token_data.session_uuid,
|
||||
password_expire_days_remaining=days_remaining,
|
||||
user=user, # type: ignore
|
||||
)
|
||||
return data
|
||||
@@ -208,7 +217,7 @@ class AuthService:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
if not user.is_multi_login and await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
|
||||
if not user.is_multi_login and await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
|
||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||
new_token = await create_new_token(
|
||||
refresh_token,
|
||||
@@ -254,7 +263,7 @@ class AuthService:
|
||||
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
if refresh_token:
|
||||
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
|
||||
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
|
||||
|
||||
auth_service: AuthService = AuthService()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Table
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_data_rule import data_rule_dao
|
||||
@@ -11,10 +12,11 @@ from backend.app.admin.schema.data_rule import (
|
||||
GetDataRuleColumnDetail,
|
||||
UpdateDataRuleParam,
|
||||
)
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.common.security.permission import get_data_permission_models
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.import_parse import dynamic_import_data_model
|
||||
|
||||
|
||||
class DataRuleService:
|
||||
@@ -38,7 +40,8 @@ class DataRuleService:
|
||||
@staticmethod
|
||||
async def get_models() -> list[str]:
|
||||
"""获取所有数据规则可用模型"""
|
||||
return list(settings.DATA_PERMISSION_MODELS.keys())
|
||||
model_exclude = ['DataScope', 'DataRule', 'sys_role_data_scope', 'sys_data_scope_rule']
|
||||
return [m for m in list(get_data_permission_models().keys()) if m not in model_exclude]
|
||||
|
||||
@staticmethod
|
||||
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
|
||||
@@ -48,13 +51,15 @@ class DataRuleService:
|
||||
:param model: 模型名称
|
||||
:return:
|
||||
"""
|
||||
if model not in settings.DATA_PERMISSION_MODELS:
|
||||
available_models = get_data_permission_models()
|
||||
if model not in available_models:
|
||||
raise errors.NotFoundError(msg='数据规则可用模型不存在')
|
||||
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
|
||||
model_ins = available_models[model]
|
||||
|
||||
table = model_ins if isinstance(model_ins, Table) else model_ins.__table__
|
||||
model_columns = [
|
||||
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
|
||||
for column in model_ins.__table__.columns
|
||||
for column in table.columns
|
||||
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
|
||||
]
|
||||
return model_columns
|
||||
@@ -113,6 +118,7 @@ class DataRuleService:
|
||||
if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='数据规则已存在')
|
||||
count = await data_rule_dao.update(db, pk, obj)
|
||||
await user_cache_manager.clear_by_data_rule_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -125,6 +131,7 @@ class DataRuleService:
|
||||
:return:
|
||||
"""
|
||||
count = await data_rule_dao.delete(db, obj.pks)
|
||||
await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_data_rule import data_rule_dao
|
||||
from backend.app.admin.crud.crud_data_scope import data_scope_dao
|
||||
from backend.app.admin.model import DataScope
|
||||
from backend.app.admin.schema.data_scope import (
|
||||
@@ -11,10 +12,9 @@ from backend.app.admin.schema.data_scope import (
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
|
||||
class DataScopeService:
|
||||
@@ -57,7 +57,7 @@ class DataScopeService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
data_scope = await data_scope_dao.get_with_relation(db, pk)
|
||||
data_scope = await data_scope_dao.get_join(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
return data_scope
|
||||
@@ -105,9 +105,7 @@ class DataScopeService:
|
||||
if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='数据范围已存在')
|
||||
count = await data_scope_dao.update(db, pk, obj)
|
||||
for role in await data_scope.awaitable_attrs.roles:
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_data_scope_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -115,11 +113,20 @@ class DataScopeService:
|
||||
"""
|
||||
更新数据范围规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:param rule_ids: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
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:
|
||||
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])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -132,12 +139,7 @@ class DataScopeService:
|
||||
:return:
|
||||
"""
|
||||
count = await data_scope_dao.delete(db, obj.pks)
|
||||
for pk in obj.pks:
|
||||
data_rule = await data_scope_dao.get(db, pk)
|
||||
if data_rule:
|
||||
for role in await data_rule.awaitable_attrs.roles:
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_data_scope_id(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import ColumnElement
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_dept import dept_dao
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.build_tree import get_tree_data
|
||||
|
||||
|
||||
@@ -34,7 +32,7 @@ class DeptService:
|
||||
async def get_tree(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
request: Request,
|
||||
data_filter: ColumnElement[bool],
|
||||
name: str | None,
|
||||
leader: str | None,
|
||||
phone: str | None,
|
||||
@@ -44,15 +42,14 @@ class DeptService:
|
||||
获取部门树形结构
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param data_filter: 请求用户
|
||||
:param name: 部门名称
|
||||
:param leader: 部门负责人
|
||||
:param phone: 联系电话
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
|
||||
dept_select = await dept_dao.get_all(request, db, name, leader, phone, status)
|
||||
dept_select = await dept_dao.get_all(db, data_filter, name, leader, phone, status)
|
||||
tree_data = get_tree_data(dept_select)
|
||||
return tree_data
|
||||
|
||||
@@ -68,7 +65,7 @@ class DeptService:
|
||||
dept = await dept_dao.get_by_name(db, obj.name)
|
||||
if dept:
|
||||
raise errors.ConflictError(msg='部门名称已存在')
|
||||
if obj.parent_id:
|
||||
if obj.parent_id is not None:
|
||||
parent_dept = await dept_dao.get(db, obj.parent_id)
|
||||
if not parent_dept:
|
||||
raise errors.NotFoundError(msg='父级部门不存在')
|
||||
@@ -107,15 +104,15 @@ class DeptService:
|
||||
:param pk: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
dept = await dept_dao.get_with_relation(db, pk)
|
||||
dept = await dept_dao.get_join(db, pk)
|
||||
if not dept:
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
if dept.users:
|
||||
raise errors.ConflictError(msg='部门下存在用户,无法删除')
|
||||
children = await dept_dao.get_children(db, pk)
|
||||
if children:
|
||||
raise errors.ConflictError(msg='部门下存在子部门,无法删除')
|
||||
count = await dept_dao.delete(db, pk)
|
||||
for user in dept.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginL
|
||||
from backend.common.context import ctx
|
||||
from backend.common.log import log
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.database.db import async_db_session
|
||||
|
||||
|
||||
class LoginLogService:
|
||||
@@ -30,7 +31,6 @@ class LoginLogService:
|
||||
@staticmethod
|
||||
async def create(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user_uuid: str,
|
||||
username: str,
|
||||
login_time: datetime,
|
||||
@@ -40,7 +40,6 @@ class LoginLogService:
|
||||
"""
|
||||
创建登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_uuid: 用户 UUID
|
||||
:param username: 用户名
|
||||
:param login_time: 登录时间
|
||||
@@ -64,6 +63,8 @@ class LoginLogService:
|
||||
msg=msg,
|
||||
login_time=login_time,
|
||||
)
|
||||
# 为后台任务创建独立数据库会话
|
||||
async with async_db_session.begin() as db:
|
||||
await login_log_dao.create(db, obj)
|
||||
except Exception as e:
|
||||
log.error(f'登录日志创建失败: {e}')
|
||||
|
||||
@@ -6,9 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.app.admin.crud.crud_menu import menu_dao
|
||||
from backend.app.admin.model import Menu
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
|
||||
|
||||
|
||||
@@ -54,7 +53,7 @@ class MenuService:
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
|
||||
menu_data = None
|
||||
if request.user.is_superuser:
|
||||
menu_data = await menu_dao.get_sidebar(db, None)
|
||||
else:
|
||||
@@ -64,8 +63,11 @@ class MenuService:
|
||||
for role in roles:
|
||||
menu_ids.update(menu.id for menu in role.menus)
|
||||
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
|
||||
menu_tree = get_vben5_tree_data(menu_data)
|
||||
return menu_tree
|
||||
|
||||
if menu_data:
|
||||
return get_vben5_tree_data(menu_data)
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
|
||||
@@ -109,9 +111,7 @@ class MenuService:
|
||||
if obj.parent_id == menu.id:
|
||||
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
||||
count = await menu_dao.update(db, pk, obj)
|
||||
for role in await menu.awaitable_attrs.roles:
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_menu_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -127,12 +127,9 @@ class MenuService:
|
||||
children = await menu_dao.get_children(db, pk)
|
||||
if children:
|
||||
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
|
||||
menu = await menu_dao.get(db, pk)
|
||||
count = await menu_dao.delete(db, pk)
|
||||
if menu:
|
||||
for role in await menu.awaitable_attrs.roles:
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
if count:
|
||||
await user_cache_manager.clear_by_menu_id(db, [pk])
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.database.redis import redis_client
|
||||
from backend.plugin.tools import uninstall_requirements_async
|
||||
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
|
||||
from backend.plugin.installer import install_git_plugin, install_zip_plugin
|
||||
from backend.plugin.requirements import uninstall_requirements_async
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ class PluginService:
|
||||
:param repo_url: git 仓库地址
|
||||
:return:
|
||||
"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.RequestError(msg='禁止在非开发环境下安装插件')
|
||||
if type == PluginType.zip:
|
||||
if not file:
|
||||
raise errors.RequestError(msg='ZIP 压缩包不能为空')
|
||||
@@ -64,6 +66,8 @@ class PluginService:
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.RequestError(msg='禁止在非开发环境下卸载插件')
|
||||
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
|
||||
if not await plugin_dir.exists():
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
@@ -71,7 +75,7 @@ class PluginService:
|
||||
bacup_dir = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup'
|
||||
shutil.move(plugin_dir, bacup_dir)
|
||||
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
@staticmethod
|
||||
async def update_status(*, plugin: str) -> None:
|
||||
|
||||
@@ -14,10 +14,9 @@ from backend.app.admin.schema.role import (
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.build_tree import get_tree_data
|
||||
|
||||
|
||||
@@ -34,7 +33,7 @@ class RoleService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get_with_relation(db, pk)
|
||||
role = await role_dao.get_join(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
return role
|
||||
@@ -74,10 +73,11 @@ class RoleService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get_with_relation(db, pk)
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
menu_tree = get_tree_data(role.menus) if role.menus else []
|
||||
menus = await role_dao.get_menus(db, pk)
|
||||
menu_tree = get_tree_data(menus) if menus else []
|
||||
return menu_tree
|
||||
|
||||
@staticmethod
|
||||
@@ -90,7 +90,7 @@ class RoleService:
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get_with_relation(db, pk)
|
||||
role = await role_dao.get_join(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
scope_ids = [scope.id for scope in role.scopes]
|
||||
@@ -128,8 +128,7 @@ class RoleService:
|
||||
if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='角色已存在')
|
||||
count = await role_dao.update(db, pk, obj)
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -151,8 +150,7 @@ class RoleService:
|
||||
if not menu:
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
count = await role_dao.update_menus(db, pk, menu_ids)
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -174,8 +172,7 @@ class RoleService:
|
||||
if not scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
count = await role_dao.update_scopes(db, pk, scope_ids)
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
@@ -189,11 +186,7 @@ class RoleService:
|
||||
"""
|
||||
|
||||
count = await role_dao.delete(db, obj.pks)
|
||||
for pk in obj.pks:
|
||||
role = await role_dao.get(db, pk)
|
||||
if role:
|
||||
for user in await role.awaitable_attrs.users:
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
await user_cache_manager.clear_by_role_id(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import math
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
|
||||
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_user_security_config
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class UserPasswordHistoryService:
|
||||
"""用户密码历史服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def check_status(user_id: int, user_status: int) -> None:
|
||||
"""
|
||||
检查用户状态
|
||||
|
||||
:param user_id: 用户 ID
|
||||
:param user_status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
if not user_status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
|
||||
locked_until_str = await redis_client.get(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
|
||||
|
||||
if locked_until_str:
|
||||
locked_until = timezone.from_str(locked_until_str)
|
||||
now = timezone.now()
|
||||
if locked_until > now:
|
||||
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
|
||||
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
|
||||
|
||||
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||
|
||||
@staticmethod
|
||||
async def handle_login_failure(db: AsyncSession, user_id: int) -> None:
|
||||
"""
|
||||
处理登录失败
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
await load_user_security_config(db)
|
||||
|
||||
if settings.USER_LOCK_THRESHOLD == 0:
|
||||
return
|
||||
|
||||
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(
|
||||
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
|
||||
settings.USER_LOCK_SECONDS,
|
||||
str(failure_count),
|
||||
)
|
||||
|
||||
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
||||
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
||||
await redis_client.setex(
|
||||
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
|
||||
settings.USER_LOCK_SECONDS,
|
||||
timezone.to_str(locked_until),
|
||||
)
|
||||
raise errors.AuthorizationError(msg='登录失败次数过多,账号已被锁定')
|
||||
|
||||
@staticmethod
|
||||
async def check_password_expiry_status(db: AsyncSession, password_changed_time: datetime) -> int | None:
|
||||
"""
|
||||
检查密码过期状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param password_changed_time: 密码修改时间
|
||||
:return:
|
||||
"""
|
||||
await load_user_security_config(db)
|
||||
|
||||
if settings.USER_PASSWORD_EXPIRY_DAYS == 0:
|
||||
return None
|
||||
|
||||
if not password_changed_time:
|
||||
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
|
||||
|
||||
expiry_time = password_changed_time + timedelta(days=settings.USER_PASSWORD_EXPIRY_DAYS)
|
||||
days_remaining = (expiry_time - timezone.now()).days
|
||||
|
||||
if days_remaining < 0:
|
||||
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
|
||||
|
||||
if days_remaining <= settings.USER_PASSWORD_REMINDER_DAYS:
|
||||
return days_remaining
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def handle_login_success(user_id: int) -> None:
|
||||
"""
|
||||
处理登录成功
|
||||
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||
|
||||
@staticmethod
|
||||
async def save_password_history(db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
|
||||
"""
|
||||
保存密码历史记录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建密码历史记录参数
|
||||
:return:
|
||||
"""
|
||||
await user_password_history_dao.create(db, obj)
|
||||
|
||||
|
||||
password_security_service: UserPasswordHistoryService = UserPasswordHistoryService()
|
||||
@@ -1,5 +1,3 @@
|
||||
import random
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -15,14 +13,18 @@ from backend.app.admin.schema.user import (
|
||||
ResetPasswordParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
|
||||
from backend.app.admin.service.user_password_history_service import password_security_service
|
||||
from backend.app.admin.utils.password_security import password_verify, validate_new_password
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import UserPermissionType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.common.response.response_code import CustomErrorCode
|
||||
from backend.common.security.jwt import get_token, jwt_decode, password_verify
|
||||
from backend.common.security.jwt import get_token, jwt_decode
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
|
||||
|
||||
class UserService:
|
||||
@@ -38,7 +40,7 @@ class UserService:
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_with_relation(db, user_id=pk, username=username)
|
||||
user = await user_dao.get_join(db, user_id=pk, username=username)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user
|
||||
@@ -52,7 +54,7 @@ class UserService:
|
||||
:param pk: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_with_relation(db, user_id=pk)
|
||||
user = await user_dao.get_join(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user.roles
|
||||
@@ -70,7 +72,12 @@ class UserService:
|
||||
:return:
|
||||
"""
|
||||
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
|
||||
return await paging_data(db, user_select)
|
||||
data = await paging_data(db, user_select)
|
||||
if data['items']:
|
||||
serialized_items = select_join_serialize(data['items'], relationships=['User-m2o-Dept', 'User-m2m-Role'])
|
||||
# 确保返回的是列表,即使只有一个元素
|
||||
data['items'] = [serialized_items] if not isinstance(serialized_items, list) else serialized_items
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
|
||||
@@ -83,7 +90,6 @@ class UserService:
|
||||
"""
|
||||
if await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
|
||||
if not obj.password:
|
||||
raise errors.RequestError(msg='密码不允许为空')
|
||||
if not await dept_dao.get(db, obj.dept_id):
|
||||
@@ -91,6 +97,7 @@ class UserService:
|
||||
for role_id in obj.roles:
|
||||
if not await role_dao.get(db, role_id):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
obj.nickname = obj.nickname or obj.username
|
||||
await user_dao.add(db, obj)
|
||||
|
||||
@staticmethod
|
||||
@@ -103,15 +110,17 @@ class UserService:
|
||||
:param obj: 用户更新参数
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_with_relation(db, user_id=pk)
|
||||
user = await user_dao.get_join(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
if obj.dept_id and obj.dept_id != user.dept_id and not await dept_dao.get(db, dept_id=obj.dept_id):
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
for role_id in obj.roles:
|
||||
if not await role_dao.get(db, role_id):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
count = await user_dao.update(db, user, obj)
|
||||
count = await user_dao.update(db, user.id, obj)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@@ -133,7 +142,7 @@ class UserService:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_super(db, pk, is_super=not user.status)
|
||||
count = await user_dao.set_super(db, pk, is_super=not user.is_superuser)
|
||||
case UserPermissionType.staff:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
@@ -189,104 +198,101 @@ class UserService:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
|
||||
await validate_new_password(db, user.id, password)
|
||||
count = await user_dao.reset_password(db, user.id, password)
|
||||
|
||||
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)
|
||||
await redis_client.delete_prefix(prefix)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_nickname(*, db: AsyncSession, request: Request, nickname: str) -> int:
|
||||
async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
更新当前用户昵称
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param user_id: 用户 ID
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
user = await user_dao.get(db, token_payload.id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
count = await user_dao.update_nickname(db, token_payload.id, nickname)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
count = await user_dao.update_nickname(db, user_id, nickname)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_avatar(*, db: AsyncSession, request: Request, avatar: str) -> int:
|
||||
async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
|
||||
"""
|
||||
更新当前用户头像
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param user_id: 用户 ID
|
||||
:param avatar: 头像地址
|
||||
:return:
|
||||
"""
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
user = await user_dao.get(db, token_payload.id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
count = await user_dao.update_avatar(db, token_payload.id, avatar)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
count = await user_dao.update_avatar(db, user_id, avatar)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_email(*, db: AsyncSession, request: Request, captcha: str, email: str) -> int:
|
||||
async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int:
|
||||
"""
|
||||
更新当前用户邮箱
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param user_id: 用户 ID
|
||||
:param captcha: 邮箱验证码
|
||||
:param email: 邮箱
|
||||
:return:
|
||||
"""
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
user = await user_dao.get(db, token_payload.id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
|
||||
if not captcha_code:
|
||||
raise errors.RequestError(msg='验证码已失效,请重新获取')
|
||||
if captcha != captcha_code:
|
||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
|
||||
count = await user_dao.update_email(db, token_payload.id, email)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
count = await user_dao.update_email(db, user_id, email)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_password(*, db: AsyncSession, request: Request, obj: ResetPasswordParam) -> int:
|
||||
async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
|
||||
"""
|
||||
更新当前用户密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param user_id: 用户 ID
|
||||
:param obj: 密码重置参数
|
||||
:return:
|
||||
"""
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
user = await user_dao.get(db, token_payload.id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if not password_verify(obj.old_password, user.password):
|
||||
user = await user_dao.get(db, user_id)
|
||||
|
||||
if user.password and not password_verify(obj.old_password, user.password):
|
||||
raise errors.RequestError(msg='原密码错误')
|
||||
|
||||
if obj.new_password != obj.confirm_password:
|
||||
raise errors.RequestError(msg='密码输入不一致')
|
||||
count = await user_dao.reset_password(db, user.id, obj.new_password)
|
||||
raise errors.RequestError(msg='两次密码输入不一致')
|
||||
|
||||
await validate_new_password(db, user_id, obj.new_password)
|
||||
count = await user_dao.reset_password(db, user_id, obj.new_password)
|
||||
|
||||
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}',
|
||||
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)
|
||||
@@ -308,6 +314,7 @@ class UserService:
|
||||
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)
|
||||
|
||||
@@ -2,14 +2,17 @@ from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio.session import AsyncSession
|
||||
|
||||
from backend.database.db import create_async_engine_and_session, create_database_url
|
||||
from backend.database.db import create_database_async_engine, create_database_async_session, create_database_url
|
||||
|
||||
# SQLA 数据库链接
|
||||
TEST_SQLALCHEMY_DATABASE_URL = create_database_url(unittest=True)
|
||||
|
||||
_, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL)
|
||||
# SALA 异步引擎和会话
|
||||
async_test_engine = create_database_async_engine(TEST_SQLALCHEMY_DATABASE_URL)
|
||||
async_test_db_session = create_database_async_session(async_test_engine)
|
||||
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""session 生成器"""
|
||||
"""获取数据库会话"""
|
||||
async with async_test_db_session() as session:
|
||||
yield session
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.model import data_scope_rule, role_data_scope, role_menu, user_role
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
|
||||
class UserCacheManager:
|
||||
"""用户缓存管理"""
|
||||
|
||||
@staticmethod
|
||||
async def clear(user_ids: Sequence[int]) -> None:
|
||||
"""
|
||||
清理用户缓存
|
||||
|
||||
:param user_ids: 用户 ID 列表
|
||||
:return:
|
||||
"""
|
||||
if user_ids:
|
||||
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids])
|
||||
|
||||
async def clear_by_role_id(self, db: AsyncSession, role_ids: list[int]) -> None:
|
||||
"""
|
||||
通过角色 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = select(user_role.c.user_id).where(user_role.c.role_id.in_(role_ids)).distinct()
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
async def clear_by_menu_id(self, db: AsyncSession, menu_ids: list[int]) -> None:
|
||||
"""
|
||||
通过菜单 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = (
|
||||
select(user_role.c.user_id)
|
||||
.join(role_menu, user_role.c.role_id == role_menu.c.role_id)
|
||||
.where(role_menu.c.menu_id.in_(menu_ids))
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
async def clear_by_data_scope_id(self, db: AsyncSession, scope_ids: list[int]) -> None:
|
||||
"""
|
||||
通过数据范围 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param scope_ids: 数据范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = (
|
||||
select(user_role.c.user_id)
|
||||
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
|
||||
.where(role_data_scope.c.data_scope_id.in_(scope_ids))
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
async def clear_by_data_rule_id(self, db: AsyncSession, rule_ids: list[int]) -> None:
|
||||
"""
|
||||
通过数据规则 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param rule_ids: 数据规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = (
|
||||
select(user_role.c.user_id)
|
||||
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
|
||||
.join(data_scope_rule, role_data_scope.c.data_scope_id == data_scope_rule.c.data_scope_id)
|
||||
.where(data_scope_rule.c.data_rule_id.in_(rule_ids))
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
|
||||
user_cache_manager: UserCacheManager = UserCacheManager()
|
||||
@@ -0,0 +1,68 @@
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.dynamic_config import load_user_security_config
|
||||
from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char
|
||||
|
||||
password_hash = PasswordHash((BcryptHasher(),))
|
||||
|
||||
|
||||
def get_hash_password(password: str, salt: bytes | None) -> str:
|
||||
"""
|
||||
使用哈希算法加密密码
|
||||
|
||||
:param password: 密码
|
||||
:param salt: 盐值
|
||||
:return:
|
||||
"""
|
||||
return password_hash.hash(password, salt=salt)
|
||||
|
||||
|
||||
def password_verify(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
密码验证
|
||||
|
||||
:param plain_password: 待验证的密码
|
||||
:param hashed_password: 哈希密码
|
||||
:return:
|
||||
"""
|
||||
return password_hash.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
async def validate_new_password(db: AsyncSession, user_id: int, new_password: str) -> None:
|
||||
"""
|
||||
验证新密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户ID
|
||||
:param new_password: 新密码
|
||||
:return:
|
||||
"""
|
||||
await load_user_security_config(db)
|
||||
|
||||
if len(new_password) < settings.USER_PASSWORD_MIN_LENGTH:
|
||||
raise errors.RequestError(msg=f'密码长度不能少于 {settings.USER_PASSWORD_MIN_LENGTH} 个字符')
|
||||
|
||||
if len(new_password) > settings.USER_PASSWORD_MAX_LENGTH:
|
||||
raise errors.RequestError(msg=f'密码长度不能超过 {settings.USER_PASSWORD_MAX_LENGTH} 个字符')
|
||||
|
||||
if not is_has_number(new_password):
|
||||
raise errors.RequestError(msg='密码必须包含数字')
|
||||
|
||||
if not is_has_letter(new_password):
|
||||
raise errors.RequestError(msg='密码必须包含字母')
|
||||
|
||||
if settings.USER_PASSWORD_REQUIRE_SPECIAL_CHAR and not is_has_special_char(new_password):
|
||||
raise errors.RequestError(msg='密码必须包含特殊字符(如:!@#$%)')
|
||||
|
||||
password_history = await user_password_history_dao.get_by_user_id(db, user_id)
|
||||
|
||||
for hist in password_history[: settings.USER_PASSWORD_HISTORY_CHECK_COUNT]:
|
||||
if password_verify(new_password, hist.password):
|
||||
raise errors.RequestError(
|
||||
msg=f'新密码不能与最近 {settings.USER_PASSWORD_HISTORY_CHECK_COUNT} 次使用的密码相同'
|
||||
)
|
||||
@@ -16,8 +16,7 @@
|
||||
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
|
||||
|
||||
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
|
||||
2. 新建目录后,务必更新 `conf.py` 配置中的 `CELERY_TASKS_PACKAGES`,将新建目录模块路径添加到此列表
|
||||
3. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
|
||||
2. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
|
||||
|
||||
## 消息代理
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import sys
|
||||
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
|
||||
|
||||
from .actions import * # noqa: F403
|
||||
|
||||
# 导入项目根目录
|
||||
sys.path.append(str(BASE_PATH.parent))
|
||||
|
||||
@@ -5,8 +5,8 @@ from backend.app.task.api.v1.result import router as task_result_router
|
||||
from backend.app.task.api.v1.scheduler import router as task_scheduler_router
|
||||
from backend.core.conf import settings
|
||||
|
||||
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/tasks', tags=['任务'])
|
||||
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH, tags=['任务'])
|
||||
|
||||
v1.include_router(task_control_router)
|
||||
v1.include_router(task_result_router, prefix='/results')
|
||||
v1.include_router(task_control_router, prefix='/tasks')
|
||||
v1.include_router(task_result_router, prefix='/task-results')
|
||||
v1.include_router(task_scheduler_router, prefix='/schedulers')
|
||||
|
||||
@@ -117,8 +117,8 @@ async def delete_task_scheduler(
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{pk}/executions',
|
||||
summary='手动执行任务',
|
||||
'/{pk}/execute',
|
||||
summary='执行任务',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:task:exec')),
|
||||
DependsRBAC,
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
import celery
|
||||
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.common.enums import DataBaseType
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.utils.otel import init_resource, init_tracer
|
||||
|
||||
|
||||
@worker_process_init.connect(weak=False)
|
||||
def init_celery_worker_tracing(*args, **kwargs) -> None:
|
||||
"""初始化 Celery 追踪"""
|
||||
if settings.GRAFANA_METRICS_ENABLE:
|
||||
resource = init_resource('fba_celery_worker')
|
||||
init_tracer(resource)
|
||||
CeleryInstrumentor().instrument()
|
||||
|
||||
|
||||
def find_task_packages() -> list[str]:
|
||||
@@ -27,15 +42,20 @@ def init_celery() -> celery.Celery:
|
||||
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
|
||||
celery.app.trace.reset_worker_optimizations()
|
||||
|
||||
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{urllib.parse.quote(settings.CELERY_RABBITMQ_PASSWORD)}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}/{settings.CELERY_RABBITMQ_VHOST}'
|
||||
if settings.CELERY_BROKER == 'redis':
|
||||
broker_url = f'redis://:{urllib.parse.quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
|
||||
|
||||
result_backend = f'db+postgresql+psycopg://{settings.DATABASE_USER}:{urllib.parse.quote(settings.DATABASE_PASSWORD)}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}'
|
||||
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
|
||||
app = celery.Celery(
|
||||
'fba_celery',
|
||||
broker_url=f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
|
||||
if settings.CELERY_BROKER == 'redis'
|
||||
else f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}',
|
||||
broker_url=broker_url,
|
||||
broker_connection_retry_on_startup=True,
|
||||
result_backend=f'db+{settings.DATABASE_TYPE}+{"pymysql" if settings.DATABASE_TYPE == "mysql" else "psycopg"}'
|
||||
f'://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}',
|
||||
result_backend=result_backend,
|
||||
result_extended=True,
|
||||
database_engine_options={'echo': settings.DATABASE_ECHO},
|
||||
# result_expires=0,
|
||||
@@ -46,6 +66,8 @@ def init_celery() -> celery.Celery:
|
||||
task_track_started=True,
|
||||
enable_utc=False,
|
||||
timezone=settings.DATETIME_TIMEZONE,
|
||||
worker_send_task_events=True,
|
||||
task_send_sent_event=True,
|
||||
)
|
||||
|
||||
# 在 Celery 中设置此参数无效
|
||||
|
||||
@@ -9,13 +9,11 @@ from sqlalchemy.orm import Session
|
||||
from backend.app.task.model.result import Task, TaskExtended, TaskSet
|
||||
from backend.app.task.session import SessionManager
|
||||
|
||||
"""
|
||||
重写 from celery.backends.database 内部 DatabaseBackend 类,此类实现与模型配合不佳,导致 fba 创建表和 alembic 迁移困难
|
||||
"""
|
||||
|
||||
|
||||
class DatabaseBackend(BaseBackend):
|
||||
"""The database result backend."""
|
||||
"""
|
||||
重写 celery.backends.database DatabaseBackend,此类实现与模型配合不佳,导致 fba 创建表和 alembic 迁移困难
|
||||
"""
|
||||
|
||||
# ResultSet.iterate should sleep this much between each pool,
|
||||
# to not bombard the database with queries.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from celery import states
|
||||
from sqlalchemy.types import PickleType
|
||||
|
||||
from backend.common.model import MappedBase
|
||||
from backend.common.model import MappedBase, TimeZone
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
"""
|
||||
重写 celery.backends.database.models 内部所有模型,适配 fba 创建表和 alembic 迁移
|
||||
@@ -20,12 +19,12 @@ class Task(MappedBase):
|
||||
|
||||
id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True)
|
||||
task_id = sa.Column(sa.String(155), unique=True)
|
||||
status = sa.Column(sa.String(50), default=states.PENDING)
|
||||
status = sa.Column(sa.String(64), default=states.PENDING)
|
||||
result = sa.Column(PickleType, nullable=True)
|
||||
date_done = sa.Column(
|
||||
sa.DateTime,
|
||||
default=datetime.now(timezone.utc),
|
||||
onupdate=datetime.now(timezone.utc),
|
||||
TimeZone,
|
||||
default=timezone.now,
|
||||
onupdate=timezone.now,
|
||||
nullable=True,
|
||||
)
|
||||
traceback = sa.Column(sa.Text, nullable=True)
|
||||
@@ -87,7 +86,7 @@ class TaskSet(MappedBase):
|
||||
id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True)
|
||||
taskset_id = sa.Column(sa.String(155), unique=True)
|
||||
result = sa.Column(PickleType, nullable=True)
|
||||
date_done = sa.Column(sa.DateTime, default=datetime.now(timezone.utc), nullable=True)
|
||||
date_done = sa.Column(TimeZone, default=timezone.now, nullable=True)
|
||||
|
||||
def __init__(self, taskset_id, result) -> None: # noqa: ANN001
|
||||
self.taskset_id = taskset_id
|
||||
|
||||
@@ -20,20 +20,20 @@ class TaskScheduler(Base):
|
||||
__tablename__ = 'task_scheduler'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(50), unique=True, comment='任务名称')
|
||||
task: Mapped[str] = mapped_column(sa.String(255), comment='要运行的 Celery 任务')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='任务名称')
|
||||
task: Mapped[str] = mapped_column(sa.String(256), comment='要运行的 Celery 任务')
|
||||
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的位置参数')
|
||||
kwargs: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的关键字参数')
|
||||
queue: Mapped[str | None] = mapped_column(sa.String(255), comment='CELERY_TASK_QUEUES 中定义的队列')
|
||||
exchange: Mapped[str | None] = mapped_column(sa.String(255), comment='低级别 AMQP 路由的交换机')
|
||||
routing_key: Mapped[str | None] = mapped_column(sa.String(255), comment='低级别 AMQP 路由的路由密钥')
|
||||
queue: Mapped[str | None] = mapped_column(sa.String(256), comment='CELERY_TASK_QUEUES 中定义的队列')
|
||||
exchange: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的交换机')
|
||||
routing_key: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的路由密钥')
|
||||
start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
|
||||
expire_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务不再触发的截止时间')
|
||||
expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差')
|
||||
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
|
||||
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
|
||||
interval_period: Mapped[str | None] = mapped_column(sa.String(255), comment='任务运行之间的周期类型')
|
||||
crontab: Mapped[str | None] = mapped_column(sa.String(50), default='* * * * *', comment='任务运行的 Crontab 计划')
|
||||
interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型')
|
||||
crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='任务运行的 Crontab 计划')
|
||||
one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次')
|
||||
enabled: Mapped[bool] = mapped_column(default=True, comment='是否启用任务')
|
||||
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
|
||||
|
||||
@@ -23,7 +23,7 @@ from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils._await import run_await
|
||||
from backend.utils.async_helper import run_await
|
||||
from backend.utils.serializers import select_as_dict
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# work && beat
|
||||
celery -A backend.app.task.celery worker -l info -P gevent -c 100 &
|
||||
|
||||
# beat
|
||||
celery -A backend.app.task.celery beat -l info &
|
||||
|
||||
# flower
|
||||
celery -A backend.app.task.celery flower --port=8555 --basic-auth=admin:123456
|
||||
+492
-34
@@ -1,32 +1,51 @@
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import anyio
|
||||
import cappa
|
||||
import granian
|
||||
|
||||
from cappa.output import error_format
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import IntPrompt
|
||||
from rich.prompt import IntPrompt, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from sqlalchemy import text
|
||||
from watchfiles import PythonFilter
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
|
||||
from watchfiles import Change, PythonFilter
|
||||
|
||||
from backend import __version__
|
||||
from backend.common.enums import DataBaseType, PrimaryKeyType
|
||||
from backend.common.exception.errors import BaseExceptionError
|
||||
from backend.common.model import MappedBase
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
from backend.plugin.code_generator.schema.code import ImportParam
|
||||
from backend.plugin.code_generator.service.business_service import gen_business_service
|
||||
from backend.plugin.code_generator.service.code_service import gen_service
|
||||
from backend.plugin.tools import get_plugin_sql
|
||||
from backend.utils._await import run_await
|
||||
from backend.core.path_conf import (
|
||||
BASE_PATH,
|
||||
ENV_EXAMPLE_FILE_PATH,
|
||||
ENV_FILE_PATH,
|
||||
MYSQL_SCRIPT_DIR,
|
||||
POSTGRESQL_SCRIPT_DIR,
|
||||
RELOAD_LOCK_FILE,
|
||||
)
|
||||
from backend.database.db import (
|
||||
async_db_session,
|
||||
create_database_async_engine,
|
||||
create_database_async_session,
|
||||
create_database_url,
|
||||
)
|
||||
from backend.database.redis import RedisCli, redis_client
|
||||
from backend.plugin.core import get_plugin_sql, get_plugins
|
||||
from backend.plugin.installer import install_git_plugin, install_zip_plugin
|
||||
from backend.utils.console import console
|
||||
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
from backend.utils.sql_parser import parse_sql_script
|
||||
|
||||
output_help = '\n更多信息,尝试 "[cyan]--help[/]"'
|
||||
|
||||
@@ -37,6 +56,215 @@ class CustomReloadFilter(PythonFilter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])
|
||||
|
||||
def __call__(self, change: Change, path: str) -> bool:
|
||||
if RELOAD_LOCK_FILE.exists():
|
||||
return False
|
||||
return super().__call__(change, path)
|
||||
|
||||
|
||||
def setup_env_file() -> bool:
|
||||
if not ENV_EXAMPLE_FILE_PATH.exists():
|
||||
console.print('.env.example 文件不存在', style='red')
|
||||
return False
|
||||
|
||||
try:
|
||||
env_content = Path(ENV_EXAMPLE_FILE_PATH).read_text(encoding='utf-8')
|
||||
console.print('配置数据库连接信息...', style='white')
|
||||
db_type = Prompt.ask('数据库类型', choices=['mysql', 'postgresql'], default='postgresql')
|
||||
db_host = Prompt.ask('数据库主机', default='127.0.0.1')
|
||||
db_port = Prompt.ask('数据库端口', default='5432' if db_type == 'postgresql' else '3306')
|
||||
db_user = Prompt.ask('数据库用户名', default='postgres' if db_type == 'postgresql' else 'root')
|
||||
db_password = Prompt.ask('数据库密码', password=True, default='123456')
|
||||
|
||||
console.print('配置 Redis 连接信息...', style='white')
|
||||
redis_host = Prompt.ask('Redis 主机', default='127.0.0.1')
|
||||
redis_port = Prompt.ask('Redis 端口', default='6379')
|
||||
redis_password = Prompt.ask('Redis 密码(留空表示无密码)', password=True, default='')
|
||||
redis_db = Prompt.ask('Redis 数据库编号', default='0')
|
||||
|
||||
console.print('生成 Token 密钥...', style='white')
|
||||
token_secret = secrets.token_urlsafe(32)
|
||||
|
||||
console.print('写入 .env 文件...', style='white')
|
||||
env_content = env_content.replace("DATABASE_TYPE='postgresql'", f"DATABASE_TYPE='{db_type}'")
|
||||
settings.DATABASE_TYPE = db_type
|
||||
env_content = env_content.replace("DATABASE_HOST='127.0.0.1'", f"DATABASE_HOST='{db_host}'")
|
||||
settings.DATABASE_HOST = db_host
|
||||
env_content = env_content.replace('DATABASE_PORT=5432', f'DATABASE_PORT={db_port}')
|
||||
settings.DATABASE_PORT = db_port
|
||||
env_content = env_content.replace("DATABASE_USER='postgres'", f"DATABASE_USER='{db_user}'")
|
||||
settings.DATABASE_USER = db_user
|
||||
env_content = env_content.replace("DATABASE_PASSWORD='123456'", f"DATABASE_PASSWORD='{db_password}'")
|
||||
settings.DATABASE_PASSWORD = db_password
|
||||
env_content = env_content.replace("REDIS_HOST='127.0.0.1'", f"REDIS_HOST='{redis_host}'")
|
||||
settings.REDIS_HOST = redis_host
|
||||
env_content = env_content.replace('REDIS_PORT=6379', f'REDIS_PORT={redis_port}')
|
||||
settings.REDIS_PORT = redis_port
|
||||
env_content = env_content.replace("REDIS_PASSWORD=''", f"REDIS_PASSWORD='{redis_password}'")
|
||||
settings.REDIS_PASSWORD = redis_password
|
||||
env_content = env_content.replace('REDIS_DATABASE=0', f'REDIS_DATABASE={redis_db}')
|
||||
settings.REDIS_DATABASE = redis_db
|
||||
env_content = re.sub(r"TOKEN_SECRET_KEY='[^']*'", f"TOKEN_SECRET_KEY='{token_secret}'", env_content)
|
||||
settings.TOKEN_SECRET_KEY = token_secret
|
||||
|
||||
Path(ENV_FILE_PATH).write_text(env_content, encoding='utf-8')
|
||||
console.print('.env 文件创建成功', style='green')
|
||||
except Exception as e:
|
||||
console.print(f'.env 文件创建失败: {e}', style='red')
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def create_database(conn: AsyncConnection) -> bool:
|
||||
try:
|
||||
terminate_sql = None
|
||||
if DataBaseType.mysql == settings.DATABASE_TYPE:
|
||||
check_sql = f"SHOW DATABASES LIKE '{settings.DATABASE_SCHEMA}'"
|
||||
drop_sql = f'DROP DATABASE IF EXISTS `{settings.DATABASE_SCHEMA}`'
|
||||
create_sql = (
|
||||
f'CREATE DATABASE `{settings.DATABASE_SCHEMA}` CHARACTER SET {settings.DATABASE_CHARSET} '
|
||||
f'COLLATE {settings.DATABASE_CHARSET}_unicode_ci'
|
||||
)
|
||||
else:
|
||||
check_sql = f"SELECT 1 FROM pg_database WHERE datname = '{settings.DATABASE_SCHEMA}'"
|
||||
drop_sql = f'DROP DATABASE IF EXISTS {settings.DATABASE_SCHEMA}'
|
||||
create_sql = f'CREATE DATABASE {settings.DATABASE_SCHEMA}'
|
||||
terminate_sql = (
|
||||
f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
|
||||
f"WHERE datname = '{settings.DATABASE_SCHEMA}' AND pid <> pg_backend_pid()"
|
||||
)
|
||||
|
||||
result = await conn.execute(text(check_sql))
|
||||
exists = result.fetchone() is not None
|
||||
console.print(f'重建 {settings.DATABASE_SCHEMA} 数据库...', style='white')
|
||||
if exists:
|
||||
if terminate_sql:
|
||||
await conn.execute(text(terminate_sql))
|
||||
await conn.execute(text(drop_sql))
|
||||
await conn.execute(text(create_sql))
|
||||
console.print('数据库创建成功', style='green')
|
||||
except Exception as e:
|
||||
console.print(f'数据库创建失败: {e}', style='red')
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def auto_init() -> None:
|
||||
"""自动化初始化流程"""
|
||||
console.print('\n[bold cyan]步骤 1/3:[/] 配置环境变量', style='bold')
|
||||
panel_content = Text()
|
||||
panel_content.append('【环境变量配置】', style='bold green')
|
||||
panel_content.append('\n\n • 数据库连接信息')
|
||||
panel_content.append('\n • Redis 连接信息')
|
||||
panel_content.append('\n • Token 密钥(自动生成)')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 环境变量', border_style='cyan', padding=(1, 2)))
|
||||
if not setup_env_file():
|
||||
raise cappa.Exit('.env 文件配置失败', code=1)
|
||||
|
||||
console.print('\n[bold cyan]步骤 2/3:[/] 数据库创建', style='bold')
|
||||
panel_content = Text()
|
||||
panel_content.append('【数据库配置】', style='bold green')
|
||||
panel_content.append('\n\n • 类型: ')
|
||||
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
|
||||
panel_content.append('\n • 主机:')
|
||||
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
|
||||
panel_content.append('\n • 数据库:')
|
||||
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
|
||||
panel_content.append('\n • 主键模式:')
|
||||
panel_content.append(f'{settings.DATABASE_PK_MODE}', style='yellow')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 数据库', border_style='cyan', padding=(1, 2)))
|
||||
ok = Prompt.ask('即将[red]新建/重建数据库[/red],确认继续吗?', choices=['y', 'n'], default='n')
|
||||
|
||||
if ok.lower() == 'y':
|
||||
async_init_engine = create_database_async_engine(create_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):
|
||||
raise cappa.Exit('数据库创建失败', code=1)
|
||||
else:
|
||||
console.print('已取消数据库操作', style='yellow')
|
||||
|
||||
console.print('\n[bold cyan]步骤 3/3:[/] 初始化数据库表和数据', style='bold')
|
||||
async_init_engine = create_database_async_engine(create_database_url())
|
||||
async_init_db_session = create_database_async_session(async_init_engine)
|
||||
redis_init_client = RedisCli(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
password=settings.REDIS_PASSWORD,
|
||||
db=settings.REDIS_DATABASE,
|
||||
)
|
||||
await redis_init_client.init()
|
||||
async with async_init_db_session.begin() as db:
|
||||
await init(db, redis_init_client)
|
||||
|
||||
|
||||
async def init(db: AsyncSession, redis: RedisCli) -> None:
|
||||
panel_content = Text()
|
||||
panel_content.append('【数据库配置】', style='bold green')
|
||||
panel_content.append('\n\n • 类型: ')
|
||||
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
|
||||
panel_content.append('\n • 主机:')
|
||||
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
|
||||
panel_content.append('\n • 数据库:')
|
||||
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
|
||||
panel_content.append('\n • 主键模式:')
|
||||
panel_content.append(f'{settings.DATABASE_PK_MODE}', style='yellow')
|
||||
pk_details = panel_content.from_markup(
|
||||
'[link=https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html](了解详情)[/]'
|
||||
)
|
||||
panel_content.append(pk_details)
|
||||
panel_content.append('\n\n【Redis 配置】', style='bold green')
|
||||
panel_content.append('\n\n • 主机:')
|
||||
panel_content.append(f'{settings.REDIS_HOST}:{settings.REDIS_PORT}', style='yellow')
|
||||
panel_content.append('\n • 数据库:')
|
||||
panel_content.append(f'{settings.REDIS_DATABASE}', style='yellow')
|
||||
plugins = get_plugins()
|
||||
panel_content.append('\n\n【已安装插件】', style='bold green')
|
||||
panel_content.append('\n\n • ')
|
||||
if plugins:
|
||||
panel_content.append(f'{", ".join(plugins)}', style='yellow')
|
||||
else:
|
||||
panel_content.append('无', style='dim')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 初始化', border_style='cyan', padding=(1, 2)))
|
||||
ok = Prompt.ask(
|
||||
'即将[red]新建/重建数据库表[/red]并[red]执行所有数据库脚本[/red],确认继续吗?', choices=['y', 'n'], default='n'
|
||||
)
|
||||
|
||||
if ok.lower() == 'y':
|
||||
console.print('开始初始化...', style='white')
|
||||
try:
|
||||
console.print('清理 Redis 缓存', style='white')
|
||||
for prefix in [
|
||||
settings.JWT_USER_REDIS_PREFIX,
|
||||
settings.TOKEN_EXTRA_INFO_REDIS_PREFIX,
|
||||
settings.TOKEN_REDIS_PREFIX,
|
||||
settings.TOKEN_REFRESH_REDIS_PREFIX,
|
||||
]:
|
||||
await redis.delete_prefix(prefix)
|
||||
|
||||
console.print('重建数据库表', style='white')
|
||||
conn = await db.connection()
|
||||
await conn.run_sync(MappedBase.metadata.drop_all)
|
||||
await conn.run_sync(MappedBase.metadata.create_all)
|
||||
|
||||
console.print('执行 SQL 脚本', style='white')
|
||||
sql_scripts = await get_sql_scripts()
|
||||
for sql_script in sql_scripts:
|
||||
console.print(f'正在执行:{sql_script}', style='white')
|
||||
await execute_sql_scripts(db, sql_script, is_init=True)
|
||||
|
||||
console.print('初始化成功', style='green')
|
||||
console.print('\n快试试 [bold cyan]fba run[/bold cyan] 启动服务吧~')
|
||||
except Exception as e:
|
||||
raise cappa.Exit(f'初始化失败:{e}', code=1)
|
||||
else:
|
||||
console.print('已取消初始化操作', style='yellow')
|
||||
|
||||
|
||||
def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001
|
||||
url = f'http://{host}:{port}'
|
||||
@@ -45,16 +273,32 @@ def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT0
|
||||
openapi_url = url + (settings.FASTAPI_OPENAPI_URL or '')
|
||||
|
||||
panel_content = Text()
|
||||
panel_content.append(f'当前版本: v{__version__}')
|
||||
panel_content.append(f'\n服务地址: {url}')
|
||||
panel_content.append('\n官方文档: https://fastapi-practices.github.io/fastapi_best_architecture_docs/')
|
||||
panel_content.append('Python 版本:', style='bold cyan')
|
||||
panel_content.append(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}', style='white')
|
||||
|
||||
panel_content.append('\nAPI 请求地址: ', style='bold cyan')
|
||||
panel_content.append(f'{url}{settings.FASTAPI_API_V1_PATH}', style='blue')
|
||||
|
||||
panel_content.append('\n\n环境模式:', style='bold green')
|
||||
env_style = 'yellow' if settings.ENVIRONMENT == 'dev' else 'green'
|
||||
panel_content.append(f'{settings.ENVIRONMENT.upper()}', style=env_style)
|
||||
|
||||
plugins = get_plugins()
|
||||
panel_content.append('\n已安装插件:', style='bold green')
|
||||
if plugins:
|
||||
panel_content.append(f'{", ".join(plugins)}', style='yellow')
|
||||
else:
|
||||
panel_content.append('无', style='white')
|
||||
|
||||
if settings.ENVIRONMENT == 'dev':
|
||||
panel_content.append(f'\n\n📖 Swagger 文档: {docs_url}', style='yellow')
|
||||
panel_content.append(f'\n📚 Redoc 文档: {redoc_url}', style='blue')
|
||||
panel_content.append(f'\n📡 OpenAPI JSON: {openapi_url}', style='green')
|
||||
panel_content.append(f'\n\n📖 Swagger 文档: {docs_url}', style='bold magenta')
|
||||
panel_content.append(f'\n📚 Redoc 文档: {redoc_url}', style='bold magenta')
|
||||
panel_content.append(f'\n📡 OpenAPI JSON: {openapi_url}', style='bold magenta')
|
||||
|
||||
console.print(Panel(panel_content, title='fba 服务信息', border_style='purple', padding=(1, 2)))
|
||||
panel_content.append('\n🌐 架构官方文档: ', style='bold magenta')
|
||||
panel_content.append('https://fastapi-practices.github.io/fastapi_best_architecture_docs/')
|
||||
|
||||
console.print(Panel(panel_content, title=f'fba (v{__version__})', border_style='purple', padding=(1, 2)))
|
||||
granian.Granian(
|
||||
target='backend.main:app',
|
||||
interface='asgi',
|
||||
@@ -101,13 +345,16 @@ async def install_plugin(
|
||||
db_type: DataBaseType,
|
||||
pk_type: PrimaryKeyType,
|
||||
) -> None:
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise cappa.Exit('插件安装仅在开发环境可用', code=1)
|
||||
|
||||
if not path and not repo_url:
|
||||
raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1)
|
||||
if path and repo_url:
|
||||
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
|
||||
|
||||
plugin_name = None
|
||||
console.print(Text('开始安装插件...', style='bold cyan'))
|
||||
console.print('开始安装插件...', style='bold cyan')
|
||||
|
||||
try:
|
||||
if path:
|
||||
@@ -115,19 +362,41 @@ async def install_plugin(
|
||||
if repo_url:
|
||||
plugin_name = await install_git_plugin(repo_url=repo_url)
|
||||
|
||||
console.print(Text(f'插件 {plugin_name} 安装成功', style='bold green'))
|
||||
console.print(f'插件 {plugin_name} 安装成功', style='bold green')
|
||||
|
||||
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
|
||||
if sql_file and not no_sql:
|
||||
console.print(Text('开始自动执行插件 SQL 脚本...', style='bold cyan'))
|
||||
await execute_sql_scripts(sql_file)
|
||||
console.print('开始自动执行插件 SQL 脚本...', style='bold cyan')
|
||||
async with async_db_session.begin() as db:
|
||||
await execute_sql_scripts(db, sql_file)
|
||||
|
||||
except Exception as e:
|
||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
||||
|
||||
|
||||
async def execute_sql_scripts(sql_scripts: str) -> None:
|
||||
async with async_db_session.begin() as db:
|
||||
async def get_sql_scripts() -> list[str]:
|
||||
sql_scripts = []
|
||||
db_script_dir = MYSQL_SCRIPT_DIR if DataBaseType.mysql == settings.DATABASE_TYPE else POSTGRESQL_SCRIPT_DIR
|
||||
main_sql_file = (
|
||||
db_script_dir / 'init_test_data.sql'
|
||||
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
|
||||
else db_script_dir / 'init_snowflake_test_data.sql'
|
||||
)
|
||||
|
||||
main_sql_path = anyio.Path(main_sql_file)
|
||||
if await main_sql_path.exists():
|
||||
sql_scripts.append(str(main_sql_file))
|
||||
|
||||
plugins = get_plugins()
|
||||
for plugin in plugins:
|
||||
plugin_sql = await get_plugin_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
|
||||
if plugin_sql:
|
||||
sql_scripts.append(str(plugin_sql))
|
||||
|
||||
return sql_scripts
|
||||
|
||||
|
||||
async def execute_sql_scripts(db: AsyncSession, sql_scripts: str, *, is_init: bool = False) -> None:
|
||||
try:
|
||||
stmts = await parse_sql_script(sql_scripts)
|
||||
for stmt in stmts:
|
||||
@@ -135,7 +404,8 @@ async def execute_sql_scripts(sql_scripts: str) -> None:
|
||||
except Exception as e:
|
||||
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
|
||||
|
||||
console.print(Text('SQL 脚本已执行完成', style='bold green'))
|
||||
if not is_init:
|
||||
console.print('SQL 脚本已执行完成', style='bold green')
|
||||
|
||||
|
||||
async def import_table(
|
||||
@@ -143,18 +413,33 @@ async def import_table(
|
||||
table_schema: str,
|
||||
table_name: str,
|
||||
) -> None:
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
|
||||
|
||||
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||
|
||||
try:
|
||||
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
|
||||
async with async_db_session.begin() as db:
|
||||
await gen_service.import_business_and_model(db=db, obj=obj)
|
||||
console.log('代码生成业务和模型列导入成功', style='bold green')
|
||||
console.log('\n快试试 [bold cyan]fba codegen[/bold cyan] 生成代码吧~')
|
||||
except Exception as e:
|
||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
||||
|
||||
|
||||
def generate() -> None:
|
||||
async def generate(*, preview: bool = False) -> None:
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
|
||||
|
||||
from backend.plugin.code_generator.service.business_service import gen_business_service
|
||||
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||
|
||||
try:
|
||||
ids = []
|
||||
results = run_await(gen_business_service.get_all)()
|
||||
async with async_db_session() as db:
|
||||
results = await gen_business_service.get_all(db=db)
|
||||
|
||||
if not results:
|
||||
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
|
||||
@@ -175,14 +460,65 @@ def generate() -> None:
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(_id) for _id in ids])
|
||||
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(id_) for id_ in ids])
|
||||
|
||||
# 预览
|
||||
async with async_db_session() as db:
|
||||
preview_data = await gen_service.preview(db=db, pk=business)
|
||||
|
||||
console.print('\n[bold yellow]将要生成以下文件:[/]')
|
||||
file_table = Table(show_header=True, header_style='bold cyan')
|
||||
file_table.add_column('文件路径', style='white')
|
||||
file_table.add_column('大小', style='green', justify='right')
|
||||
|
||||
for filepath, content in sorted(preview_data.items()):
|
||||
size = len(content)
|
||||
size_str = f'{size} B' if size < 1024 else f'{size / 1024:.1f} KB'
|
||||
file_table.add_row(filepath, size_str)
|
||||
|
||||
console.print(file_table)
|
||||
|
||||
if preview:
|
||||
console.print('\n[bold cyan]预览模式:未执行实际生成操作[/]')
|
||||
return
|
||||
|
||||
# 生成
|
||||
console.print('\n[bold red]警告:代码生成将进行磁盘文件(覆盖)写入,切勿在生产环境中使用!!![/]')
|
||||
ok = Prompt.ask('\n确认继续生成代码吗?', choices=['y', 'n'], default='n')
|
||||
|
||||
if ok.lower() == 'y':
|
||||
async with async_db_session.begin() as db:
|
||||
gen_path = await gen_service.generate(db=db, pk=business)
|
||||
|
||||
console.print('\n代码已生成完成', style='bold green')
|
||||
console.print(Text('\n详情请查看:'), Text(str(gen_path), style='bold white'))
|
||||
|
||||
gen_path = run_await(gen_service.generate)(pk=business)
|
||||
except Exception as e:
|
||||
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
|
||||
|
||||
console.print(Text('\n代码已生成完毕', style='bold green'))
|
||||
console.print(Text('\n详情请查看:'), Text(gen_path, style='bold magenta'))
|
||||
|
||||
def run_alembic(*args: str) -> None:
|
||||
"""执行 alembic 命令"""
|
||||
try:
|
||||
subprocess.run(['alembic', *args], cwd=BASE_PATH.parent, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise cappa.Exit('Alembic 命令执行失败', code=e.returncode)
|
||||
|
||||
|
||||
@cappa.command(help='初始化 fba 项目', default_long=True)
|
||||
@dataclass
|
||||
class Init:
|
||||
auto: Annotated[
|
||||
bool,
|
||||
cappa.Arg(default=False, help='自动化初始化模式:自动创建 .env、安装依赖、创建数据库并初始化表结构'),
|
||||
]
|
||||
|
||||
async def __call__(self) -> None:
|
||||
if self.auto:
|
||||
await auto_init()
|
||||
else:
|
||||
async with async_db_session.begin() as db:
|
||||
await init(db, redis_client)
|
||||
|
||||
|
||||
@cappa.command(help='运行 API 服务', default_long=True)
|
||||
@@ -276,7 +612,7 @@ class Add:
|
||||
]
|
||||
db_type: Annotated[
|
||||
DataBaseType,
|
||||
cappa.Arg(default='mysql', help='执行插件 SQL 脚本的数据库类型'),
|
||||
cappa.Arg(default='postgresql', help='执行插件 SQL 脚本的数据库类型'),
|
||||
]
|
||||
pk_type: Annotated[
|
||||
PrimaryKeyType,
|
||||
@@ -303,17 +639,138 @@ class Import:
|
||||
cappa.Arg(short='tn', help='数据库表名'),
|
||||
]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
import_module_cached('backend.plugin.code_generator')
|
||||
except ImportError:
|
||||
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
|
||||
|
||||
async def __call__(self) -> None:
|
||||
await import_table(self.app, self.table_schema, self.table_name)
|
||||
|
||||
|
||||
@cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True)
|
||||
@dataclass
|
||||
class CodeGenerate:
|
||||
class CodeGenerator:
|
||||
preview: Annotated[
|
||||
bool,
|
||||
cappa.Arg(short='-p', default=False, help='仅预览将要生成的文件,不执行实际生成操作'),
|
||||
]
|
||||
subcmd: cappa.Subcommands[Import | None] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
import_module_cached('backend.plugin.code_generator')
|
||||
except ImportError:
|
||||
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
|
||||
|
||||
async def __call__(self) -> None:
|
||||
await generate(preview=self.preview)
|
||||
|
||||
|
||||
@cappa.command(help='生成数据库迁移文件', default_long=True)
|
||||
@dataclass
|
||||
class Revision:
|
||||
autogenerate: Annotated[
|
||||
bool,
|
||||
cappa.Arg(default=True, help='自动检测模型变更并生成迁移脚本'),
|
||||
]
|
||||
message: Annotated[
|
||||
str,
|
||||
cappa.Arg(short='-m', default='', help='迁移文件的描述信息'),
|
||||
]
|
||||
|
||||
def __call__(self) -> None:
|
||||
generate()
|
||||
args = ['revision']
|
||||
if self.autogenerate:
|
||||
args.append('--autogenerate')
|
||||
if self.message:
|
||||
args.extend(['-m', self.message])
|
||||
run_alembic(*args)
|
||||
console.print('迁移文件生成成功', style='bold green')
|
||||
|
||||
|
||||
@cappa.command(help='升级数据库到指定版本', default_long=True)
|
||||
@dataclass
|
||||
class Upgrade:
|
||||
revision: Annotated[
|
||||
str,
|
||||
cappa.Arg(default='head', help='目标版本,默认为最新版本'),
|
||||
]
|
||||
|
||||
def __call__(self) -> None:
|
||||
run_alembic('upgrade', self.revision)
|
||||
console.print(f'数据库已升级到: {self.revision}', style='bold green')
|
||||
|
||||
|
||||
@cappa.command(help='降级数据库到指定版本', default_long=True)
|
||||
@dataclass
|
||||
class Downgrade:
|
||||
revision: Annotated[
|
||||
str,
|
||||
cappa.Arg(default='-1', help='目标版本,默认回退一个版本'),
|
||||
]
|
||||
|
||||
def __call__(self) -> None:
|
||||
run_alembic('downgrade', self.revision)
|
||||
console.print(f'数据库已降级到: {self.revision}', style='bold green')
|
||||
|
||||
|
||||
@cappa.command(help='显示数据库当前迁移版本')
|
||||
@dataclass
|
||||
class Current:
|
||||
verbose: Annotated[
|
||||
bool,
|
||||
cappa.Arg(short='-v', default=False, help='显示详细信息'),
|
||||
]
|
||||
|
||||
def __call__(self) -> None:
|
||||
args = ['current']
|
||||
if self.verbose:
|
||||
args.append('-v')
|
||||
run_alembic(*args)
|
||||
|
||||
|
||||
@cappa.command(help='显示迁移历史记录', default_long=True)
|
||||
@dataclass
|
||||
class History:
|
||||
verbose: Annotated[
|
||||
bool,
|
||||
cappa.Arg(short='-v', default=False, help='显示详细信息'),
|
||||
]
|
||||
range: Annotated[
|
||||
str,
|
||||
cappa.Arg(short='-r', default='', help='显示指定范围的历史,例如 -r base:head'),
|
||||
]
|
||||
|
||||
def __call__(self) -> None:
|
||||
args = ['history']
|
||||
if self.verbose:
|
||||
args.append('-v')
|
||||
if self.range:
|
||||
args.extend(['-r', self.range])
|
||||
run_alembic(*args)
|
||||
|
||||
|
||||
@cappa.command(help='显示所有头版本')
|
||||
@dataclass
|
||||
class Heads:
|
||||
verbose: Annotated[
|
||||
bool,
|
||||
cappa.Arg(short='-v', default=False, help='显示详细信息'),
|
||||
]
|
||||
|
||||
def __call__(self) -> None:
|
||||
args = ['heads']
|
||||
if self.verbose:
|
||||
args.append('-v')
|
||||
run_alembic(*args)
|
||||
|
||||
|
||||
@cappa.command(help='数据库迁移管理')
|
||||
@dataclass
|
||||
class Alembic:
|
||||
subcmd: cappa.Subcommands[Revision | Upgrade | Downgrade | Current | History | Heads]
|
||||
|
||||
|
||||
@cappa.command(help='一个高效的 fba 命令行界面', default_long=True)
|
||||
@@ -323,11 +780,12 @@ class FbaCli:
|
||||
str,
|
||||
cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'),
|
||||
]
|
||||
subcmd: cappa.Subcommands[Run | Celery | Add | CodeGenerate | None] = None
|
||||
subcmd: cappa.Subcommands[Init | Run | Add | Alembic | Celery | CodeGenerator | None] = None
|
||||
|
||||
async def __call__(self) -> None:
|
||||
if self.sql:
|
||||
await execute_sql_scripts(self.sql)
|
||||
async with async_db_session.begin() as db:
|
||||
await execute_sql_scripts(db, self.sql)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
Vendored
+233
@@ -0,0 +1,233 @@
|
||||
import functools
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from msgspec import json
|
||||
|
||||
from backend.common.cache.local import local_cache_manager
|
||||
from backend.common.cache.pubsub import cache_pubsub_manager
|
||||
from backend.common.context import ctx
|
||||
from backend.common.exception import errors
|
||||
from backend.common.log import log
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.serializers import select_columns_serialize, select_list_serialize
|
||||
|
||||
P = ParamSpec('P')
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def _build_cache_key(
|
||||
name: str,
|
||||
key: str | None,
|
||||
key_builder: Callable[..., str] | None,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""构建缓存 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}" 不存在或值为空')
|
||||
|
||||
if isinstance(value, list):
|
||||
raise errors.ServerError(msg='缓存键构建失败:不支持从列表中提取字段,请使用 key_builder 处理列表参数')
|
||||
|
||||
if hasattr(value, field):
|
||||
value = getattr(value, field)
|
||||
elif isinstance(value, dict) and field in value:
|
||||
value = value[field]
|
||||
else:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,对象中不存在字段 "{field}"')
|
||||
else:
|
||||
value = kwargs.get(key)
|
||||
if value is None:
|
||||
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{key}" 不存在或值为空')
|
||||
|
||||
return f'{name}:{value}'
|
||||
|
||||
if key_builder:
|
||||
return f'{name}:{key_builder(*args, **kwargs)}'
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def _serialize_result(result: Any) -> bytes:
|
||||
"""
|
||||
序列化缓存结果
|
||||
|
||||
:param result: 需要进行序列化的结果
|
||||
:return:
|
||||
"""
|
||||
# SQLAlchemy 查询表
|
||||
if hasattr(result, '__table__'):
|
||||
return json.encode(select_columns_serialize(result))
|
||||
|
||||
# SQLAlchemy 查询列表
|
||||
if (
|
||||
isinstance(result, Sequence)
|
||||
and not isinstance(result, (str, bytes))
|
||||
and len(result) > 0
|
||||
and hasattr(result[0], '__table__')
|
||||
):
|
||||
return json.encode(select_list_serialize(result))
|
||||
|
||||
# 基本类型
|
||||
return json.encode(result)
|
||||
|
||||
|
||||
def _deserialize_result(value: bytes) -> Any:
|
||||
"""
|
||||
反序列化缓存结果
|
||||
|
||||
:param value: 缓存结果
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return json.decode(value)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def user_key_builder() -> str:
|
||||
"""基于当前用户 ID 生成缓存 Key"""
|
||||
user_id = ctx.user_id
|
||||
if user_id is None:
|
||||
raise errors.ServerError(msg='用户缓存键构建失败')
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def cached( # noqa: C901
|
||||
name: str,
|
||||
*,
|
||||
key: str | None = None,
|
||||
key_builder: Callable[..., str] | None = None,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""
|
||||
缓存装饰器
|
||||
|
||||
:param name: 缓存名称(通常为缓存 Key 前缀)
|
||||
:param key: 从方法参数中获取指定参数名的值作为缓存 Key,与 key_builder 互斥
|
||||
:param key_builder: 自定义 Key 生成函数,与 key 互斥
|
||||
:return:
|
||||
"""
|
||||
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
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
cache_key = _build_cache_key(name, key, key_builder, *args, **kwargs)
|
||||
|
||||
# L1: 本地缓存
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
local_value = local_cache_manager.get(cache_key)
|
||||
if local_value is not None:
|
||||
return local_value
|
||||
|
||||
# L2: Redis 缓存
|
||||
try:
|
||||
redis_value = await redis_client.get(cache_key)
|
||||
if redis_value is not None:
|
||||
result = _deserialize_result(redis_value)
|
||||
# 回填 L1
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
local_cache_manager.set(cache_key, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning(f'[Cache] GET error: {e}')
|
||||
|
||||
# 缓存未命中
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
if result is not None:
|
||||
try:
|
||||
# 回填 L1
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
local_cache_manager.set(cache_key, result)
|
||||
|
||||
# 回填 L2
|
||||
serialized_result = _serialize_result(result)
|
||||
if settings.CACHE_REDIS_TTL:
|
||||
await redis_client.setex(cache_key, settings.CACHE_REDIS_TTL, serialized_result)
|
||||
else:
|
||||
await redis_client.set(cache_key, serialized_result)
|
||||
except Exception as e:
|
||||
log.warning(f'[Cache] SET error: {e}')
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cache_invalidate( # noqa: C901
|
||||
name: str,
|
||||
*,
|
||||
key: str | None = None,
|
||||
key_builder: Callable[..., str] | None = None,
|
||||
atomic: bool = True,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""
|
||||
缓存失效装饰器
|
||||
|
||||
:param name: 缓存名称(通常为缓存 Key 前缀)
|
||||
:param key: 从方法参数中获取指定参数名的值作为缓存 Key,与 key_builder 互斥
|
||||
:param key_builder: 自定义 Key 生成函数,与 key 互斥
|
||||
:param atomic: 是否保证缓存原子性
|
||||
:return:
|
||||
"""
|
||||
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]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 尝试失效缓存
|
||||
invalidate_success = False
|
||||
invalidate_error = None
|
||||
|
||||
try:
|
||||
invalidate_key = _build_cache_key(name, key, key_builder, *args, **kwargs)
|
||||
|
||||
# L1 缓存失效
|
||||
if settings.CACHE_LOCAL_ENABLED:
|
||||
if invalidate_key == name:
|
||||
local_cache_manager.delete_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)
|
||||
else:
|
||||
await cache_pubsub_manager.publish_invalidation(invalidate_key)
|
||||
|
||||
# L2 缓存失效
|
||||
if invalidate_key == name:
|
||||
await redis_client.delete_prefix(invalidate_key)
|
||||
else:
|
||||
await redis_client.delete(invalidate_key)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'[Cache] INVALIDATE error: {e}')
|
||||
invalidate_error = e
|
||||
else:
|
||||
invalidate_success = True
|
||||
|
||||
# 原子性检查
|
||||
if atomic and not invalidate_success:
|
||||
raise errors.ServerError(msg='缓存失效失败,数据可能不一致', data=invalidate_error)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
from typing import Any
|
||||
|
||||
import cachebox
|
||||
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
class LocalCacheManager:
|
||||
"""本地缓存管理器"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.hot_cache: cachebox.TTLCache = cachebox.TTLCache(
|
||||
settings.CACHE_LOCAL_MAXSIZE, ttl=settings.CACHE_LOCAL_TTL
|
||||
)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""获取缓存"""
|
||||
try:
|
||||
return self.hot_cache[key]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""设置缓存"""
|
||||
self.hot_cache[key] = value
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""删除缓存"""
|
||||
try:
|
||||
del self.hot_cache[key]
|
||||
except KeyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空缓存"""
|
||||
self.hot_cache.clear()
|
||||
|
||||
def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None) -> None:
|
||||
"""
|
||||
删除指定前缀的缓存
|
||||
|
||||
:param prefix: 要删除的键前缀
|
||||
:param exclude: 要排除的键或键列表
|
||||
:return:
|
||||
"""
|
||||
exclude_set = set(exclude) if isinstance(exclude, list) else {exclude} if isinstance(exclude, str) else set()
|
||||
for key in list(self.hot_cache.keys()):
|
||||
if key.startswith(prefix) and key not in exclude_set:
|
||||
try:
|
||||
del self.hot_cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
local_cache_manager = LocalCacheManager()
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from backend.common.cache.local import local_cache_manager
|
||||
from backend.common.log import log
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import RedisCli, redis_client
|
||||
|
||||
|
||||
class CachePubSubManager:
|
||||
"""缓存 Pub/Sub 管理器"""
|
||||
|
||||
_pubsub_task: asyncio.Task | None = None
|
||||
|
||||
@staticmethod
|
||||
async def publish_invalidation(key: str, *, is_delete_prefix: bool) -> None:
|
||||
"""
|
||||
发布缓存失效通知
|
||||
|
||||
:param key: 缓存键
|
||||
:param is_delete_prefix: 是否删除符合前缀的所有缓存
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
message = json.dumps({'key': key, 'is_delete_prefix': is_delete_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
|
||||
"""订阅并监听缓存失效通知"""
|
||||
reconnect_attempts = 0
|
||||
|
||||
while reconnect_attempts < settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS:
|
||||
pubsub_client: RedisCli | None = None
|
||||
pubsub = None
|
||||
|
||||
try:
|
||||
# 使用独立连接
|
||||
pubsub_client = RedisCli()
|
||||
pubsub = pubsub_client.pubsub()
|
||||
await pubsub.subscribe(settings.CACHE_PUBSUB_CHANNEL)
|
||||
|
||||
# 发布订阅成功
|
||||
reconnect_attempts = 0
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
try:
|
||||
data = json.loads(message['data'])
|
||||
key = data['key']
|
||||
if not data['is_delete_prefix']:
|
||||
local_cache_manager.delete(key)
|
||||
else:
|
||||
local_cache_manager.delete_prefix(key)
|
||||
except json.JSONDecodeError as e:
|
||||
log.warning(f'[CachePubSub] 消息格式错误 {e}')
|
||||
except Exception as e:
|
||||
log.error(f'[CachePubSub] 处理通知失败: {e}')
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
reconnect_attempts += 1
|
||||
log.error(
|
||||
f'[CachePubSub] 订阅异常 ({reconnect_attempts}/{settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS}): {e}'
|
||||
)
|
||||
|
||||
if reconnect_attempts >= settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS:
|
||||
log.error('[CachePubSub] 达到最大重连次数,停止订阅')
|
||||
break
|
||||
|
||||
await asyncio.sleep(settings.CACHE_PUBSUB_RECONNECT_DELAY)
|
||||
finally:
|
||||
if pubsub_client:
|
||||
try:
|
||||
await pubsub_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
if pubsub:
|
||||
try:
|
||||
await pubsub.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def start_listener(cls) -> None:
|
||||
"""启动缓存 Pub/Sub 监听器"""
|
||||
if not settings.CACHE_LOCAL_ENABLED:
|
||||
return
|
||||
|
||||
if cls._pubsub_task is None or cls._pubsub_task.done():
|
||||
cls._pubsub_task = asyncio.create_task(cls.subscribe_and_listen())
|
||||
|
||||
@classmethod
|
||||
async def stop_listener(cls) -> None:
|
||||
"""停止缓存 Pub/Sub 监听器"""
|
||||
if cls._pubsub_task is None:
|
||||
return
|
||||
|
||||
if not cls._pubsub_task.done():
|
||||
cls._pubsub_task.cancel()
|
||||
try:
|
||||
await cls._pubsub_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
cls._pubsub_task = None
|
||||
|
||||
|
||||
cache_pubsub_manager = CachePubSubManager()
|
||||
@@ -13,12 +13,15 @@ class TypedContextProtocol(Protocol):
|
||||
region: str | None
|
||||
city: str | None
|
||||
|
||||
user_agent: str
|
||||
user_agent: str | None
|
||||
os: str | None
|
||||
browser: str | None
|
||||
device: str | None
|
||||
|
||||
permission: str | None
|
||||
language: str
|
||||
|
||||
user_id: int | None
|
||||
|
||||
|
||||
class TypedContext(TypedContextProtocol, _Context):
|
||||
|
||||
@@ -17,7 +17,7 @@ class IpInfo:
|
||||
|
||||
@dataclasses.dataclass
|
||||
class UserAgentInfo:
|
||||
user_agent: str
|
||||
user_agent: str | None
|
||||
os: str | None
|
||||
browser: str | None
|
||||
device: str | None
|
||||
@@ -70,6 +70,6 @@ class UploadUrl:
|
||||
class SnowflakeInfo:
|
||||
timestamp: int
|
||||
datetime: str
|
||||
cluster_id: int
|
||||
node_id: int
|
||||
datacenter_id: int
|
||||
worker_id: int
|
||||
sequence: int
|
||||
|
||||
@@ -103,14 +103,6 @@ class StatusType(IntEnum):
|
||||
enable = 1
|
||||
|
||||
|
||||
class UserSocialType(StrEnum):
|
||||
"""用户社交类型"""
|
||||
|
||||
github = 'GitHub'
|
||||
google = 'Google'
|
||||
linux_do = 'LinuxDo'
|
||||
|
||||
|
||||
class FileType(StrEnum):
|
||||
"""文件类型"""
|
||||
|
||||
@@ -118,6 +110,13 @@ class FileType(StrEnum):
|
||||
video = 'video'
|
||||
|
||||
|
||||
class PluginLevelType(StrEnum):
|
||||
"""插件级别类型"""
|
||||
|
||||
app = 'app'
|
||||
extend = 'extend'
|
||||
|
||||
|
||||
class PluginType(StrEnum):
|
||||
"""插件类型"""
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class BaseExceptionError(Exception):
|
||||
self.data = data
|
||||
# The original background task: https://www.starlette.io/background/
|
||||
self.background = background
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
class HTTPError(HTTPException):
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
|
||||
|
||||
from backend.common.context import ctx
|
||||
@@ -75,7 +76,7 @@ async def _validation_exception_handler(exc: RequestValidationError | Validation
|
||||
return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_422, content=content)
|
||||
|
||||
|
||||
def register_exception(app: FastAPI) -> None:
|
||||
def register_exception(app: FastAPI) -> None: # noqa: C901
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""
|
||||
@@ -194,3 +195,55 @@ def register_exception(app: FastAPI) -> None:
|
||||
status_code=StandardResponseCode.HTTP_500,
|
||||
content=content,
|
||||
)
|
||||
|
||||
if settings.MIDDLEWARE_CORS:
|
||||
|
||||
@app.exception_handler(StandardResponseCode.HTTP_500)
|
||||
async def cors_custom_code_500_exception_handler(request: Request, exc: BaseExceptionError | Exception):
|
||||
"""
|
||||
跨域自定义 500 异常处理
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param exc: 自定义异常
|
||||
:return:
|
||||
"""
|
||||
if isinstance(exc, BaseExceptionError):
|
||||
content = {
|
||||
'code': exc.code,
|
||||
'msg': exc.msg,
|
||||
'data': exc.data,
|
||||
}
|
||||
else:
|
||||
if settings.ENVIRONMENT == 'dev':
|
||||
content = {
|
||||
'code': StandardResponseCode.HTTP_500,
|
||||
'msg': str(exc),
|
||||
'data': None,
|
||||
}
|
||||
else:
|
||||
res = response_base.fail(res=CustomResponseCode.HTTP_500)
|
||||
content = res.model_dump()
|
||||
content.update(trace_id=get_request_trace_id())
|
||||
response = MsgSpecJSONResponse(
|
||||
status_code=exc.code if isinstance(exc, BaseExceptionError) else StandardResponseCode.HTTP_500,
|
||||
content=content,
|
||||
background=exc.background if isinstance(exc, BaseExceptionError) else None,
|
||||
)
|
||||
origin = request.headers.get('origin')
|
||||
if origin:
|
||||
cors = CORSMiddleware(
|
||||
app=app,
|
||||
allow_origins=settings.CORS_ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
expose_headers=settings.CORS_EXPOSE_HEADERS,
|
||||
)
|
||||
response.headers.update(cors.simple_headers)
|
||||
has_cookie = 'cookie' in request.headers
|
||||
if cors.allow_all_origins and has_cookie:
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin):
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
response.headers.add_vary_header('Origin')
|
||||
return response
|
||||
|
||||
+19
-2
@@ -6,6 +6,9 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from starlette_context.errors import ContextDoesNotExistError
|
||||
|
||||
from backend.common.context import ctx
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import LOCALE_DIR
|
||||
|
||||
@@ -15,7 +18,20 @@ class I18n:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.locales: dict[str, dict[str, Any]] = {}
|
||||
self.current_language: str = settings.I18N_DEFAULT_LANGUAGE
|
||||
self.load_locales()
|
||||
|
||||
@property
|
||||
def current_language(self) -> str:
|
||||
"""获取当前请求的语言"""
|
||||
try:
|
||||
return ctx.language
|
||||
except (AttributeError, LookupError, ContextDoesNotExistError):
|
||||
return settings.I18N_DEFAULT_LANGUAGE
|
||||
|
||||
@current_language.setter
|
||||
def current_language(self, language: str) -> None:
|
||||
"""设置当前请求的语言"""
|
||||
ctx.language = language
|
||||
|
||||
def load_locales(self) -> None:
|
||||
"""加载语言文本"""
|
||||
@@ -54,7 +70,7 @@ class I18n:
|
||||
try:
|
||||
translation = self.locales[self.current_language]
|
||||
except KeyError:
|
||||
keys = 'error.language_not_found'
|
||||
keys = 'error.language_not_found'.split('.')
|
||||
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]
|
||||
|
||||
for k in keys:
|
||||
@@ -63,6 +79,7 @@ class I18n:
|
||||
else:
|
||||
# Pydantic 兼容
|
||||
translation = None if keys[0] == 'pydantic' else key
|
||||
break
|
||||
|
||||
if translation and kwargs:
|
||||
translation = translation.format(**kwargs)
|
||||
|
||||
@@ -47,6 +47,13 @@ def default_formatter(record: logging.LogRecord) -> str:
|
||||
return settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n'
|
||||
|
||||
|
||||
def request_id_filter(record: logging.LogRecord) -> logging.LogRecord:
|
||||
"""请求 ID 过滤器"""
|
||||
rid = get_request_trace_id()
|
||||
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
|
||||
return record
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""
|
||||
设置日志处理器
|
||||
@@ -75,12 +82,6 @@ def setup_logging() -> None:
|
||||
# 移除 loguru 默认处理器
|
||||
logger.remove()
|
||||
|
||||
# request_id 过滤器
|
||||
def request_id_filter(record: logging.LogRecord) -> logging.LogRecord:
|
||||
rid = get_request_trace_id()
|
||||
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
|
||||
return record
|
||||
|
||||
# 配置 loguru 处理器
|
||||
logger.configure(
|
||||
handlers=[
|
||||
|
||||
+7
-10
@@ -6,6 +6,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
|
||||
|
||||
from backend.common.enums import DataBaseType, PrimaryKeyType
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.snowflake import snowflake
|
||||
from backend.utils.timezone import timezone
|
||||
@@ -23,15 +24,11 @@ id_key = Annotated[
|
||||
autoincrement=True,
|
||||
sort_order=-999,
|
||||
comment='主键 ID',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# 雪花算法 Mapped 类型主键,使用方法与 id_key 相同
|
||||
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
|
||||
snowflake_id_key = Annotated[
|
||||
int,
|
||||
mapped_column(
|
||||
)
|
||||
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
|
||||
# 雪花算法 Mapped 类型主键
|
||||
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
|
||||
else mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
unique=True,
|
||||
@@ -46,7 +43,7 @@ snowflake_id_key = Annotated[
|
||||
class UniversalText(TypeDecorator[str]):
|
||||
"""PostgreSQL、MySQL 兼容性(长)文本类型"""
|
||||
|
||||
impl = LONGTEXT if settings.DATABASE_TYPE == 'mysql' else Text
|
||||
impl = LONGTEXT if DataBaseType.mysql == settings.DATABASE_TYPE else Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: str | None, dialect) -> str | None: # noqa: ANN001
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
|
||||
# 警告: 此值与以下位置强关联,修改必须同步更新,否则会导致 Grafana 指标数据查询失败:
|
||||
# - deploy/backend/grafana/fba_datasource.yml
|
||||
# - deploy/backend/grafana/dashboards/fba_server.json
|
||||
PROMETHEUS_APP_NAME = 'fba_server'
|
||||
|
||||
PROMETHEUS_REQUEST_IN_PROGRESS_GAUGE = Gauge(
|
||||
name='fba_request_in_progress',
|
||||
documentation='按方法和路径统计请求的衡量',
|
||||
labelnames=['app_name', 'method', 'path'],
|
||||
)
|
||||
|
||||
PROMETHEUS_REQUEST_COUNTER = Counter(
|
||||
name='fba_request_total',
|
||||
documentation='按方法和路径统计请求总数',
|
||||
labelnames=['app_name', 'method', 'path'],
|
||||
)
|
||||
|
||||
PROMETHEUS_REQUEST_COST_TIME_HISTOGRAM = Histogram(
|
||||
name='fba_request_cost_time',
|
||||
documentation='按方法和路径划分请求耗时的直方图(以 ms 为单位)',
|
||||
labelnames=['app_name', 'method', 'path'],
|
||||
)
|
||||
|
||||
PROMETHEUS_EXCEPTION_COUNTER = Counter(
|
||||
name='fba_exception_total',
|
||||
documentation='按方法,路径和异常类型统计异常总数',
|
||||
labelnames=['app_name', 'method', 'path', 'exception_type'],
|
||||
)
|
||||
|
||||
|
||||
PROMETHEUS_RESPONSE_COUNTER = Counter(
|
||||
name='fba_response_total',
|
||||
documentation='按方法、路径和状态码统计响应总数',
|
||||
labelnames=['app_name', 'method', 'path', 'status_code'],
|
||||
)
|
||||
@@ -2,6 +2,8 @@ import asyncio
|
||||
|
||||
from asyncio import Queue
|
||||
|
||||
from backend.common.log import log
|
||||
|
||||
|
||||
async def batch_dequeue(queue: Queue, max_items: int, timeout: float) -> list:
|
||||
"""
|
||||
@@ -23,5 +25,7 @@ async def batch_dequeue(queue: Queue, max_items: int, timeout: float) -> list:
|
||||
await asyncio.wait_for(collector(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.error(f'队列批量获取失败: {e}')
|
||||
|
||||
return items
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
|
||||
|
||||
from backend.common.enums import PrimaryKeyType
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
|
||||
@@ -22,8 +24,26 @@ class SchemaBase(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
use_enum_values=True,
|
||||
json_encoders={
|
||||
datetime: lambda x: timezone.to_str(timezone.from_datetime(x))
|
||||
datetime: lambda x: (
|
||||
timezone.to_str(timezone.from_datetime(x))
|
||||
if x.tzinfo is not None and x.tzinfo != timezone.tz_info
|
||||
else timezone.to_str(x),
|
||||
else timezone.to_str(x)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if PrimaryKeyType.snowflake == settings.DATABASE_PK_MODE:
|
||||
from pydantic import field_serializer
|
||||
|
||||
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html#%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9
|
||||
@field_serializer('id', check_fields=False)
|
||||
def serialize_id(self, value: int) -> str | int:
|
||||
if self.model_config.get('from_attributes'):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
def ser_string(value: Any) -> str | None:
|
||||
if value:
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
from fastapi.security.http import HTTPAuthorizationCredentials
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||
from pydantic_core import from_json
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -18,56 +15,13 @@ from backend.app.admin.model import User
|
||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||
from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload
|
||||
from backend.common.exception import errors
|
||||
from backend.common.exception.errors import TokenError
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.serializers import select_as_dict
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CustomHTTPBearer(HTTPBearer):
|
||||
"""
|
||||
自定义 HTTPBearer 认证类
|
||||
|
||||
Issues: https://github.com/fastapi/fastapi/issues/10177
|
||||
"""
|
||||
|
||||
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
|
||||
try:
|
||||
return await super().__call__(request)
|
||||
except HTTPException as e:
|
||||
if e.status_code == 403:
|
||||
raise TokenError
|
||||
raise
|
||||
|
||||
|
||||
# JWT authorizes dependency injection
|
||||
DependsJwtAuth = Depends(CustomHTTPBearer())
|
||||
|
||||
password_hash = PasswordHash((BcryptHasher(),))
|
||||
|
||||
|
||||
def get_hash_password(password: str, salt: bytes | None) -> str:
|
||||
"""
|
||||
使用哈希算法加密密码
|
||||
|
||||
:param password: 密码
|
||||
:param salt: 盐值
|
||||
:return:
|
||||
"""
|
||||
return password_hash.hash(password, salt=salt)
|
||||
|
||||
|
||||
def password_verify(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
密码验证
|
||||
|
||||
:param plain_password: 待验证的密码
|
||||
:param hashed_password: 哈希密码
|
||||
:return:
|
||||
"""
|
||||
return password_hash.verify(plain_password, hashed_password)
|
||||
# JWT dependency injection
|
||||
DependsJwtAuth = Depends(HTTPBearer())
|
||||
|
||||
|
||||
def jwt_encode(payload: dict[str, Any]) -> str:
|
||||
@@ -120,7 +74,7 @@ async def create_access_token(user_id: int, *, multi_login: bool, **kwargs) -> A
|
||||
:return:
|
||||
"""
|
||||
expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS)
|
||||
session_uuid = str(uuid4())
|
||||
session_uuid = str(uuid.uuid4())
|
||||
access_token = jwt_encode({
|
||||
'session_uuid': session_uuid,
|
||||
'exp': timezone.to_utc(expire).timestamp(),
|
||||
@@ -246,7 +200,7 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
|
||||
"""
|
||||
from backend.app.admin.crud.crud_user import user_dao
|
||||
|
||||
user = await user_dao.get_with_relation(db, user_id=pk)
|
||||
user = await user_dao.get_join(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.TokenError(msg='Token 无效')
|
||||
if not user.status:
|
||||
@@ -263,6 +217,30 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def get_jwt_user(user_id: int) -> GetUserInfoWithRelationDetail:
|
||||
"""
|
||||
获取 JWT 用户
|
||||
|
||||
:param user_id:
|
||||
:return:
|
||||
"""
|
||||
cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
if not cache_user:
|
||||
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(
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
||||
settings.TOKEN_EXPIRE_SECONDS,
|
||||
user.model_dump_json(),
|
||||
)
|
||||
else:
|
||||
# TODO: 在恰当的时机,应替换为使用 model_validate_json
|
||||
# https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing
|
||||
user = GetUserInfoWithRelationDetail.model_validate(from_json(cache_user, allow_partial=True))
|
||||
return user
|
||||
|
||||
|
||||
def superuser_verify(request: Request, _token: str = DependsJwtAuth) -> bool:
|
||||
"""
|
||||
验证当前用户超级管理员权限
|
||||
@@ -293,21 +271,7 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
|
||||
if token != redis_token:
|
||||
raise errors.TokenError(msg='Token 已失效')
|
||||
|
||||
cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
if not cache_user:
|
||||
async with async_db_session() as db:
|
||||
current_user = await get_current_user(db, user_id)
|
||||
user = GetUserInfoWithRelationDetail(**select_as_dict(current_user))
|
||||
await redis_client.setex(
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
|
||||
settings.TOKEN_EXPIRE_SECONDS,
|
||||
user.model_dump_json(),
|
||||
)
|
||||
else:
|
||||
# TODO: 在恰当的时机,应替换为使用 model_validate_json
|
||||
# https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing
|
||||
user = GetUserInfoWithRelationDetail.model_validate(from_json(cache_user, allow_partial=True))
|
||||
return user
|
||||
return await get_jwt_user(user_id)
|
||||
|
||||
|
||||
# 超级管理员鉴权依赖注入
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import ColumnElement, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import Alias, ColumnElement, Table, and_, or_
|
||||
from sqlalchemy.orm.util import AliasedClass
|
||||
from sqlalchemy_crud_plus.types import Model
|
||||
|
||||
from backend.app.admin.crud.crud_data_scope import data_scope_dao
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.import_parse import dynamic_import_data_model
|
||||
from backend.utils.dynamic_import import get_all_models
|
||||
|
||||
|
||||
class RequestPermission:
|
||||
@@ -38,90 +40,100 @@ class RequestPermission:
|
||||
if settings.RBAC_ROLE_MENU_MODE:
|
||||
if not isinstance(self.value, str):
|
||||
raise errors.ServerError
|
||||
# 附加权限标识到请求状态
|
||||
|
||||
# 设置权限标识到上下文
|
||||
ctx.permission = self.value
|
||||
|
||||
|
||||
async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnElement[bool]: # noqa: C901
|
||||
def get_data_permission_models() -> dict[str, object]:
|
||||
"""获取所有可用于数据权限的模型"""
|
||||
return {getattr(model, '__name__', str(model)): model for model in get_all_models()}
|
||||
|
||||
|
||||
def filter_data_permission( # noqa: C901
|
||||
request: Request, *models: type[Model] | AliasedClass | Alias | Table
|
||||
) -> ColumnElement[bool]:
|
||||
"""
|
||||
过滤数据权限,控制用户可见数据范围
|
||||
|
||||
使用场景:
|
||||
- 控制用户能看到哪些数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param models: 需要应用数据权限的模型类
|
||||
:return:
|
||||
"""
|
||||
# 是否过滤数据权限
|
||||
# 超级管理员不过滤
|
||||
if request.user.is_superuser:
|
||||
return or_(1 == 1)
|
||||
|
||||
# 角色未启用数据权限过滤
|
||||
for role in request.user.roles:
|
||||
if not role.is_filter_scopes:
|
||||
return or_(1 == 1)
|
||||
|
||||
# 获取数据范围
|
||||
data_scope_ids = set()
|
||||
# 获取数据规则
|
||||
data_rules = set()
|
||||
for role in request.user.roles:
|
||||
for scope in role.scopes:
|
||||
if scope.status:
|
||||
data_scope_ids.add(scope.id)
|
||||
data_rules.update(scope.rules)
|
||||
|
||||
# 无规则用户不做过滤
|
||||
if not list(data_scope_ids):
|
||||
if not data_rules:
|
||||
return or_(1 == 1)
|
||||
|
||||
# 获取数据范围规则
|
||||
unique_data_rules = {}
|
||||
for data_scope_id in list(data_scope_ids):
|
||||
data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope_id)
|
||||
for rule in data_scope_with_relation.rules:
|
||||
unique_data_rules[rule.id] = rule
|
||||
|
||||
# 转换为列表
|
||||
data_rule_list = list(unique_data_rules.values())
|
||||
# 获取目标模型
|
||||
model_map = (
|
||||
{getattr(model, '__name__', str(model)): model for model in models} if models else get_data_permission_models()
|
||||
)
|
||||
|
||||
where_and_list = []
|
||||
where_or_list = []
|
||||
|
||||
for data_rule in data_rule_list:
|
||||
# 验证规则模型
|
||||
rule_model = data_rule.model
|
||||
if rule_model not in settings.DATA_PERMISSION_MODELS:
|
||||
raise errors.NotFoundError(msg='数据规则模型不存在')
|
||||
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
|
||||
for data_rule in data_rules:
|
||||
target_model = model_map.get(data_rule.model)
|
||||
if target_model is None:
|
||||
continue
|
||||
|
||||
# 验证规则列
|
||||
model_columns = [
|
||||
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
|
||||
]
|
||||
column = data_rule.column
|
||||
if column not in model_columns:
|
||||
raise errors.NotFoundError(msg='数据规则模型列不存在')
|
||||
table = target_model if isinstance(target_model, Table) else target_model.__table__
|
||||
rule_column = data_rule.column
|
||||
if rule_column not in table.columns.keys():
|
||||
continue
|
||||
if rule_column in settings.DATA_PERMISSION_COLUMN_EXCLUDE:
|
||||
continue
|
||||
|
||||
# 构建过滤条件
|
||||
column_obj = getattr(model_ins, column)
|
||||
rule_expression = data_rule.expression
|
||||
column_obj = (
|
||||
getattr(target_model, rule_column) if not isinstance(target_model, Table) else table.columns[rule_column]
|
||||
)
|
||||
column_type = table.columns[rule_column].type.python_type
|
||||
|
||||
def cast_value(value: Any) -> Any:
|
||||
"""类型转换"""
|
||||
try:
|
||||
return column_type(value) if column_type is not str else value
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
|
||||
condition = None
|
||||
match rule_expression:
|
||||
match data_rule.expression:
|
||||
case RoleDataRuleExpressionType.eq:
|
||||
condition = column_obj == data_rule.value
|
||||
condition = column_obj == cast_value(data_rule.value)
|
||||
case RoleDataRuleExpressionType.ne:
|
||||
condition = column_obj != data_rule.value
|
||||
condition = column_obj != cast_value(data_rule.value)
|
||||
case RoleDataRuleExpressionType.gt:
|
||||
condition = column_obj > data_rule.value
|
||||
condition = column_obj > cast_value(data_rule.value)
|
||||
case RoleDataRuleExpressionType.ge:
|
||||
condition = column_obj >= data_rule.value
|
||||
condition = column_obj >= cast_value(data_rule.value)
|
||||
case RoleDataRuleExpressionType.lt:
|
||||
condition = column_obj < data_rule.value
|
||||
condition = column_obj < cast_value(data_rule.value)
|
||||
case RoleDataRuleExpressionType.le:
|
||||
condition = column_obj <= data_rule.value
|
||||
condition = column_obj <= cast_value(data_rule.value)
|
||||
case RoleDataRuleExpressionType.in_:
|
||||
values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
|
||||
values = [cast_value(v.strip()) for v in data_rule.value.split(',')]
|
||||
condition = column_obj.in_(values)
|
||||
case RoleDataRuleExpressionType.not_in:
|
||||
values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value
|
||||
values = [cast_value(v.strip()) for v in data_rule.value.split(',')]
|
||||
condition = column_obj.not_in(values)
|
||||
|
||||
# 根据运算符添加到对应列表
|
||||
@@ -140,3 +152,24 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl
|
||||
where_list.append(or_(*where_or_list))
|
||||
|
||||
return or_(*where_list) if where_list else or_(1 == 1)
|
||||
|
||||
|
||||
# 此函数是为了简化调用方式,但目前无法正常工作: https://github.com/fastapi/fastapi/discussions/14438
|
||||
# def DataPermissionFilter(*models: type[Model] | AliasedClass | Alias | Table) -> type[ColumnElement[bool]]:
|
||||
# """
|
||||
# 指定模型的数据权限过滤器
|
||||
#
|
||||
# :param models: 模型类(可选,支持多个)
|
||||
# :return:
|
||||
# """
|
||||
# return Annotated[ColumnElement[bool], Depends(partial(filter_data_permission, *models))]
|
||||
|
||||
|
||||
class DataPermissionFilter:
|
||||
"""指定模型的数据权限过滤器"""
|
||||
|
||||
def __init__(self, *models: type[Model] | AliasedClass | Alias | Table) -> None:
|
||||
self.models = models
|
||||
|
||||
async def __call__(self, request: Request) -> ColumnElement[bool]:
|
||||
return filter_data_permission(request, *self.models)
|
||||
|
||||
@@ -6,7 +6,7 @@ from backend.common.exception import errors
|
||||
from backend.common.log import log
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.import_parse import import_module_cached
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
|
||||
|
||||
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import urllib.parse
|
||||
|
||||
import socketio
|
||||
|
||||
from backend.common.log import log
|
||||
@@ -8,7 +10,7 @@ from backend.database.redis import redis_client
|
||||
# 创建 Socket.IO 服务器实例
|
||||
sio = socketio.AsyncServer(
|
||||
client_manager=socketio.AsyncRedisManager(
|
||||
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DATABASE}',
|
||||
f'redis://:{urllib.parse.quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DATABASE}',
|
||||
),
|
||||
async_mode='asgi',
|
||||
cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user