mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-20 20:49:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b775dd3a16 | ||
|
|
6149d0472f | ||
|
|
a0b3f022fe | ||
|
|
69001f2745 | ||
|
|
ec6e1cdfd1 | ||
|
|
7568275126 | ||
|
|
67a6a58e57 | ||
|
|
8e5c379853 | ||
|
|
2dffe8f6a8 | ||
|
|
c802f67a5a | ||
|
|
1916261795 | ||
|
|
325016adc8 | ||
|
|
09fd380c3a | ||
|
|
e6292747bd | ||
|
|
a96b2cfad1 | ||
|
|
a0a1bde88c | ||
|
|
9251a5d5fd | ||
|
|
e3d2c68592 | ||
|
|
4d6970c6cc | ||
|
|
d4f78d6448 | ||
|
|
efec3308df | ||
|
|
0441907b00 | ||
|
|
08ee3878c1 | ||
|
|
c7e686588e | ||
|
|
495046c978 | ||
|
|
ffaa1282cc | ||
|
|
15dda9ab6e | ||
|
|
814068a9e6 | ||
|
|
39cdfdfb00 | ||
|
|
4b730099cd | ||
|
|
974972b490 | ||
|
|
c9b4678148 | ||
|
|
d20bc91465 | ||
|
|
bc8d1ab9e3 | ||
|
|
a5e9a0e805 | ||
|
|
1897b3198e | ||
|
|
6b9c7d81de | ||
|
|
e9b83d3c93 | ||
|
|
0f0e94c9b1 | ||
|
|
ba0f30cde1 | ||
|
|
9f3673d812 | ||
|
|
ef9a6b554c | ||
|
|
dbfdc6762e | ||
|
|
2e86d79150 | ||
|
|
5352f98c13 | ||
|
|
e32b4232c5 | ||
|
|
0731f38702 | ||
|
|
7ebcdda26f | ||
|
|
f32f7e56a2 | ||
|
|
330639d975 | ||
|
|
0fa5468483 | ||
|
|
bfe4aa91ce | ||
|
|
5d85bc98d2 | ||
|
|
8f90f764b6 | ||
|
|
49d12f7825 | ||
|
|
b60c9ab17c | ||
|
|
576b2fc5d0 | ||
|
|
35a368ae04 | ||
|
|
776482c5d6 | ||
|
|
92f182cd09 | ||
|
|
0428a2b07d | ||
|
|
9f193c7e7c | ||
|
|
4e0ca6a214 | ||
|
|
45842d917a | ||
|
|
8719c63424 | ||
|
|
d71db7d58d | ||
|
|
02b5e670cc | ||
|
|
41f53d7225 | ||
|
|
b71179cea9 | ||
|
|
41a64bf64c | ||
|
|
afacc84da3 | ||
|
|
3bbe460beb | ||
|
|
131bfcf063 | ||
|
|
283d762141 | ||
|
|
bd9737cc6e | ||
|
|
6d381dba74 | ||
|
|
7e6856a5e0 | ||
|
|
c96181bd08 | ||
|
|
7a934fee32 | ||
|
|
5bec0e083b | ||
|
|
a97dab1716 | ||
|
|
5ff7f4bc69 | ||
|
|
94f643c587 | ||
|
|
c98ea57a94 | ||
|
|
a58ca7f156 | ||
|
|
c08f64ae75 | ||
|
|
48352563ef | ||
|
|
f87d8f43c8 | ||
|
|
568e3ca66b | ||
|
|
a9acc31a78 | ||
|
|
2866fc45b1 | ||
|
|
dcb68698e5 | ||
|
|
06f9751c84 | ||
|
|
23975442d8 | ||
|
|
577a13b271 | ||
|
|
05bb98dada | ||
|
|
4abe09dbc3 | ||
|
|
2bedeef26c | ||
|
|
8efcc394cc | ||
|
|
8fbd2c1ede | ||
|
|
19de14ffa6 | ||
|
|
2876f57ea4 | ||
|
|
e9ed1292f0 | ||
|
|
62ece2f289 | ||
|
|
0bd41a9e67 | ||
|
|
e8ac9e848c | ||
|
|
9698dde9c2 | ||
|
|
39c3366e0a | ||
|
|
6432a66732 | ||
|
|
1aa448796b | ||
|
|
db8fff0431 | ||
|
|
28113a4d52 | ||
|
|
d843dfa1c7 | ||
|
|
9e817db278 | ||
|
|
0dfe74d1a3 | ||
|
|
eb9399e14f | ||
|
|
0c9eafbbb9 | ||
|
|
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 | ||
|
|
1f958fc42a | ||
|
|
e6e53296a8 | ||
|
|
f533de8abd | ||
|
|
adc6427aab | ||
|
|
d034b78b48 | ||
|
|
6deda906de | ||
|
|
a063105de8 | ||
|
|
02b4215742 | ||
|
|
56b6e2113f | ||
|
|
4fcc78eb94 | ||
|
|
b28a4d7673 | ||
|
|
27383e4ff5 | ||
|
|
bf16f8916d | ||
|
|
75a62ac6aa | ||
|
|
bbc4aaa020 | ||
|
|
89e5d02fe2 | ||
|
|
a93973af06 | ||
|
|
15a9527866 | ||
|
|
9147d35c8c | ||
|
|
6a065797f5 | ||
|
|
dd08775c85 | ||
|
|
0cbe635ed8 | ||
|
|
e923f13e83 | ||
|
|
889da744ef | ||
|
|
74df1d1ce2 | ||
|
|
54e09b68c7 | ||
|
|
5762834744 | ||
|
|
51354593d0 | ||
|
|
8751425e2b | ||
|
|
921f05fa19 | ||
|
|
5754c5449d |
@@ -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
|
||||
|
||||
@@ -18,4 +20,6 @@ jobs:
|
||||
with:
|
||||
file: CHANGELOG.md
|
||||
pull_request: true
|
||||
commit_summary_template: 'Update the changelog for %s'
|
||||
args: -c
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
@@ -12,13 +12,13 @@ jobs:
|
||||
name: lint ${{ matrix.python-version }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ '3.10', '3.11', '3.12', '3.13' ]
|
||||
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,11 @@ venv/
|
||||
.python-version
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
logs/
|
||||
.claude/
|
||||
.serena/
|
||||
.agents/
|
||||
.superpowers/
|
||||
superpowers/
|
||||
.codegraph/
|
||||
.pi/
|
||||
|
||||
+16
-6
@@ -1,25 +1,35 @@
|
||||
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/charliermarsh/ruff-pre-commit
|
||||
rev: v0.13.2
|
||||
- repo: https://github.com/tombi-toml/tombi-pre-commit
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: tombi-lint
|
||||
args: ["--offline"]
|
||||
- id: tombi-format
|
||||
args: ["--offline"]
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.16.3
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
# https://github.com/astral-sh/ruff-pre-commit/issues/64
|
||||
- '--config'
|
||||
- 'backend/.ruff.toml'
|
||||
- '--fix'
|
||||
- '--unsafe-fixes'
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.8.22
|
||||
rev: 0.12.3
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
- id: uv-export
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"$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"],
|
||||
"additionalProperties": false,
|
||||
|
||||
"properties": {
|
||||
"plugin": {
|
||||
"type": "object",
|
||||
"description": "Plugin metadata",
|
||||
"required": ["summary", "version", "description", "author", "tags"],
|
||||
"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",
|
||||
"minItems": 1,
|
||||
"description": "Plugin tags for categorization",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["ai", "mcp", "agent", "auth", "storage", "notification", "task", "payment", "other"]
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
},
|
||||
"database": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Supported databases. Required for app-level and extend-level plugins, optional for capability-level plugins without models.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql"]
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "array",
|
||||
"description": "List of plugin names this plugin depends on (used for startup order)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"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" },
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1425
-602
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
## 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 run fba init --auto
|
||||
```
|
||||
|
||||
3. Checkout
|
||||
|
||||
Checkout a new branch and make your changes
|
||||
|
||||
```shell
|
||||
git checkout -b your-new-feature-branch
|
||||
```
|
||||
|
||||
4. Format and Lint
|
||||
|
||||
```shell
|
||||
fba format
|
||||
```
|
||||
|
||||
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 with preview
|
||||
|
||||
- `scripts/lint.sh`: Perform prek formatting
|
||||
|
||||
- `scripts/export.sh`: Execute uv export dependency package
|
||||
+17
-44
@@ -1,68 +1,41 @@
|
||||
# Select the image to build based on SERVER_TYPE, defaulting to fastapi_server, or docker-compose build args
|
||||
ARG SERVER_TYPE=fastapi_server
|
||||
# Python environment from uv
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS builder
|
||||
|
||||
# === Python environment from uv ===
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
|
||||
|
||||
# Used for build Python packages
|
||||
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc python3-dev \
|
||||
&& apt-get install -y --no-install-recommends gcc make python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . /fba
|
||||
|
||||
WORKDIR /fba
|
||||
|
||||
# Configure uv environment
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_NO_CACHE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/usr/local
|
||||
|
||||
# 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
|
||||
# Preinstall plugin dependencies
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)"
|
||||
|
||||
# Single FastAPI server image
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim
|
||||
|
||||
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/*
|
||||
|
||||
COPY --from=builder /fba /fba
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
|
||||
|
||||
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
WORKDIR /fba/backend
|
||||
|
||||
# === FastAPI server image ===
|
||||
FROM base_server AS fastapi_server
|
||||
|
||||
COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fastapi_server
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
|
||||
|
||||
# === Celery server image ===
|
||||
FROM base_server AS celery
|
||||
|
||||
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/celery
|
||||
|
||||
RUN chmod +x celery-start.sh
|
||||
|
||||
EXPOSE 8555
|
||||
|
||||
CMD ["./celery-start.sh"]
|
||||
|
||||
# Build image
|
||||
FROM ${SERVER_TYPE}
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
@@ -1,78 +1,7 @@
|
||||
<div align="center">
|
||||
# FastAPI Best Architecture Slim
|
||||
|
||||
<img alt="The logo includes the abstract combination of the three letters FBA, forming a lightning bolt that seems to spread out from the ground" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
|
||||
专注于基本架构的 API 开发简化版本
|
||||
|
||||
# FastAPI Best Architecture
|
||||
完整版请查看 [FastAPI Best Architecture](https://github.com/fastapi-practices/fastapi-best-architecture)
|
||||
|
||||
Enterprise-level backend architecture solution
|
||||
|
||||
English | [简体中文](./README.zh-CN.md)
|
||||
|
||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||

|
||||

|
||||

|
||||
[](https://pydantic.dev)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://github.com/astral-sh/uv)
|
||||

|
||||
[](https://discord.com/invite/yNN3wTbVAC)
|
||||

|
||||
[](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
|
||||
|
||||
</div>
|
||||
|
||||
## Pseudo 3-tier architecture
|
||||
|
||||
The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating
|
||||
|
||||
In python web development, there is no common standard for the concept of 3-tier architecture, so we'll call it a
|
||||
pseudo 3-tier architecture here
|
||||
|
||||
But please note that we don't have a traditional multi-app structure (django, springBoot...) If you don't like this
|
||||
pattern, use templates to transform it to your heart's content!
|
||||
|
||||
| workflow | java | fastapi_best_architecture |
|
||||
|----------------|----------------|---------------------------|
|
||||
| view | controller | api |
|
||||
| data transmit | dto | schema |
|
||||
| business logic | service + impl | service |
|
||||
| data access | dao / mapper | crud |
|
||||
| model | model / entity | model |
|
||||
|
||||
## Help
|
||||
|
||||
For more details, please check
|
||||
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
|
||||
</a>
|
||||
|
||||
## Special thanks
|
||||
|
||||
- [FastAPI](https://fastapi.tiangolo.com/)
|
||||
- [Pydantic](https://docs.pydantic.dev/latest/)
|
||||
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
|
||||
- [Casbin](https://casbin.org/zh/)
|
||||
- [Ruff](https://beta.ruff.rs/docs/)
|
||||
- ...
|
||||
|
||||
## Interactivity
|
||||
|
||||
[Discord](https://wu-clan.github.io/homepage/)
|
||||
|
||||
## Sponsor us
|
||||
|
||||
If this program has helped you, you can sponsor us with some coffee
|
||||
beans: [:coffee: Sponsor :coffee:](https://wu-clan.github.io/sponsor/)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed by the terms of
|
||||
the [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) license
|
||||
|
||||
[](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
|
||||
简体中文 | [English](./README.zh-CN.md)
|
||||
|
||||
+4
-70
@@ -1,73 +1,7 @@
|
||||
<div align="center">
|
||||
# FastAPI Best Architecture Slim
|
||||
|
||||
<img alt="Logo 包含了 FBA 三个字母抽象结合,形成了一个类似从地面扩散投影上来的闪电" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
|
||||
A simplified version focused on basic architecture for API development
|
||||
|
||||
# FastAPI Best Architecture
|
||||
See the [full FastAPI Best Architecture project](https://github.com/fastapi-practices/fastapi-best-architecture)
|
||||
|
||||
企业级后端架构解决方案
|
||||
|
||||
简体中文 | [English](./README.md)
|
||||
|
||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||

|
||||

|
||||

|
||||
[](https://pydantic.dev)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://github.com/astral-sh/uv)
|
||||

|
||||
[](https://discord.com/invite/yNN3wTbVAC)
|
||||

|
||||
[](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
|
||||
|
||||
</div>
|
||||
|
||||
## 伪三层架构
|
||||
|
||||
mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷
|
||||
|
||||
在 python web 开发中,三层架构的概念并没有通用标准,所以这里我们称之为伪三层架构
|
||||
|
||||
但请注意,我们并没有传统的多应用程序结构(django、springBoot...),如果您不喜欢这种模式,可以使用模板对其进行随意改造!
|
||||
|
||||
| 工作流程 | java | fastapi_best_architecture |
|
||||
|------|----------------|---------------------------|
|
||||
| 视图 | controller | api |
|
||||
| 数据传输 | dto | schema |
|
||||
| 业务逻辑 | service + impl | service |
|
||||
| 数据访问 | dao / mapper | crud |
|
||||
| 模型 | model / entity | model |
|
||||
|
||||
## 帮助
|
||||
|
||||
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
|
||||
</a>
|
||||
|
||||
## 特别鸣谢
|
||||
|
||||
- [FastAPI](https://fastapi.tiangolo.com/)
|
||||
- [Pydantic](https://docs.pydantic.dev/latest/)
|
||||
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
|
||||
- [Casbin](https://casbin.org/zh/)
|
||||
- [Ruff](https://beta.ruff.rs/docs/)
|
||||
- ...
|
||||
|
||||
## 互动
|
||||
|
||||
[Discord](https://wu-clan.github.io/homepage/)
|
||||
|
||||
## 赞助我们
|
||||
|
||||
如果此项目能够帮助到你,你可以赞助作者一些咖啡豆表示鼓励:[:coffee: Sponsor :coffee:](https://wu-clan.github.io/sponsor/)
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目由 [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) 许可证的条款进行许可
|
||||
|
||||
[](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
|
||||
English | [简体中文](./README.md)
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
# FBA-Slim 瘦身指南
|
||||
|
||||
> 本文档记录了 fba-slim 与 fba 完整版之间的差异,方便日后从完整版合并代码时快速瘦身
|
||||
|
||||
## 功能差异概览
|
||||
|
||||
| 模块 | 完整版 | 精简版 | 说明 |
|
||||
|-------------------------------|-----|-----|-----------------------------|
|
||||
| 用户认证 (JWT) | ✅ | ✅ | 完整保留 |
|
||||
| RBAC (角色/菜单/部门/权限校验) | ✅ | ❌ | 整体移除,仅保留 `DependsSuperUser` |
|
||||
| 用户 CRUD | ✅ | ✅ | 保留(移除 dept/role 关联) |
|
||||
| 操作日志/登录日志 | ✅ | ❌ | 移除 DB 日志,中间件仅控制台输出 |
|
||||
| 多级缓存 (Local + Redis + PubSub) | ✅ | ✅ | 完整保留 |
|
||||
| Snowflake 分布式 ID | ✅ | ✅ | 完整保留 |
|
||||
| 文件上传 | ✅ | ✅ | 完整保留 |
|
||||
| 密码安全/历史记录 | ✅ | ✅ | 完整保留 |
|
||||
| config 插件 | ✅ | ✅ | 保留 |
|
||||
| 插件核心系统 | ✅ | ✅ | 保留插件安装/卸载机制,内置仅 `config` |
|
||||
| Celery 任务系统 | ✅ | ❌ | 整体移除 |
|
||||
| Socket.IO 实时通信 | ✅ | ❌ | 整体移除 |
|
||||
| Prometheus + OTel 可观测性 | ✅ | ❌ | 整体移除 |
|
||||
| 监控 API (online/redis/server) | ✅ | ❌ | 整体移除 |
|
||||
| 数据权限 (DataRule/DataScope) | ✅ | ❌ | 整体移除 |
|
||||
| dict 插件 | ✅ | ❌ | 整体移除 |
|
||||
| email 插件 | ✅ | ❌ | 整体移除 |
|
||||
| notice 插件 | ✅ | ❌ | 整体移除 |
|
||||
| oauth2 插件 | ✅ | ❌ | 整体移除 |
|
||||
| code_generator 插件 | ✅ | ❌ | 整体移除 |
|
||||
|
||||
## 插件范围
|
||||
|
||||
精简版仓库只内置 `config` 插件,`backend/plugin/` 下不得保留其他业务插件目录
|
||||
|
||||
- `backend/core/conf.py` 中 `PLUGIN_REQUIRED` 固定为 `['config']`
|
||||
- `backend/plugin/config/` 是唯一随仓库发布的插件
|
||||
- 插件核心、CLI 和插件管理 API 仍然保留,用于后续按需安装外部插件
|
||||
- 不保留 AI、dict、email、notice、oauth2、code_generator 等插件及其依赖
|
||||
|
||||
---
|
||||
|
||||
## 已删除的目录
|
||||
|
||||
```
|
||||
backend/app/task/ # Celery 任务系统
|
||||
backend/common/socketio/ # Socket.IO 实时通信
|
||||
backend/common/observability/ # Prometheus + OpenTelemetry
|
||||
backend/app/admin/api/v1/monitor/ # 监控 API (online/redis/server)
|
||||
backend/app/admin/tests/ # 测试文件
|
||||
backend/app/admin/api/v1/log/ # 日志 API (login_log/opera_log)
|
||||
backend/plugin/dict/ # 字典插件
|
||||
backend/plugin/email/ # 邮件插件
|
||||
backend/plugin/notice/ # 通知插件
|
||||
backend/plugin/oauth2/ # OAuth2 插件
|
||||
backend/plugin/code_generator/ # 代码生成插件
|
||||
backend/plugin/ai/ # AI 插件
|
||||
deploy/backend/grafana/ # Grafana 部署配置
|
||||
```
|
||||
|
||||
## 已删除的文件
|
||||
|
||||
```
|
||||
# RBAC (角色/菜单/部门)
|
||||
backend/app/admin/model/role.py
|
||||
backend/app/admin/model/menu.py
|
||||
backend/app/admin/model/dept.py
|
||||
backend/app/admin/model/m2m.py
|
||||
backend/app/admin/schema/role.py
|
||||
backend/app/admin/schema/menu.py
|
||||
backend/app/admin/schema/dept.py
|
||||
backend/app/admin/crud/crud_role.py
|
||||
backend/app/admin/crud/crud_menu.py
|
||||
backend/app/admin/crud/crud_dept.py
|
||||
backend/app/admin/service/role_service.py
|
||||
backend/app/admin/service/menu_service.py
|
||||
backend/app/admin/service/dept_service.py
|
||||
backend/app/admin/api/v1/sys/role.py
|
||||
backend/app/admin/api/v1/sys/menu.py
|
||||
backend/app/admin/api/v1/sys/dept.py
|
||||
backend/common/security/rbac.py
|
||||
backend/common/security/permission.py
|
||||
backend/utils/build_tree.py
|
||||
|
||||
# 数据权限
|
||||
backend/app/admin/model/data_rule.py
|
||||
backend/app/admin/model/data_scope.py
|
||||
backend/app/admin/schema/data_rule.py
|
||||
backend/app/admin/schema/data_scope.py
|
||||
backend/app/admin/crud/crud_data_rule.py
|
||||
backend/app/admin/crud/crud_data_scope.py
|
||||
backend/app/admin/service/data_rule_service.py
|
||||
backend/app/admin/service/data_scope_service.py
|
||||
backend/app/admin/api/v1/sys/data_rule.py
|
||||
backend/app/admin/api/v1/sys/data_scope.py
|
||||
|
||||
# 监控 API
|
||||
backend/app/admin/schema/monitor.py
|
||||
|
||||
# 可观测性
|
||||
backend/common/observability/otel.py
|
||||
backend/common/observability/prometheus/fastapi.py
|
||||
backend/common/observability/prometheus/queue.py
|
||||
backend/common/observability/prometheus/sqlalchemy.py
|
||||
|
||||
# 日志系统
|
||||
backend/app/admin/model/login_log.py
|
||||
backend/app/admin/model/opera_log.py
|
||||
backend/app/admin/schema/login_log.py
|
||||
backend/app/admin/schema/opera_log.py
|
||||
backend/app/admin/crud/crud_login_log.py
|
||||
backend/app/admin/crud/crud_opera_log.py
|
||||
backend/app/admin/service/login_log_service.py
|
||||
backend/app/admin/service/opera_log_service.py
|
||||
backend/common/queue.py
|
||||
|
||||
# 部署
|
||||
deploy/backend/supervisor/fba_celery_beat.conf
|
||||
deploy/backend/supervisor/fba_celery_flower.conf
|
||||
deploy/backend/supervisor/fba_celery_worker.conf
|
||||
deploy/backend/grafana/dashboards/fba_celery.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已修改的文件
|
||||
|
||||
### 模型层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---------------------------------------|-------------------------------------|
|
||||
| `backend/app/admin/model/__init__.py` | 仅保留 `User`、`UserPasswordHistory` 导出 |
|
||||
| `backend/app/admin/model/user.py` | 删除 `dept_id` 字段 |
|
||||
|
||||
### Schema 层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `backend/app/admin/schema/user.py` | 删除 `dept_id`/`roles` 字段、`AddUserRoleParam`/`GetUserInfoWithRelationDetail`/`GetCurrentUserInfoWithRelationDetail`/`AddOAuth2UserParam` 类 |
|
||||
|
||||
### CRUD 层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---------------------------------------|-----------------------------------------------------------------------------------------------------------------|
|
||||
| `backend/app/admin/crud/crud_user.py` | 删除 Role/Dept/Menu/m2m 全部引用,移除 `get_join()` 方法、JoinConfig、m2m 操作;`add()`/`update()`/`delete()`/`get_select()` 简化 |
|
||||
|
||||
### Service 层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `backend/app/admin/service/user_service.py` | 删除 `get_roles()` 方法、dept/role 验证逻辑、`dept` 参数;`get_userinfo()` 改用 `user_dao.get()`/`get_by_username()`;`update_email()` 删除邮箱验证码(email 插件已移除),保留邮箱唯一性校验 |
|
||||
| `backend/app/admin/service/auth_service.py` | 删除 `login_log_service`/`menu_dao` 引用、`get_codes()` 方法、`background_tasks` 参数 |
|
||||
|
||||
### Utils/安全层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------------------------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `backend/app/admin/utils/cache.py` | 删除 `clear_by_role_id()`/`clear_by_menu_id()`/`clear_by_data_scope_id()`/`clear_by_data_rule_id()` 方法 |
|
||||
| `backend/utils/trace_id.py` | 删除 `OtelTraceIdPlugin` 类 |
|
||||
| `backend/utils/dynamic_config.py` | 保留上游懒加载 config 插件实现,删除邮箱插件相关 `load_email_config()`,仅保留用户安全与登录配置加载 |
|
||||
|
||||
### API 层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|----------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `backend/app/admin/api/v1/sys/user.py` | 删除 `get_user_roles` 路由、`dept` 参数、`update_user_email` 的 captcha 参数;`delete_user` 改用 `DependsSuperUser`;响应类型改为 `GetUserInfoDetail` |
|
||||
| `backend/app/admin/api/v1/sys/file.py` | `RequestPermission + DependsRBAC` 改为 `DependsJwtAuth` |
|
||||
| `backend/app/admin/api/v1/sys/__init__.py` | 仅保留 `user_router`、`file_router`、`plugin_router` |
|
||||
| `backend/app/admin/api/v1/auth/auth.py` | 删除 `get_codes` 路由、`background_tasks` 参数 |
|
||||
| `backend/app/admin/api/router.py` | 删除 `monitor_router`、`log_router` |
|
||||
| `backend/app/router.py` | 删除 `task_v1` |
|
||||
| `backend/app/admin/api/v1/sys/plugin.py` | `RequestPermission + DependsRBAC` 改为 `DependsSuperUser` |
|
||||
| `backend/plugin/config/api/v1/sys/config.py` | `RequestPermission + DependsRBAC` 改为 `DependsSuperUser` |
|
||||
|
||||
### JWT/中间件层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|----------------------------------------------|------------------------------------------------------------------------------------------------------------------|
|
||||
| `backend/common/security/jwt.py` | `GetUserInfoWithRelationDetail` → `GetUserInfoDetail`;`get_current_user()` 改用 `user_dao.get()`,删除 dept/role 状态检查;保留上游 `jwt_authentication_verify` 与 `TOKEN_REQUEST_UNDERLYING_SECURITY` |
|
||||
| `backend/middleware/jwt_auth_middleware.py` | `GetUserInfoWithRelationDetail` → `GetUserInfoDetail` |
|
||||
| `backend/middleware/opera_log_middleware.py` | 移除 DB 队列/消费者/入库,改为纯控制台日志输出;异常码读取需包含 `__request_authentication_exception__` |
|
||||
| `backend/middleware/access_middleware.py` | 删除 Prometheus 导入和 2 处计数器调用 |
|
||||
|
||||
### 核心层
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `backend/core/registrar.py` | 删除 socketio/prometheus/otel 导入、`register_socket_app()`、`register_metrics()`、OtelTraceIdPlugin、`create_task(OperaLogMiddleware.consumer())`;保留 lifespan `try/finally` 与上游 `dispose_database()` |
|
||||
| `backend/core/conf.py` | 删除 CELERY/GRAFANA/DATA_PERMISSION/OAUTH2/EMAIL/WS/CODE_GENERATOR/OPERA_LOG_*/RBAC_ROLE_MENU_*/CACHE_DICT_* 配置段;保留 `DATABASE_SOURCES`、`TOKEN_REQUEST_UNDERLYING_SECURITY`;`PLUGIN_REQUIRED` 仅保留 `config` |
|
||||
| `backend/database/db.py` | 删除 SQLAlchemy 连接池 Prometheus 指标监听 |
|
||||
| `backend/alembic/env.py` | 保持上游 `get_database_url()` 命名 |
|
||||
| `backend/main.py` | 保留上游插件准备流程 `_prepare_plugins()`,检查必需插件并安装缺失插件依赖 |
|
||||
| `backend/plugin/core.py` | `get_required_plugins()` 删除 RBAC 模式相关 `casbin_rbac` 逻辑 |
|
||||
| `backend/plugin/hooks.py` | 保留上游插件 setup/lifespan hooks,删除 OpenTelemetry hook |
|
||||
|
||||
### CLI
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------------------|----------------------------------------------------------------------------------|
|
||||
| `backend/cli.py` | 删除 Celery/代码生成相关命令,保留插件安装卸载与依赖同步;`FbaCli.subcmd` 为 `Init \| Run \| Add \| Remove \| Deps \| Format \| Alembic` |
|
||||
|
||||
### Enum
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `backend/common/enums.py` | 删除 `MenuType`、`MethodType`、`BuildTreeType`、`RoleDataRuleOperatorType`、`RoleDataRuleExpressionType`、`LoginLogStatusType`、`OperaLogCipherType` 枚举;保留上游 `PluginLevelType.capability` |
|
||||
| `backend/plugin/config/enums.py` | 仅保留 `ConfigType.user_security` 与 `ConfigType.login` |
|
||||
|
||||
### SQL 初始化数据
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|-------------------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `backend/sql/mysql/init_test_data.sql` | 仅保留 sys_user INSERT(删除 dept/menu/role/role_menu/user_role/data_scope/data_rule 等) |
|
||||
| `backend/sql/mysql/init_snowflake_test_data.sql` | 同上 |
|
||||
| `backend/sql/postgresql/init_test_data.sql` | 同上 |
|
||||
| `backend/sql/postgresql/init_snowflake_test_data.sql` | 同上 |
|
||||
| `backend/plugin/config/sql/**/init*.sql` | 删除 `sys_menu` 初始化与 EMAIL 配置,仅保留 `sys_config` 中用户安全/登录配置 |
|
||||
| `backend/plugin/config/sql/**/destroy*.sql` | 删除 `sys_menu` 清理语句,仅保留 `sys_config` 清理 |
|
||||
|
||||
### 配置/部署
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---------------------------------------------|--------------------------------------------------------------------------------------|
|
||||
| `backend/.env.example` | 删除 Celery/RabbitMQ/OAuth2/Email 环境变量;保留 `DATABASE_SOURCES` |
|
||||
| `pyproject.toml` | 项目名改为 `fba-slim`;删除 celery/socketio/opentelemetry/prometheus/psutil/flower/gevent/aio-pika/jinja2/psycopg/pymysql 等依赖;保留 `dulwich`(插件 Git 安装仍依赖) |
|
||||
| `README.md` / `README.zh-CN.md` | 使用 slim 专用说明,并指向完整版仓库 |
|
||||
| `docker-compose.yml` | 删除 rabbitmq/celery/grafana 全套容器 |
|
||||
| `Dockerfile` | 简化为单一 server 镜像,删除 celery worker/beat/flower 阶段;保留插件依赖预安装步骤,用于打包随镜像发布的插件依赖 |
|
||||
| `deploy/backend/docker-compose/.env.docker` | 删除 RabbitMQ/Celery/Grafana 端口映射 |
|
||||
| `deploy/backend/docker-compose/.env.server` | 删除 Celery/OAuth2/Email 环境变量 |
|
||||
| `deploy/backend/nginx.conf` | 删除 Flower 代理配置 |
|
||||
|
||||
---
|
||||
|
||||
## 插件依赖处理约定
|
||||
|
||||
- `Dockerfile` 必须保留 `# Preinstall plugin dependencies`,用于镜像构建时为随包插件安装依赖;当前随包插件仅有 `config`
|
||||
- `backend/main.py` 必须保留上游 `_prepare_plugins()`,服务启动时会检查必需插件并安装缺失插件依赖
|
||||
- 后续通过插件安装接口安装 zip/git 插件时,仍由 `backend/plugin/requirements.py` 安装该插件依赖
|
||||
|
||||
---
|
||||
|
||||
## 合并指南
|
||||
|
||||
从 fba 完整版同步代码到 fba-slim 后,需要关注以下冲突区域:
|
||||
|
||||
### 快速检测 rg 模式
|
||||
|
||||
合并后运行以下命令,快速找出需要处理的非 slim 引用:
|
||||
|
||||
```bash
|
||||
# 全量残留扫描:唯一可接受命中是 OperaLogMiddleware 导入(控制台日志保留)
|
||||
rg -n "celery|CELERY_|app\.task|python-socketio|socketio|prometheus|opentelemetry|psutil|flower|gevent|aio-pika|rabbitmq|GRAFANA_|OAUTH2_|EMAIL_CAPTCHA|CACHE_DICT|RBAC_ROLE_MENU|DATA_PERMISSION|RequestPermission|DependsRBAC|GetUserInfoWithRelation|GetCurrentUserInfoWithRelation|sys_menu|sys_role|sys_dept|data_scope|data_rule|login_log|opera_log|casbin_rbac|OtelTraceIdPlugin|init_plugin_otel|AddOAuth2UserParam" backend pyproject.toml Dockerfile docker-compose.yml deploy -g '!**/__pycache__/**'
|
||||
|
||||
# 精确 DB 日志 / RBAC / 数据权限残留扫描:应无命中
|
||||
rg -n "opera_log_service|login_log_service|OPERA_LOG_|batch_dequeue|opera_log_queue|LoginLog|DataRule|DataScope|RequestPermission|DependsRBAC|GetUserInfoWithRelationDetail|CACHE_DICT_REDIS_PREFIX|EMAIL_CAPTCHA_REDIS_PREFIX" backend pyproject.toml Dockerfile docker-compose.yml deploy -g '!**/__pycache__/**'
|
||||
|
||||
# 已移除依赖残留扫描:应无命中
|
||||
rg -n "celery|celery-aio|opentelemetry|prometheus-client|psutil|python-socketio|flower|gevent|aio-pika|psycopg|pymysql" pyproject.toml
|
||||
|
||||
# 内置插件校验:输出必须只有 config
|
||||
find backend/plugin -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' -exec basename {} \; | sort
|
||||
test "$(find backend/plugin -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' -exec basename {} \; | sort)" = config
|
||||
```
|
||||
|
||||
> 注:slim 版仍保留 `backend/middleware/opera_log_middleware.py` 的 `OperaLogMiddleware`,用于控制台访问日志;检测 DB 日志残留时不要把该中间件类名视为问题。
|
||||
|
||||
### 高冲突文件
|
||||
|
||||
以下文件在完整版更新时最容易产生冲突:
|
||||
|
||||
1. **`backend/core/conf.py`** — 配置字段差异最大
|
||||
2. **`backend/core/registrar.py`** — 中间件和组件注册差异
|
||||
3. **`backend/cli.py`** — 删除 Celery/代码生成命令,保留插件 `Add`/`Remove`/`Deps`
|
||||
4. **`backend/main.py`** — 上游插件准备流程必须保留
|
||||
5. **`backend/common/security/jwt.py`** — `GetUserInfoDetail` vs `GetUserInfoWithRelationDetail`,`get_current_user()` 差异
|
||||
6. **`backend/app/admin/crud/crud_user.py`** — 无 get_join/JoinConfig/m2m 操作
|
||||
7. **`backend/app/admin/service/auth_service.py`** — 登录日志、menu_dao、background_tasks 差异
|
||||
8. **`backend/middleware/opera_log_middleware.py`** — 完整版有 DB 队列,slim 版仅控制台
|
||||
9. **`backend/middleware/jwt_auth_middleware.py`** — schema 类型差异
|
||||
10. **`backend/plugin/config/sql/**`** — config 插件 SQL 不能再引用已删除的 `sys_menu`
|
||||
11. **`pyproject.toml`** — 依赖列表差异
|
||||
12. **`docker-compose.yml`** — 容器编排差异
|
||||
13. **`Dockerfile`** — 构建阶段差异,需保留插件依赖预安装步骤但删除 Celery 多镜像阶段
|
||||
14. **`deploy/backend/nginx.conf`** — Flower 代理容易残留
|
||||
|
||||
### 合并策略
|
||||
|
||||
1. **以上游新版本为基准**,只按本文档删除标准瘦身项,不要直接用旧 slim 文件覆盖上游正常演进
|
||||
2. **需要手动合并**的文件:CRUD/Service/API 层(可能有新增功能需要保留,但需移除 RBAC/数据权限/可观测性引用)
|
||||
3. **直接接受完整版**的文件:不涉及上述移除功能的纯业务逻辑改动
|
||||
4. **合并后运行上述 `rg` 命令**清理残留引用
|
||||
5. **插件依赖安装保持上游行为**:`backend/main.py` 启动时检测并安装缺失插件依赖,Dockerfile 构建时预装随包插件依赖
|
||||
6. **运行 `fba format` 和导入检查**确认格式、锁文件、导出依赖和导入无误
|
||||
7. **插件 CLI 必须保留**:插件核心系统完整保留,因此 `fba add` / `fba remove` / `fba deps` 与插件安装 API 一并保留;不要再删除插件安装卸载命令
|
||||
|
||||
### 验证清单
|
||||
|
||||
瘦身完成后至少运行:
|
||||
|
||||
```bash
|
||||
fba format
|
||||
uv run python -c "from backend.main import app; print(app.title); print(sorted(app.openapi()['paths']))"
|
||||
```
|
||||
|
||||
> FastAPI 0.141+ 将 `include_router` 存为嵌套 `_IncludedRouter`,`len(app.routes)` 不再等于接口数量;请用 OpenAPI paths 核对。精简版应只有 auth/user/file/plugin/config,不应出现 role/menu/dept/log/monitor/task 或 AI 插件路由。
|
||||
+6
-23
@@ -1,11 +1,14 @@
|
||||
# Env
|
||||
ENVIRONMENT='dev'
|
||||
# Database
|
||||
DATABASE_TYPE='mysql'
|
||||
DATABASE_TYPE='postgresql'
|
||||
DATABASE_HOST='127.0.0.1'
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER='root'
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USER='postgres'
|
||||
DATABASE_PASSWORD='123456'
|
||||
# 额外数据源
|
||||
# DATABASE_SOURCES='{"read": "postgresql+asyncpg://user:password@host:5432/fba"}'
|
||||
DATABASE_SOURCES='{}'
|
||||
# Redis
|
||||
REDIS_HOST='127.0.0.1'
|
||||
REDIS_PORT=6379
|
||||
@@ -13,23 +16,3 @@ 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
|
||||
# Rabbitmq
|
||||
CELERY_RABBITMQ_HOST='127.0.0.1'
|
||||
CELERY_RABBITMQ_PORT=5672
|
||||
CELERY_RABBITMQ_USERNAME='guest'
|
||||
CELERY_RABBITMQ_PASSWORD='guest'
|
||||
# [ Plugin ] oauth2
|
||||
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=''
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
line-length = 120
|
||||
cache-dir = ".ruff_cache"
|
||||
target-version = "py310"
|
||||
unsafe-fixes = true
|
||||
show-fixes = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"E",
|
||||
"F",
|
||||
"I",
|
||||
"TC",
|
||||
# W
|
||||
"W505",
|
||||
# PT
|
||||
"PT018",
|
||||
# SIM
|
||||
"SIM101",
|
||||
"SIM114",
|
||||
# PGH
|
||||
"PGH004",
|
||||
# PL
|
||||
"PLE1142",
|
||||
# RUF
|
||||
"RUF100",
|
||||
# UP
|
||||
"UP007"
|
||||
]
|
||||
preview = true
|
||||
ignore = ["FURB101"]
|
||||
|
||||
[lint.flake8-pytest-style]
|
||||
mark-parentheses = false
|
||||
parametrize-names-type = "list"
|
||||
parametrize-values-row-type = "list"
|
||||
parametrize-values-type = "tuple"
|
||||
|
||||
[lint.flake8-unused-arguments]
|
||||
ignore-variadic-names = true
|
||||
|
||||
[lint.isort]
|
||||
lines-between-types = 1
|
||||
order-by-type = true
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"**/api/v1/*.py" = ["TC"]
|
||||
"**/model/*.py" = ["TC003"]
|
||||
"**/model/__init__.py" = ["F401"]
|
||||
"**/tests/*.py" = ["E402"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
quote-style = "single"
|
||||
docstring-code-format = true
|
||||
skip-magic-trailing-comma = false
|
||||
@@ -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
-6
@@ -1,9 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from backend.common.i18n import i18n
|
||||
import sqlalchemy as sa
|
||||
|
||||
__version__ = '1.8.2'
|
||||
from backend.utils.dynamic_import import get_all_models
|
||||
|
||||
|
||||
# 初始化 i18n
|
||||
i18n.load_locales()
|
||||
def _register_model_globals() -> None:
|
||||
"""导入所有模型并注册到 backend 模块命名空间"""
|
||||
for model_obj in get_all_models():
|
||||
model_name = model_obj.name if isinstance(model_obj, sa.Table) else model_obj.__name__
|
||||
if model_name not in globals():
|
||||
globals()[model_name] = model_obj
|
||||
|
||||
|
||||
_register_model_globals()
|
||||
|
||||
|
||||
__version__ = '1.15.1'
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-24
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# ruff: noqa: F403, F401, I001, RUF100
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
@@ -10,41 +8,38 @@ from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from backend.app import get_app_models
|
||||
from backend.common.model import MappedBase
|
||||
from backend.core import path_conf
|
||||
from backend.database.db import SQLALCHEMY_DATABASE_URL
|
||||
from backend.plugin.tools import get_plugin_models
|
||||
|
||||
# import models
|
||||
for cls in get_app_models() + get_plugin_models():
|
||||
class_name = cls.__name__
|
||||
if class_name not in globals():
|
||||
globals()[class_name] = cls
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.database.db import get_database_url
|
||||
|
||||
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
|
||||
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
|
||||
|
||||
# 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(
|
||||
'sqlalchemy.url', SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%')
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
get_database_url().render_as_string(hide_password=False).replace('%', '%%'),
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
@@ -56,7 +51,7 @@ def run_migrations_offline():
|
||||
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,
|
||||
@@ -72,9 +67,9 @@ def run_migrations_offline():
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
# 当迁移无变化时,不生成迁移记录
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if alembic_config.cmd_opts.autogenerate:
|
||||
def process_revision_directives(context, revision, directives) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""当迁移无变化时,不生成迁移记录"""
|
||||
if config.cmd_opts.autogenerate:
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
@@ -100,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,35 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
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 = os.path.join(BASE_PATH, 'app')
|
||||
list_dirs = os.listdir(app_path)
|
||||
|
||||
apps = []
|
||||
|
||||
for d in list_dirs:
|
||||
if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__':
|
||||
apps.append(d)
|
||||
|
||||
objs = []
|
||||
|
||||
for app in apps:
|
||||
module_path = f'backend.app.{app}.model'
|
||||
obj = get_model_objects(module_path)
|
||||
if obj:
|
||||
objs.extend(obj)
|
||||
|
||||
return objs
|
||||
|
||||
|
||||
# import all app models for auto create db tables
|
||||
for cls in get_app_models():
|
||||
class_name = cls.__name__
|
||||
if class_name not in globals():
|
||||
globals()[class_name] = cls
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.auth import router as auth_router
|
||||
from backend.app.admin.api.v1.log import router as log_router
|
||||
from backend.app.admin.api.v1.monitor import router as monitor_router
|
||||
from backend.app.admin.api.v1.sys import router as sys_router
|
||||
from backend.core.conf import settings
|
||||
|
||||
@@ -12,5 +8,3 @@ v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
|
||||
|
||||
v1.include_router(auth_router)
|
||||
v1.include_router(sys_router)
|
||||
v1.include_router(log_router)
|
||||
v1.include_router(monitor_router)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.auth.auth import router as auth_router
|
||||
|
||||
@@ -1,49 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.security import HTTPBasicCredentials
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
from starlette.background import BackgroundTasks
|
||||
from pyrate_limiter import Duration, Rate
|
||||
|
||||
from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken
|
||||
from backend.app.admin.schema.user import AuthLoginParam
|
||||
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()
|
||||
|
||||
|
||||
@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证')
|
||||
async def login_swagger(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetSwaggerToken:
|
||||
token, user = await auth_service.swagger_login(obj=obj)
|
||||
return GetSwaggerToken(access_token=token, user=user)
|
||||
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) # 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(
|
||||
request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
|
||||
db: CurrentSessionTransaction,
|
||||
response: Response,
|
||||
obj: AuthLoginParam,
|
||||
) -> ResponseSchemaModel[GetLoginToken]:
|
||||
data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks)
|
||||
data = await auth_service.login(db=db, response=response, obj=obj)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/codes', summary='获取所有授权码', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
|
||||
async def get_codes(request: Request) -> ResponseSchemaModel[list[str]]:
|
||||
codes = await auth_service.get_codes(request=request)
|
||||
return response_base.success(data=codes)
|
||||
|
||||
|
||||
@router.post('/refresh', summary='刷新 token')
|
||||
async def refresh_token(request: Request) -> ResponseSchemaModel[GetNewToken]:
|
||||
data = await auth_service.refresh_token(request=request)
|
||||
async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]:
|
||||
data = await auth_service.refresh_token(db=db, request=request, response=response)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from uuid import uuid4
|
||||
import uuid
|
||||
|
||||
from fast_captcha import img_captcha
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
from fastapi import APIRouter, Depends
|
||||
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()
|
||||
|
||||
@@ -18,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(request: Request) -> 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)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.log.login_log import router as login_log
|
||||
from backend.app.admin.api.v1.log.opera_log import router as opera_log
|
||||
|
||||
router = APIRouter(prefix='/logs')
|
||||
|
||||
router.include_router(login_log, prefix='/login', tags=['登录日志'])
|
||||
router.include_router(opera_log, prefix='/opera', tags=['操作日志'])
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from backend.app.admin.schema.login_log import DeleteLoginLogParam, GetLoginLogDetail
|
||||
from backend.app.admin.service.login_log_service import login_log_service
|
||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||
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.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取登录日志',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_login_logs_paged(
|
||||
db: CurrentSession,
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
ip: Annotated[str | None, Query(description='IP 地址')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
|
||||
log_select = await login_log_service.get_select(username=username, status=status, ip=ip)
|
||||
page_data = await paging_data(db, log_select)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除登录日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:login:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_login_logs(obj: DeleteLoginLogParam) -> ResponseModel:
|
||||
count = await login_log_service.delete(obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/all',
|
||||
summary='清空登录日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:login:clear')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_all_login_logs() -> ResponseModel:
|
||||
await login_log_service.delete_all()
|
||||
return response_base.success()
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from backend.app.admin.schema.opera_log import DeleteOperaLogParam, GetOperaLogDetail
|
||||
from backend.app.admin.service.opera_log_service import opera_log_service
|
||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||
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.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取操作日志',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_opera_logs_paged(
|
||||
db: CurrentSession,
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
ip: Annotated[str | None, Query(description='IP 地址')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
|
||||
log_select = await opera_log_service.get_select(username=username, status=status, ip=ip)
|
||||
page_data = await paging_data(db, log_select)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除操作日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:opera:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_opera_logs(obj: DeleteOperaLogParam) -> ResponseModel:
|
||||
count = await opera_log_service.delete(obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/all',
|
||||
summary='清空操作日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:opera:clear')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_all_opera_logs() -> ResponseModel:
|
||||
await opera_log_service.delete_all()
|
||||
return response_base.success()
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.monitor.online import router as token_router
|
||||
from backend.app.admin.api.v1.monitor.redis import router as redis_router
|
||||
from backend.app.admin.api.v1.monitor.server import router as server_router
|
||||
|
||||
router = APIRouter(prefix='/monitors')
|
||||
|
||||
router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
|
||||
router.include_router(server_router, prefix='/server', tags=['服务器监控'])
|
||||
router.include_router(token_router, prefix='/sessions', tags=['会话监控'])
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
|
||||
from backend.app.admin.schema.token import GetTokenDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth])
|
||||
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}:*')
|
||||
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
|
||||
data: list[GetTokenDetail] = []
|
||||
|
||||
def append_token_detail() -> None:
|
||||
data.append(
|
||||
token_detail.model_copy(
|
||||
update={
|
||||
'username': extra_info.get('username', '未知'),
|
||||
'nickname': extra_info.get('nickname', '未知'),
|
||||
'ip': extra_info.get('ip', '未知'),
|
||||
'os': extra_info.get('os', '未知'),
|
||||
'browser': extra_info.get('browser', '未知'),
|
||||
'device': extra_info.get('device', '未知'),
|
||||
'last_login_time': extra_info.get('last_login_time', '未知'),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for key in token_keys:
|
||||
token = await redis_client.get(key)
|
||||
token_payload = jwt_decode(token)
|
||||
user_id = token_payload.id
|
||||
session_uuid = token_payload.session_uuid
|
||||
token_detail = GetTokenDetail(
|
||||
id=user_id,
|
||||
session_uuid=session_uuid,
|
||||
username='未知',
|
||||
nickname='未知',
|
||||
ip='未知',
|
||||
os='未知',
|
||||
browser='未知',
|
||||
device='未知',
|
||||
status=StatusType.enable if session_uuid in online_clients else StatusType.disable,
|
||||
last_login_time='未知',
|
||||
expire_time=token_payload.expire_time,
|
||||
)
|
||||
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
|
||||
if extra_info:
|
||||
extra_info = json.loads(extra_info)
|
||||
# 排除 swagger 登录生成的 token
|
||||
if extra_info.get('swagger') is None:
|
||||
if username is not None:
|
||||
if username == extra_info.get('username'):
|
||||
append_token_detail()
|
||||
else:
|
||||
append_token_detail()
|
||||
else:
|
||||
data.append(token_detail)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{pk}',
|
||||
summary='强制下线',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:session:delete')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_session(
|
||||
request: Request,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
session_uuid: Annotated[str, Query(description='会话 UUID')],
|
||||
) -> ResponseModel:
|
||||
superuser_verify(request)
|
||||
await revoke_token(pk, session_uuid)
|
||||
return response_base.success()
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.common.response.response_schema import ResponseModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.utils.redis_info import redis_info
|
||||
|
||||
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(),
|
||||
}
|
||||
return response_base.success(data=data)
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.common.response.response_schema import ResponseModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.utils.server_info import server_info
|
||||
|
||||
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),
|
||||
}
|
||||
return response_base.success(data=data)
|
||||
@@ -1,23 +1,11 @@
|
||||
# !/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
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.menu import router as menu_router
|
||||
from backend.app.admin.api.v1.sys.file import router as file_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
|
||||
from backend.app.admin.api.v1.sys.user import router as user_router
|
||||
|
||||
router = APIRouter(prefix='/sys')
|
||||
|
||||
router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
|
||||
router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
|
||||
router.include_router(role_router, prefix='/roles', tags=['系统角色'])
|
||||
router.include_router(user_router, prefix='/users', tags=['系统用户'])
|
||||
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则'])
|
||||
router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
|
||||
router.include_router(file_router, prefix='/files', tags=['系统文件'])
|
||||
router.include_router(plugin_router, prefix='/plugins', tags=['系统插件'])
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from backend.app.admin.schema.data_rule import (
|
||||
CreateDataRuleParam,
|
||||
DeleteDataRuleParam,
|
||||
GetDataRuleColumnDetail,
|
||||
GetDataRuleDetail,
|
||||
UpdateDataRuleParam,
|
||||
)
|
||||
from backend.app.admin.service.data_rule_service import data_rule_service
|
||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||
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.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/models', summary='获取数据规则可用模型', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
|
||||
models = await data_rule_service.get_models()
|
||||
return response_base.success(data=models)
|
||||
|
||||
|
||||
@router.get('/models/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule_model_columns(
|
||||
model: Annotated[str, Path(description='模型名称')],
|
||||
) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
|
||||
models = await data_rule_service.get_columns(model=model)
|
||||
return response_base.success(data=models)
|
||||
|
||||
|
||||
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
|
||||
async def get_all_data_rules() -> ResponseSchemaModel[list[GetDataRuleDetail]]:
|
||||
data = await data_rule_service.get_all()
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule(
|
||||
pk: Annotated[int, Path(description='数据规则 ID')],
|
||||
) -> ResponseSchemaModel[GetDataRuleDetail]:
|
||||
data = await data_rule_service.get(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有数据规则',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_data_rules_paged(
|
||||
db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None
|
||||
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
|
||||
data_rule_select = await data_rule_service.get_select(name=name)
|
||||
page_data = await paging_data(db, data_rule_select)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建数据规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:rule:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel:
|
||||
await data_rule_service.create(obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新数据规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:rule:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_data_rule(
|
||||
pk: Annotated[int, Path(description='数据规则 ID')], obj: UpdateDataRuleParam
|
||||
) -> ResponseModel:
|
||||
count = await data_rule_service.update(pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除数据规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:rule:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_data_rules(obj: DeleteDataRuleParam) -> ResponseModel:
|
||||
count = await data_rule_service.delete(obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from backend.app.admin.schema.data_scope import (
|
||||
CreateDataScopeParam,
|
||||
DeleteDataScopeParam,
|
||||
GetDataScopeDetail,
|
||||
GetDataScopeWithRelationDetail,
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.app.admin.service.data_scope_service import data_scope_service
|
||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||
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.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/all', summary='获取所有数据范围', dependencies=[DependsJwtAuth])
|
||||
async def get_all_data_scope() -> ResponseSchemaModel[list[GetDataScopeDetail]]:
|
||||
data = await data_scope_service.get_all()
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
|
||||
async def get_data_scope(
|
||||
pk: Annotated[int, Path(description='数据范围 ID')],
|
||||
) -> ResponseSchemaModel[GetDataScopeDetail]:
|
||||
data = await data_scope_service.get(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
|
||||
async def get_data_scope_rules(
|
||||
pk: Annotated[int, Path(description='数据范围 ID')],
|
||||
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
|
||||
data = await data_scope_service.get_rules(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有数据范围',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_data_scopes_paged(
|
||||
db: CurrentSession,
|
||||
name: Annotated[str | None, Query(description='范围名称')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
|
||||
data_scope_select = await data_scope_service.get_select(name=name, status=status)
|
||||
page_data = await paging_data(db, data_scope_select)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel:
|
||||
await data_scope_service.create(obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_data_scope(
|
||||
pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam
|
||||
) -> ResponseModel:
|
||||
count = await data_scope_service.update(pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}/rules',
|
||||
summary='更新数据范围规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:rule:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_data_scope_rules(
|
||||
pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam
|
||||
):
|
||||
count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_data_scopes(obj: DeleteDataScopeParam) -> ResponseModel:
|
||||
count = await data_scope_service.delete(obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
|
||||
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.rbac import DependsRBAC
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
|
||||
async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseSchemaModel[GetDeptDetail]:
|
||||
data = await dept_service.get(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
|
||||
async def get_dept_tree(
|
||||
request: Request,
|
||||
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(request=request, name=name, leader=leader, phone=phone, status=status)
|
||||
return response_base.success(data=dept)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建部门',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:dept:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_dept(obj: CreateDeptParam) -> ResponseModel:
|
||||
await dept_service.create(obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新部门',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:dept:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam) -> ResponseModel:
|
||||
count = await dept_service.update(pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{pk}',
|
||||
summary='删除部门',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:dept:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
|
||||
count = await dept_service.delete(pk=pk)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -1,13 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
|
||||
from backend.common.dataclasses import UploadUrl
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.utils.file_ops import upload_file, upload_file_verify
|
||||
|
||||
router = APIRouter()
|
||||
@@ -15,11 +12,8 @@ router = APIRouter()
|
||||
|
||||
@router.post(
|
||||
'/upload',
|
||||
summary='文件上传',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:file:upload')),
|
||||
DependsRBAC,
|
||||
],
|
||||
summary='本地文件上传',
|
||||
dependencies=[DependsJwtAuth],
|
||||
)
|
||||
async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
|
||||
upload_file_verify(file)
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, GetMenuTree, UpdateMenuParam
|
||||
from backend.app.admin.service.menu_service import menu_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.rbac import DependsRBAC
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='已适配 vben admin v5', dependencies=[DependsJwtAuth])
|
||||
async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
|
||||
menu = await menu_service.get_sidebar(request=request)
|
||||
return response_base.success(data=menu)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
|
||||
async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseSchemaModel[GetMenuDetail]:
|
||||
data = await menu_service.get(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('', summary='获取菜单树', dependencies=[DependsJwtAuth])
|
||||
async def get_menu_tree(
|
||||
title: Annotated[str | None, Query(description='菜单标题')] = None,
|
||||
status: Annotated[int | None, Query(description='状体')] = None,
|
||||
) -> ResponseSchemaModel[list[GetMenuTree]]:
|
||||
menu = await menu_service.get_tree(title=title, status=status)
|
||||
return response_base.success(data=menu)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:menu:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_menu(obj: CreateMenuParam) -> ResponseModel:
|
||||
await menu_service.create(obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:menu:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam) -> ResponseModel:
|
||||
count = await menu_service.update(pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{pk}',
|
||||
summary='删除菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:menu:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseModel:
|
||||
count = await menu_service.delete(pk=pk)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Path, UploadFile
|
||||
from fastapi import APIRouter, File, Path, UploadFile
|
||||
from fastapi.params import Query
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
@@ -10,20 +8,18 @@ from backend.app.admin.service.plugin_service import plugin_service
|
||||
from backend.common.enums import PluginType
|
||||
from backend.common.response.response_code import CustomResponse
|
||||
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.rbac import DependsRBAC
|
||||
from backend.common.security.jwt import DependsSuperUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth])
|
||||
@router.get('', summary='获取所有插件', dependencies=[DependsSuperUser])
|
||||
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
|
||||
plugins = await plugin_service.get_all()
|
||||
return response_base.success(data=plugins)
|
||||
|
||||
|
||||
@router.get('/changed', summary='是否存在插件变更', dependencies=[DependsJwtAuth])
|
||||
@router.get('/changed', summary='是否存在插件变更', dependencies=[DependsSuperUser])
|
||||
async def plugin_changed() -> ResponseSchemaModel[bool]:
|
||||
plugins = await plugin_service.changed()
|
||||
return response_base.success(data=bool(plugins))
|
||||
@@ -32,11 +28,8 @@ async def plugin_changed() -> ResponseSchemaModel[bool]:
|
||||
@router.post(
|
||||
'',
|
||||
summary='安装插件',
|
||||
description='使用插件 zip 压缩包或 git 仓库地址进行安装',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:plugin:install')),
|
||||
DependsRBAC,
|
||||
],
|
||||
description='使用插件 zip 压缩包或 git 仓库地址进行安装(仅开发环境)',
|
||||
dependencies=[DependsSuperUser],
|
||||
)
|
||||
async def install_plugin(
|
||||
type: Annotated[PluginType, Query(description='插件类型')],
|
||||
@@ -46,41 +39,32 @@ async def install_plugin(
|
||||
plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url)
|
||||
return response_base.success(
|
||||
res=CustomResponse(
|
||||
code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务'
|
||||
)
|
||||
code=200,
|
||||
msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{plugin}',
|
||||
summary='卸载插件',
|
||||
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:plugin:uninstall')),
|
||||
DependsRBAC,
|
||||
],
|
||||
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录(仅开发环境)',
|
||||
dependencies=[DependsSuperUser],
|
||||
)
|
||||
async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
|
||||
await plugin_service.uninstall(plugin=plugin)
|
||||
return response_base.success(
|
||||
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
|
||||
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务'),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{plugin}/status',
|
||||
summary='更新插件状态',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:plugin:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
@router.put('/{plugin}/status', summary='更新插件状态', dependencies=[DependsSuperUser])
|
||||
async def update_plugin_status(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
|
||||
await plugin_service.update_status(plugin=plugin)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.get('/{plugin}', summary='下载插件', dependencies=[DependsJwtAuth])
|
||||
@router.get('/{plugin}', summary='下载插件', dependencies=[DependsSuperUser])
|
||||
async def download_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
|
||||
bio = await plugin_service.build(plugin=plugin)
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from backend.app.admin.schema.menu import GetMenuTree
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleParam,
|
||||
DeleteRoleParam,
|
||||
GetRoleDetail,
|
||||
GetRoleWithRelationDetail,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.app.admin.service.role_service import role_service
|
||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||
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.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
|
||||
async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
|
||||
data = await role_service.get_all()
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
|
||||
async def get_role_menu_tree(
|
||||
pk: Annotated[int, Path(description='角色 ID')],
|
||||
) -> ResponseSchemaModel[list[GetMenuTree] | None]:
|
||||
menu = await role_service.get_menu_tree(pk=pk)
|
||||
return response_base.success(data=menu)
|
||||
|
||||
|
||||
@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
|
||||
async def get_role_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]:
|
||||
rule = await role_service.get_scopes(pk=pk)
|
||||
return response_base.success(data=rule)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
|
||||
async def get_role(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
|
||||
data = await role_service.get(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有角色',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_roles_paged(
|
||||
db: CurrentSession,
|
||||
name: Annotated[str | None, Query(description='角色名称')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
|
||||
role_select = await role_service.get_select(name=name, status=status)
|
||||
page_data = await paging_data(db, role_select)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建角色',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_role(obj: CreateRoleParam) -> ResponseModel:
|
||||
await role_service.create(obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新角色',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam) -> ResponseModel:
|
||||
count = await role_service.update(pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}/menus',
|
||||
summary='更新角色菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:menu:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_role_menus(
|
||||
pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam
|
||||
) -> ResponseModel:
|
||||
count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}/scopes',
|
||||
summary='更新角色数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:scope:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_role_scopes(
|
||||
pk: Annotated[int, Path(description='角色 ID')], scope_ids: UpdateRoleScopeParam
|
||||
) -> ResponseModel:
|
||||
count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除角色',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_roles(obj: DeleteRoleParam) -> ResponseModel:
|
||||
count = await role_service.delete(obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -1,46 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Request
|
||||
from fastapi import APIRouter, Body, Path, Query, Request
|
||||
|
||||
from backend.app.admin.schema.role import GetRoleDetail
|
||||
from backend.app.admin.schema.user import (
|
||||
AddUserParam,
|
||||
GetCurrentUserInfoWithRelationDetail,
|
||||
GetUserInfoWithRelationDetail,
|
||||
GetUserInfoDetail,
|
||||
ResetPasswordParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.app.admin.service.user_service import user_service
|
||||
from backend.common.enums import UserPermissionType
|
||||
from backend.common.pagination import DependsPagination, PageData, paging_data
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
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.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession
|
||||
from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
|
||||
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
|
||||
async def get_current_user(request: Request) -> ResponseSchemaModel[GetUserInfoDetail]:
|
||||
data = request.user.model_dump()
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取用户信息', dependencies=[DependsJwtAuth])
|
||||
async def get_userinfo(
|
||||
db: CurrentSession,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
|
||||
data = await user_service.get_userinfo(pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}/roles', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
|
||||
async def get_user_roles(pk: Annotated[int, Path(description='用户 ID')]) -> ResponseSchemaModel[list[GetRoleDetail]]:
|
||||
data = await user_service.get_roles(pk=pk)
|
||||
) -> ResponseSchemaModel[GetUserInfoDetail]:
|
||||
data = await user_service.get_userinfo(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@@ -52,62 +41,65 @@ async def get_user_roles(pk: Annotated[int, Path(description='用户 ID')]) -> R
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_users_paged(
|
||||
async def get_users_paginated(
|
||||
db: CurrentSession,
|
||||
dept: Annotated[int | None, Query(description='部门 ID')] = None,
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
phone: Annotated[str | None, Query(description='手机号')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
|
||||
user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status)
|
||||
page_data = await paging_data(db, user_select)
|
||||
) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]:
|
||||
page_data = await user_service.get_list(db=db, username=username, phone=phone, status=status)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post('', summary='创建用户', dependencies=[DependsRBAC])
|
||||
async def create_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
|
||||
await user_service.create(request=request, obj=obj)
|
||||
data = await user_service.get_userinfo(username=obj.username)
|
||||
@router.post('', summary='创建用户', dependencies=[DependsSuperUser])
|
||||
async def create_user(db: CurrentSessionTransaction, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoDetail]:
|
||||
await user_service.create(db=db, obj=obj)
|
||||
data = await user_service.get_userinfo(db=db, username=obj.username)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsRBAC])
|
||||
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsSuperUser])
|
||||
async def update_user(
|
||||
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: UpdateUserParam
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
obj: UpdateUserParam,
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update(request=request, pk=pk, obj=obj)
|
||||
count = await user_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsRBAC])
|
||||
@router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsSuperUser])
|
||||
async def update_user_permission(
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
type: Annotated[UserPermissionType, Query(description='权限类型')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_permission(request=request, pk=pk, type=type)
|
||||
count = await user_service.update_permission(db=db, request=request, pk=pk, type=type)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/me/password', summary='更新当前用户密码', dependencies=[DependsJwtAuth])
|
||||
async def update_user_password(request: Request, obj: ResetPasswordParam) -> ResponseModel:
|
||||
count = await user_service.update_password(request=request, obj=obj)
|
||||
async def update_user_password(
|
||||
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
|
||||
) -> ResponseModel:
|
||||
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()
|
||||
|
||||
|
||||
@router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsRBAC])
|
||||
@router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsSuperUser])
|
||||
async def reset_user_password(
|
||||
request: Request,
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
password: Annotated[str, Body(embed=True, description='新密码')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.reset_password(request=request, pk=pk, password=password)
|
||||
count = await user_service.reset_password(db=db, pk=pk, password=password)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -115,9 +107,11 @@ async def reset_user_password(
|
||||
|
||||
@router.put('/me/nickname', summary='更新当前用户昵称', dependencies=[DependsJwtAuth])
|
||||
async def update_user_nickname(
|
||||
request: Request, nickname: Annotated[str, Body(embed=True, description='用户昵称')]
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_nickname(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()
|
||||
@@ -125,9 +119,11 @@ async def update_user_nickname(
|
||||
|
||||
@router.put('/me/avatar', summary='更新当前用户头像', dependencies=[DependsJwtAuth])
|
||||
async def update_user_avatar(
|
||||
request: Request, avatar: Annotated[str, Body(embed=True, description='用户头像地址')]
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_avatar(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()
|
||||
@@ -135,11 +131,11 @@ async def update_user_avatar(
|
||||
|
||||
@router.put('/me/email', summary='更新当前用户邮箱', dependencies=[DependsJwtAuth])
|
||||
async def update_user_email(
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
|
||||
email: Annotated[str, Body(embed=True, description='用户邮箱')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_email(request=request, captcha=captcha, email=email)
|
||||
count = await user_service.update_email(db=db, user_id=request.user.id, email=email)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -148,13 +144,10 @@ async def update_user_email(
|
||||
@router.delete(
|
||||
path='/{pk}',
|
||||
summary='删除用户',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:user:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
dependencies=[DependsSuperUser],
|
||||
)
|
||||
async def delete_user(pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
|
||||
count = await user_service.delete(pk=pk)
|
||||
async def delete_user(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
|
||||
count = await user_service.delete(db=db, pk=pk)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import DataRule
|
||||
from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam
|
||||
|
||||
|
||||
class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
"""数据规则数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
|
||||
"""
|
||||
获取规则详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 规则 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
|
||||
async def get_list(self, name: str | None) -> Select:
|
||||
"""
|
||||
获取规则列表
|
||||
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
|
||||
return await self.select_order('id', load_strategies={'scopes': 'noload'}, **filters)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
|
||||
"""
|
||||
通过名称获取规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
|
||||
"""
|
||||
获取所有规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
|
||||
"""
|
||||
创建规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建规则参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
|
||||
"""
|
||||
更新规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 规则 ID
|
||||
:param obj: 更新规则参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
|
||||
|
||||
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import DataRule, DataScope
|
||||
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam
|
||||
|
||||
|
||||
class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
"""数据范围数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, pk: int) -> DataScope | None:
|
||||
"""
|
||||
获取数据范围详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None:
|
||||
"""
|
||||
通过名称获取数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 范围名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
|
||||
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope:
|
||||
"""
|
||||
获取数据范围关联数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk, load_strategies=['rules'])
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
|
||||
"""
|
||||
获取所有数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
|
||||
async def get_list(self, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取数据范围列表
|
||||
|
||||
:param name: 范围名称
|
||||
:param status: 范围状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
|
||||
"""
|
||||
创建数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建数据范围参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
|
||||
"""
|
||||
更新数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:param obj: 更新数据范围参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, pk, obj)
|
||||
|
||||
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
"""
|
||||
更新数据范围规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
: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)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
|
||||
|
||||
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.common.security.permission import filter_data_permission
|
||||
|
||||
|
||||
class CRUDDept(CRUDPlus[Dept]):
|
||||
"""部门数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, dept_id: int) -> Dept | None:
|
||||
"""
|
||||
获取部门详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, id=dept_id, del_flag=0)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
|
||||
"""
|
||||
通过名称获取部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 部门名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name, del_flag=0)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
request: Request,
|
||||
db: AsyncSession,
|
||||
name: str | None,
|
||||
leader: str | None,
|
||||
phone: str | None,
|
||||
status: int | None,
|
||||
) -> Sequence[Dept]:
|
||||
"""
|
||||
获取所有部门
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param name: 部门名称
|
||||
:param leader: 负责人
|
||||
:param phone: 联系电话
|
||||
:param status: 部门状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'del_flag': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
if leader is not None:
|
||||
filters['leader__like'] = f'%{leader}%'
|
||||
if phone is not None:
|
||||
filters['phone__startswith'] = phone
|
||||
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)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
|
||||
"""
|
||||
创建部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建部门参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, dept_id: int, obj: UpdateDeptParam) -> int:
|
||||
"""
|
||||
更新部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:param obj: 更新部门参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, dept_id, obj)
|
||||
|
||||
async def delete(self, db: AsyncSession, dept_id: int) -> int:
|
||||
"""
|
||||
删除部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
获取部门及关联数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, dept_id, load_strategies=['users'])
|
||||
|
||||
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
|
||||
"""
|
||||
获取子部门列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, parent_id=dept_id, del_flag=0)
|
||||
|
||||
|
||||
dept_dao: CRUDDept = CRUDDept(Dept)
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import LoginLog
|
||||
from backend.app.admin.schema.login_log import CreateLoginLogParam
|
||||
|
||||
|
||||
class CRUDLoginLog(CRUDPlus[LoginLog]):
|
||||
"""登录日志数据库操作类"""
|
||||
|
||||
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
|
||||
"""
|
||||
获取登录日志列表
|
||||
|
||||
:param username: 用户名
|
||||
:param status: 登录状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if username is not None:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
if ip is not None:
|
||||
filters['ip__like'] = f'%{ip}%'
|
||||
|
||||
return await self.select_order('created_time', 'desc', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
|
||||
"""
|
||||
创建登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建登录日志参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj, commit=True)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 登录日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
|
||||
@staticmethod
|
||||
async def delete_all(db: AsyncSession) -> None:
|
||||
"""
|
||||
删除所有日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
await db.execute(sa_delete(LoginLog))
|
||||
|
||||
|
||||
login_log_dao: CRUDLoginLog = CRUDLoginLog(LoginLog)
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import Menu
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||
|
||||
|
||||
class CRUDMenu(CRUDPlus[Menu]):
|
||||
"""菜单数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, menu_id: int) -> Menu | None:
|
||||
"""
|
||||
获取菜单详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, menu_id)
|
||||
|
||||
async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
|
||||
"""
|
||||
通过标题获取菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param title: 菜单标题
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, title=title, type__ne=2)
|
||||
|
||||
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
|
||||
"""
|
||||
获取菜单列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param title: 菜单标题
|
||||
:param status: 菜单状态
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if title is not None:
|
||||
filters['title__like'] = f'%{title}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_models_order(db, 'sort', **filters)
|
||||
|
||||
async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
|
||||
"""
|
||||
获取用户的菜单侧边栏
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
filters = {'type__in': [0, 1, 3, 4]}
|
||||
|
||||
if menu_ids:
|
||||
filters['id__in'] = menu_ids
|
||||
|
||||
return await self.select_models_order(db, 'sort', 'asc', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
|
||||
"""
|
||||
创建菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建菜单参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, menu_id: int, obj: UpdateMenuParam) -> int:
|
||||
"""
|
||||
更新菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:param obj: 更新菜单参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, menu_id, obj)
|
||||
|
||||
async def delete(self, db: AsyncSession, menu_id: int) -> int:
|
||||
"""
|
||||
删除菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model(db, menu_id)
|
||||
|
||||
async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]:
|
||||
"""
|
||||
获取子菜单列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
menu = await self.select_model(db, menu_id, load_strategies=['children'])
|
||||
return menu.children
|
||||
|
||||
|
||||
menu_dao: CRUDMenu = CRUDMenu(Menu)
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import OperaLog
|
||||
from backend.app.admin.schema.opera_log import CreateOperaLogParam
|
||||
|
||||
|
||||
class CRUDOperaLogDao(CRUDPlus[OperaLog]):
|
||||
"""操作日志数据库操作类"""
|
||||
|
||||
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
|
||||
"""
|
||||
获取操作日志列表
|
||||
|
||||
:param username: 用户名
|
||||
:param status: 操作状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if username is not None:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if status is not None:
|
||||
filters['status__eq'] = status
|
||||
if ip is not None:
|
||||
filters['ip__like'] = f'%{ip}%'
|
||||
|
||||
return await self.select_order('created_time', 'desc', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
|
||||
"""
|
||||
创建操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 操作日志创建参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def bulk_create(self, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
|
||||
"""
|
||||
批量创建操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param objs: 操作日志创建参数列表
|
||||
:return:
|
||||
"""
|
||||
await self.create_models(db, objs)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 操作日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
|
||||
@staticmethod
|
||||
async def delete_all(db: AsyncSession) -> None:
|
||||
"""
|
||||
删除所有日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
await db.execute(sa_delete(OperaLog))
|
||||
|
||||
|
||||
opera_log_dao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog)
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import DataScope, Menu, Role
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleParam,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
|
||||
|
||||
class CRUDRole(CRUDPlus[Role]):
|
||||
"""角色数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, role_id: int) -> Role | None:
|
||||
"""
|
||||
获取角色详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, role_id)
|
||||
|
||||
async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None:
|
||||
"""
|
||||
获取角色及关联数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, role_id, load_strategies=['menus', 'scopes'])
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
|
||||
"""
|
||||
获取所有角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db)
|
||||
|
||||
async def get_list(self, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取角色列表
|
||||
|
||||
:param name: 角色名称
|
||||
:param status: 角色状态
|
||||
:return:
|
||||
"""
|
||||
|
||||
filters = {}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order(
|
||||
'id',
|
||||
load_strategies={
|
||||
'users': 'noload',
|
||||
'menus': 'noload',
|
||||
'scopes': 'noload',
|
||||
},
|
||||
**filters,
|
||||
)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
|
||||
"""
|
||||
通过名称获取角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 角色名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
|
||||
"""
|
||||
创建角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建角色参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, role_id: int, obj: UpdateRoleParam) -> int:
|
||||
"""
|
||||
更新角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:param obj: 更新角色参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, role_id, obj)
|
||||
|
||||
async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
"""
|
||||
更新角色菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
: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)
|
||||
|
||||
async def update_scopes(self, db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||
"""
|
||||
更新角色数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
: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)
|
||||
|
||||
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
|
||||
"""
|
||||
批量删除角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_ids)
|
||||
|
||||
|
||||
role_dao: CRUDRole = CRUDRole(Role)
|
||||
@@ -1,20 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from collections.abc import Sequence
|
||||
|
||||
import bcrypt
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import 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 backend.app.admin.model import Dept, Role, User
|
||||
from backend.app.admin.schema.user import (
|
||||
AddOAuth2UserParam,
|
||||
AddUserParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.common.security.jwt import get_hash_password
|
||||
from backend.app.admin.model import User
|
||||
from backend.app.admin.schema.user import AddUserParam, UpdateUserParam
|
||||
from backend.app.admin.utils.password_security import get_hash_password
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -29,7 +23,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, user_id)
|
||||
return await self.select_model(db, user_id, deleted=0)
|
||||
|
||||
async def get_by_username(self, db: AsyncSession, username: str) -> User | None:
|
||||
"""
|
||||
@@ -39,7 +33,17 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, username=username)
|
||||
return await self.select_model_by_column(db, username=username, deleted=0)
|
||||
|
||||
async def get_all_by_usernames(self, db: AsyncSession, usernames: list[str]) -> Sequence[User]:
|
||||
"""
|
||||
通过用户名列表批量获取用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param usernames: 用户名列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, username__in=usernames, deleted=0)
|
||||
|
||||
async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
|
||||
"""
|
||||
@@ -49,17 +53,37 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, nickname=nickname)
|
||||
return await self.select_model_by_column(db, nickname=nickname, deleted=0)
|
||||
|
||||
async def 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, deleted=0)
|
||||
|
||||
async def get_select(self, username: str | None, phone: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取用户列表查询表达式
|
||||
|
||||
:param username: 用户名
|
||||
:param phone: 电话号码
|
||||
:param status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
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', **filters)
|
||||
|
||||
async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
|
||||
"""
|
||||
@@ -71,52 +95,47 @@ class CRUDUser(CRUDPlus[User]):
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
obj.password = get_hash_password(obj.password, salt)
|
||||
dict_obj = obj.model_dump(exclude={'roles'})
|
||||
|
||||
dict_obj = obj.model_dump()
|
||||
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()
|
||||
|
||||
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
|
||||
"""
|
||||
通过 OAuth2 添加用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 注册用户参数
|
||||
:return:
|
||||
"""
|
||||
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)
|
||||
|
||||
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
|
||||
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_by_column(db, obj, id=user_id, deleted=0)
|
||||
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, deleted=0)
|
||||
|
||||
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_by_column(
|
||||
db, {'last_password_changed_time': timezone.now()}, id=user_id, deleted=0
|
||||
)
|
||||
|
||||
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
更新用户昵称
|
||||
@@ -126,7 +145,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'nickname': nickname})
|
||||
return await self.update_model_by_column(db, {'nickname': nickname}, id=user_id, deleted=0)
|
||||
|
||||
async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
|
||||
"""
|
||||
@@ -137,7 +156,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param avatar: 头像地址
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'avatar': avatar})
|
||||
return await self.update_model_by_column(db, {'avatar': avatar}, id=user_id, deleted=0)
|
||||
|
||||
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
|
||||
"""
|
||||
@@ -148,27 +167,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param email: 邮箱
|
||||
:return:
|
||||
"""
|
||||
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)
|
||||
return await self.update_model_by_column(db, {'email': email}, id=user_id, deleted=0)
|
||||
|
||||
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
|
||||
"""
|
||||
@@ -181,40 +180,9 @@ 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})
|
||||
return await self.update_model_by_column(db, {'password': new_pwd, 'salt': salt}, flush=True, id=pk, deleted=0)
|
||||
|
||||
async def get_list(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,
|
||||
)
|
||||
|
||||
async def set_super(self, db: AsyncSession, user_id: int, is_super: bool) -> int:
|
||||
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
|
||||
"""
|
||||
设置用户超级管理员状态
|
||||
|
||||
@@ -223,9 +191,9 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param is_super: 是否超级管理员
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_superuser': is_super})
|
||||
return await self.update_model_by_column(db, {'is_superuser': is_super}, id=user_id, deleted=0)
|
||||
|
||||
async def set_staff(self, db: AsyncSession, user_id: int, is_staff: bool) -> int:
|
||||
async def set_staff(self, db: AsyncSession, user_id: int, *, is_staff: bool) -> int:
|
||||
"""
|
||||
设置用户后台登录状态
|
||||
|
||||
@@ -234,7 +202,7 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param is_staff: 是否可登录后台
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_staff': is_staff})
|
||||
return await self.update_model_by_column(db, {'is_staff': is_staff}, id=user_id, deleted=0)
|
||||
|
||||
async def set_status(self, db: AsyncSession, user_id: int, status: int) -> int:
|
||||
"""
|
||||
@@ -245,9 +213,9 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'status': status})
|
||||
return await self.update_model_by_column(db, {'status': status}, id=user_id, deleted=0)
|
||||
|
||||
async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int:
|
||||
async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login: bool) -> int:
|
||||
"""
|
||||
设置用户多端登录状态
|
||||
|
||||
@@ -256,31 +224,25 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param multi_login: 是否允许多端登录
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
|
||||
return await self.update_model_by_column(db, {'is_multi_login': multi_login}, id=user_id, deleted=0)
|
||||
|
||||
async def get_with_relation(
|
||||
self, db: AsyncSession, *, user_id: int | None = None, username: str | None = None
|
||||
) -> User | None:
|
||||
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
获取用户关联信息
|
||||
删除用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if user_id:
|
||||
filters['id'] = user_id
|
||||
if username:
|
||||
filters['username'] = username
|
||||
|
||||
return await self.select_model_by_column(
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))],
|
||||
load_strategies=['dept'],
|
||||
**filters,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=user_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,10 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from backend.app.admin.model.data_rule import DataRule
|
||||
from backend.app.admin.model.data_scope import DataScope
|
||||
from backend.app.admin.model.dept import Dept
|
||||
from backend.app.admin.model.login_log import LoginLog
|
||||
from backend.app.admin.model.menu import Menu
|
||||
from backend.app.admin.model.opera_log import OperaLog
|
||||
from backend.app.admin.model.role import Role
|
||||
from backend.app.admin.model.user import User
|
||||
from backend.app.admin.model.user import User as User
|
||||
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.admin.model.m2m import sys_data_scope_rule
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import DataScope
|
||||
|
||||
|
||||
class DataRule(Base):
|
||||
"""数据规则表"""
|
||||
|
||||
__tablename__ = 'sys_data_rule'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称')
|
||||
model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名')
|
||||
column: Mapped[str] = mapped_column(String(20), 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(String(255), comment='规则值')
|
||||
|
||||
# 数据范围规则多对多
|
||||
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.admin.model.m2m import sys_data_scope_rule, sys_role_data_scope
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import DataRule, Role
|
||||
|
||||
|
||||
class DataScope(Base):
|
||||
"""数据范围表"""
|
||||
|
||||
__tablename__ = 'sys_data_scope'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(String(50), 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,41 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import INTEGER
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import User
|
||||
|
||||
|
||||
class Dept(Base):
|
||||
"""部门表"""
|
||||
|
||||
__tablename__ = 'sys_dept'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(String(50), comment='部门名称')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
leader: Mapped[str | None] = mapped_column(String(20), default=None, comment='负责人')
|
||||
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机')
|
||||
email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
|
||||
del_flag: Mapped[bool] = mapped_column(
|
||||
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='删除标志(0删除 1存在)'
|
||||
)
|
||||
|
||||
# 父级部门一对多
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
|
||||
)
|
||||
parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id])
|
||||
children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent')
|
||||
|
||||
# 部门用户一对多
|
||||
users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.dialects.postgresql import TEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class LoginLog(DataClassBase):
|
||||
"""登录日志表"""
|
||||
|
||||
__tablename__ = 'sys_login_log'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
user_uuid: Mapped[str] = mapped_column(String(50), comment='用户UUID')
|
||||
username: Mapped[str] = mapped_column(String(20), comment='用户名')
|
||||
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
|
||||
ip: Mapped[str] = mapped_column(String(50), comment='登录IP地址')
|
||||
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
|
||||
user_agent: Mapped[str] = mapped_column(String(255), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
|
||||
msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
|
||||
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import BigInteger, Column, ForeignKey, Table
|
||||
|
||||
from backend.common.model import MappedBase
|
||||
|
||||
sys_user_role = Table(
|
||||
'sys_user_role',
|
||||
MappedBase.metadata,
|
||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
Column('user_id', BigInteger, ForeignKey('sys_user.id', ondelete='CASCADE'), primary_key=True, comment='用户ID'),
|
||||
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
|
||||
)
|
||||
|
||||
sys_role_menu = Table(
|
||||
'sys_role_menu',
|
||||
MappedBase.metadata,
|
||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
|
||||
Column('menu_id', BigInteger, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'),
|
||||
)
|
||||
|
||||
sys_role_data_scope = Table(
|
||||
'sys_role_data_scope',
|
||||
MappedBase.metadata,
|
||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
|
||||
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'),
|
||||
Column(
|
||||
'data_scope_id',
|
||||
BigInteger,
|
||||
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
comment='数据范围 ID',
|
||||
),
|
||||
)
|
||||
|
||||
sys_data_scope_rule = Table(
|
||||
'sys_data_scope_rule',
|
||||
MappedBase.metadata,
|
||||
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
Column(
|
||||
'data_scope_id',
|
||||
BigInteger,
|
||||
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
comment='数据范围 ID',
|
||||
),
|
||||
Column(
|
||||
'data_rule_id',
|
||||
BigInteger,
|
||||
ForeignKey('sys_data_rule.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
comment='数据规则 ID',
|
||||
),
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, String
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.dialects.postgresql import TEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.admin.model.m2m import sys_role_menu
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import Role
|
||||
|
||||
|
||||
class Menu(Base):
|
||||
"""菜单表"""
|
||||
|
||||
__tablename__ = 'sys_menu'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
title: Mapped[str] = mapped_column(String(50), comment='菜单标题')
|
||||
name: Mapped[str] = mapped_column(String(50), comment='菜单名称')
|
||||
path: Mapped[str | None] = mapped_column(String(200), comment='路由地址')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标')
|
||||
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
||||
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径')
|
||||
perms: Mapped[str | None] = mapped_column(String(100), 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(
|
||||
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='外链地址'
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注'
|
||||
)
|
||||
|
||||
# 父级菜单一对多
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
|
||||
)
|
||||
parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id])
|
||||
children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent')
|
||||
|
||||
# 菜单角色多对多
|
||||
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.dialects.mysql import JSON, LONGTEXT
|
||||
from sqlalchemy.dialects.postgresql import TEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class OperaLog(DataClassBase):
|
||||
"""操作日志表"""
|
||||
|
||||
__tablename__ = 'sys_opera_log'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
trace_id: Mapped[str] = mapped_column(String(32), comment='请求跟踪 ID')
|
||||
username: Mapped[str | None] = mapped_column(String(20), comment='用户名')
|
||||
method: Mapped[str] = mapped_column(String(20), comment='请求类型')
|
||||
title: Mapped[str] = mapped_column(String(255), comment='操作模块')
|
||||
path: Mapped[str] = mapped_column(String(500), comment='请求路径')
|
||||
ip: Mapped[str] = mapped_column(String(50), comment='IP地址')
|
||||
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
|
||||
user_agent: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
|
||||
args: Mapped[str | None] = mapped_column(JSON(), comment='请求参数')
|
||||
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
|
||||
code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码')
|
||||
msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
|
||||
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms)')
|
||||
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.admin.model import DataScope, Menu, User
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
|
||||
__tablename__ = 'sys_role'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
|
||||
is_filter_scopes: Mapped[bool] = mapped_column(
|
||||
Boolean().with_variant(INTEGER, 'postgresql'), default=True, comment='过滤数据权限(0否 1是)'
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注'
|
||||
)
|
||||
|
||||
# 角色用户多对多
|
||||
users: Mapped[list[User]] = relationship(init=False, secondary=sys_user_role, back_populates='roles')
|
||||
|
||||
# 角色菜单多对多
|
||||
menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles')
|
||||
|
||||
# 角色数据范围多对多
|
||||
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
|
||||
@@ -1,57 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import VARBINARY, Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import sqlalchemy as sa
|
||||
|
||||
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):
|
||||
"""用户表"""
|
||||
|
||||
__tablename__ = 'sys_user'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('username', 'deleted', name='uk_sys_user_username_deleted'),
|
||||
sa.UniqueConstraint('email', 'deleted', name='uk_sys_user_email_deleted'),
|
||||
{'comment': '用户表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True)
|
||||
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名')
|
||||
nickname: Mapped[str] = mapped_column(String(20), comment='昵称')
|
||||
password: Mapped[str | None] = mapped_column(String(255), comment='密码')
|
||||
salt: Mapped[bytes | None] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐')
|
||||
email: Mapped[str | None] = mapped_column(String(50), default=None, unique=True, index=True, comment='邮箱')
|
||||
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
|
||||
avatar: Mapped[str | None] = mapped_column(String(255), default=None, 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), 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(256), comment='加密盐')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, index=True, comment='邮箱')
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
|
||||
avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
|
||||
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
|
||||
is_superuser: Mapped[bool] = mapped_column(
|
||||
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='超级权限(0否 1是)'
|
||||
)
|
||||
is_staff: Mapped[bool] = mapped_column(
|
||||
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)'
|
||||
)
|
||||
is_multi_login: Mapped[bool] = mapped_column(
|
||||
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)'
|
||||
)
|
||||
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='上次登录时间'
|
||||
)
|
||||
|
||||
# 部门用户一对多
|
||||
dept_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID'
|
||||
last_password_changed_time: Mapped[datetime | None] = mapped_column(
|
||||
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
|
||||
)
|
||||
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
|
||||
|
||||
# 用户角色多对多
|
||||
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
|
||||
|
||||
@@ -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='创建时间',
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from pydantic import Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
@@ -8,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='图片内容')
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class DataRuleSchemaBase(SchemaBase):
|
||||
"""数据规则基础模型"""
|
||||
|
||||
name: str = Field(description='规则名称')
|
||||
model: str = Field(description='模型名称')
|
||||
column: str = Field(description='字段名称')
|
||||
operator: RoleDataRuleOperatorType = Field(description='操作符(AND/OR)')
|
||||
expression: RoleDataRuleExpressionType = Field(description='表达式类型')
|
||||
value: str = Field(description='规则值')
|
||||
|
||||
|
||||
class CreateDataRuleParam(DataRuleSchemaBase):
|
||||
"""创建数据规则参数"""
|
||||
|
||||
|
||||
class UpdateDataRuleParam(DataRuleSchemaBase):
|
||||
"""更新数据规则参数"""
|
||||
|
||||
|
||||
class DeleteDataRuleParam(SchemaBase):
|
||||
"""删除数据规则参数"""
|
||||
|
||||
pks: list[int] = Field(description='规则 ID 列表')
|
||||
|
||||
|
||||
class GetDataRuleDetail(DataRuleSchemaBase):
|
||||
"""数据规则详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='规则 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetDataRuleColumnDetail(SchemaBase):
|
||||
"""数据规则可用模型字段详情"""
|
||||
|
||||
key: str = Field(description='字段名')
|
||||
comment: str = Field(description='字段评论')
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.app.admin.schema.data_rule import GetDataRuleDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class DataScopeBase(SchemaBase):
|
||||
"""数据范围基础模型"""
|
||||
|
||||
name: str = Field(description='名称')
|
||||
status: StatusType = Field(description='状态')
|
||||
|
||||
|
||||
class CreateDataScopeParam(DataScopeBase):
|
||||
"""创建数据范围参数"""
|
||||
|
||||
|
||||
class UpdateDataScopeParam(DataScopeBase):
|
||||
"""更新数据范围参数"""
|
||||
|
||||
|
||||
class UpdateDataScopeRuleParam(SchemaBase):
|
||||
"""更新数据范围规则参数"""
|
||||
|
||||
rules: list[int] = Field(description='数据规则 ID 列表')
|
||||
|
||||
|
||||
class DeleteDataScopeParam(SchemaBase):
|
||||
"""删除数据范围参数"""
|
||||
|
||||
pks: list[int] = Field(description='数据范围 ID 列表')
|
||||
|
||||
|
||||
class GetDataScopeDetail(DataScopeBase):
|
||||
"""数据范围详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='数据范围 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
|
||||
"""数据范围关联详情"""
|
||||
|
||||
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
|
||||
|
||||
|
||||
class DeptSchemaBase(SchemaBase):
|
||||
"""部门基础模型"""
|
||||
|
||||
name: str = Field(description='部门名称')
|
||||
parent_id: int | None = Field(None, description='部门父级 ID')
|
||||
sort: int = Field(0, ge=0, description='排序')
|
||||
leader: str | None = Field(None, description='负责人')
|
||||
phone: CustomPhoneNumber | None = Field(None, description='联系电话')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
status: StatusType = Field(description='状态')
|
||||
|
||||
|
||||
class CreateDeptParam(DeptSchemaBase):
|
||||
"""创建部门参数"""
|
||||
|
||||
|
||||
class UpdateDeptParam(DeptSchemaBase):
|
||||
"""更新部门参数"""
|
||||
|
||||
|
||||
class GetDeptDetail(DeptSchemaBase):
|
||||
"""部门详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='部门 ID')
|
||||
del_flag: bool = Field(description='是否删除')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetDeptTree(GetDeptDetail):
|
||||
"""获取部门树"""
|
||||
|
||||
children: list['GetDeptTree'] | None = Field(None, description='子菜单')
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class LoginLogSchemaBase(SchemaBase):
|
||||
"""登录日志基础模型"""
|
||||
|
||||
user_uuid: str = Field(description='用户 UUID')
|
||||
username: str = Field(description='用户名')
|
||||
status: int = Field(description='登录状态')
|
||||
ip: str = Field(description='IP 地址')
|
||||
country: str | None = Field(None, description='国家')
|
||||
region: str | None = Field(None, description='地区')
|
||||
city: str | None = Field(None, description='城市')
|
||||
user_agent: str = Field(description='用户代理')
|
||||
browser: str | None = Field(None, description='浏览器')
|
||||
os: str | None = Field(None, description='操作系统')
|
||||
device: str | None = Field(None, description='设备')
|
||||
msg: str = Field(description='消息')
|
||||
login_time: datetime = Field(description='登录时间')
|
||||
|
||||
|
||||
class CreateLoginLogParam(LoginLogSchemaBase):
|
||||
"""创建登录日志参数"""
|
||||
|
||||
|
||||
class UpdateLoginLogParam(LoginLogSchemaBase):
|
||||
"""更新登录日志参数"""
|
||||
|
||||
|
||||
class DeleteLoginLogParam(SchemaBase):
|
||||
"""删除登录日志参数"""
|
||||
|
||||
pks: list[int] = Field(description='登录日志 ID 列表')
|
||||
|
||||
|
||||
class GetLoginLogDetail(LoginLogSchemaBase):
|
||||
"""登录日志详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='日志 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import MenuType, StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class MenuSchemaBase(SchemaBase):
|
||||
"""菜单基础模型"""
|
||||
|
||||
title: str = Field(description='菜单标题')
|
||||
name: str = Field(description='菜单名称')
|
||||
path: str | None = Field(None, description='路由地址')
|
||||
parent_id: int | None = Field(None, description='菜单父级 ID')
|
||||
sort: int = Field(0, ge=0, description='排序')
|
||||
icon: str | None = Field(None, description='图标')
|
||||
type: MenuType = Field(description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
||||
component: str | None = Field(None, description='组件路径')
|
||||
perms: str | None = Field(None, description='权限标识')
|
||||
status: StatusType = Field(description='状态')
|
||||
display: StatusType = Field(description='是否显示')
|
||||
cache: StatusType = Field(description='是否缓存')
|
||||
link: str | None = Field(None, description='外链地址')
|
||||
remark: str | None = Field(None, description='备注')
|
||||
|
||||
|
||||
class CreateMenuParam(MenuSchemaBase):
|
||||
"""创建菜单参数"""
|
||||
|
||||
|
||||
class UpdateMenuParam(MenuSchemaBase):
|
||||
"""更新菜单参数"""
|
||||
|
||||
|
||||
class GetMenuDetail(MenuSchemaBase):
|
||||
"""菜单详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='菜单 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetMenuTree(GetMenuDetail):
|
||||
"""获取菜单树"""
|
||||
|
||||
children: list['GetMenuTree'] | None = Field(None, description='子菜单')
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class OperaLogSchemaBase(SchemaBase):
|
||||
"""操作日志基础模型"""
|
||||
|
||||
trace_id: str = Field(description='追踪 ID')
|
||||
username: str | None = Field(None, description='用户名')
|
||||
method: str = Field(description='请求方法')
|
||||
title: str = Field(description='操作标题')
|
||||
path: str = Field(description='请求路径')
|
||||
ip: str = Field(description='IP 地址')
|
||||
country: str | None = Field(None, description='国家')
|
||||
region: str | None = Field(None, description='地区')
|
||||
city: str | None = Field(None, description='城市')
|
||||
user_agent: str = Field(description='用户代理')
|
||||
os: str | None = Field(None, description='操作系统')
|
||||
browser: str | None = Field(None, description='浏览器')
|
||||
device: str | None = Field(None, description='设备')
|
||||
args: dict[str, Any] | None = Field(None, description='请求参数')
|
||||
status: StatusType = Field(description='状态')
|
||||
code: str = Field(description='状态码')
|
||||
msg: str | None = Field(None, description='消息')
|
||||
cost_time: float = Field(description='耗时')
|
||||
opera_time: datetime = Field(description='操作时间')
|
||||
|
||||
|
||||
class CreateOperaLogParam(OperaLogSchemaBase):
|
||||
"""创建操作日志参数"""
|
||||
|
||||
|
||||
class UpdateOperaLogParam(OperaLogSchemaBase):
|
||||
"""更新操作日志参数"""
|
||||
|
||||
|
||||
class DeleteOperaLogParam(SchemaBase):
|
||||
"""删除操作日志参数"""
|
||||
|
||||
pks: list[int] = Field(description='操作日志 ID 列表')
|
||||
|
||||
|
||||
class GetOperaLogDetail(OperaLogSchemaBase):
|
||||
"""操作日志详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='日志 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.app.admin.schema.data_scope import GetDataScopeDetail
|
||||
from backend.app.admin.schema.menu import GetMenuDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class RoleSchemaBase(SchemaBase):
|
||||
"""角色基础模型"""
|
||||
|
||||
name: str = Field(description='角色名称')
|
||||
status: StatusType = Field(description='状态')
|
||||
is_filter_scopes: bool = Field(True, description='过滤数据权限')
|
||||
remark: str | None = Field(None, description='备注')
|
||||
|
||||
|
||||
class CreateRoleParam(RoleSchemaBase):
|
||||
"""创建角色参数"""
|
||||
|
||||
|
||||
class UpdateRoleParam(RoleSchemaBase):
|
||||
"""更新角色参数"""
|
||||
|
||||
|
||||
class DeleteRoleParam(SchemaBase):
|
||||
"""删除角色参数"""
|
||||
|
||||
pks: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class UpdateRoleMenuParam(SchemaBase):
|
||||
"""更新角色菜单参数"""
|
||||
|
||||
menus: list[int] = Field(description='菜单 ID 列表')
|
||||
|
||||
|
||||
class UpdateRoleScopeParam(SchemaBase):
|
||||
"""更新角色数据范围参数"""
|
||||
|
||||
scopes: list[int] = Field(description='数据范围 ID 列表')
|
||||
|
||||
|
||||
class GetRoleDetail(RoleSchemaBase):
|
||||
"""角色详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='角色 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetRoleWithRelationDetail(GetRoleDetail):
|
||||
"""角色关联详情"""
|
||||
|
||||
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
|
||||
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
@@ -32,6 +30,7 @@ class GetNewToken(AccessTokenBase):
|
||||
class GetLoginToken(AccessTokenBase):
|
||||
"""获取登录令牌"""
|
||||
|
||||
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
|
||||
user: GetUserInfoDetail = Field(description='用户信息')
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import ConfigDict, Field, HttpUrl, model_validator
|
||||
from typing_extensions import Self
|
||||
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer
|
||||
|
||||
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):
|
||||
@@ -22,8 +17,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):
|
||||
@@ -32,17 +27,6 @@ class AddUserParam(AuthSchemaBase):
|
||||
nickname: str | None = Field(None, description='昵称')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
phone: CustomPhoneNumber | None = Field(None, description='手机号码')
|
||||
dept_id: int = Field(description='部门 ID')
|
||||
roles: list[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='头像地址')
|
||||
|
||||
|
||||
class ResetPasswordParam(SchemaBase):
|
||||
@@ -56,10 +40,9 @@ class ResetPasswordParam(SchemaBase):
|
||||
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='手机号')
|
||||
|
||||
@@ -67,15 +50,12 @@ class UserInfoSchemaBase(SchemaBase):
|
||||
class UpdateUserParam(UserInfoSchemaBase):
|
||||
"""更新用户参数"""
|
||||
|
||||
roles: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class GetUserInfoDetail(UserInfoSchemaBase):
|
||||
"""用户信息详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dept_id: int | None = Field(None, description='部门 ID')
|
||||
id: int = Field(description='用户 ID')
|
||||
uuid: str = Field(description='用户 UUID')
|
||||
status: StatusType = Field(description='状态')
|
||||
@@ -84,33 +64,3 @@ class GetUserInfoDetail(UserInfoSchemaBase):
|
||||
is_multi_login: bool = Field(description='是否允许多端登录')
|
||||
join_time: datetime = Field(description='加入时间')
|
||||
last_login_time: datetime | None = Field(None, description='最后登录时间')
|
||||
|
||||
|
||||
class GetUserInfoWithRelationDetail(GetUserInfoDetail):
|
||||
"""用户信息关联详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dept: GetDeptDetail | None = Field(None, description='部门信息')
|
||||
roles: list[GetRoleWithRelationDetail] = Field(description='角色列表')
|
||||
|
||||
|
||||
class GetCurrentUserInfoWithRelationDetail(GetUserInfoWithRelationDetail):
|
||||
"""当前用户信息关联详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dept: str | None = Field(None, description='部门名称')
|
||||
roles: list[str] = Field(description='角色名称列表')
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def handel(cls, data: Any) -> Self:
|
||||
"""处理部门和角色数据"""
|
||||
dept = data['dept']
|
||||
if dept:
|
||||
data['dept'] = dept['name']
|
||||
roles = data['roles']
|
||||
if roles:
|
||||
data['roles'] = [role['name'] for role in roles]
|
||||
return data
|
||||
|
||||
@@ -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):
|
||||
"""创建用户历史密码记录"""
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import Request, Response
|
||||
from fastapi.security import HTTPBasicCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.background import BackgroundTask, BackgroundTasks
|
||||
|
||||
from backend.app.admin.crud.crud_menu import menu_dao
|
||||
from backend.app.admin.crud.crud_user import user_dao
|
||||
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.common.enums import LoginLogStatusType
|
||||
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.exception import errors
|
||||
from backend.common.i18n import t
|
||||
from backend.common.log import log
|
||||
@@ -22,11 +19,10 @@ 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 async_db_session, uuid4_str
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_login_config
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -34,7 +30,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]:
|
||||
"""
|
||||
验证用户名和密码
|
||||
|
||||
@@ -47,188 +43,165 @@ class AuthService:
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户名或密码有误')
|
||||
|
||||
if user.password is None:
|
||||
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='用户名或密码有误')
|
||||
else:
|
||||
if not password_verify(password, user.password):
|
||||
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)
|
||||
|
||||
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]:
|
||||
return user, days_remaining
|
||||
|
||||
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
|
||||
"""
|
||||
Swagger 文档登录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 登录凭证
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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(
|
||||
user.id,
|
||||
user.is_multi_login,
|
||||
# extra info
|
||||
swagger=True,
|
||||
)
|
||||
return access_token.access_token, user
|
||||
user, _ = await self.user_verify(db, obj.username, obj.password)
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
swagger=True,
|
||||
)
|
||||
return access_token_data.access_token, user
|
||||
|
||||
async def login(
|
||||
self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
response: Response,
|
||||
obj: AuthLoginParam,
|
||||
) -> GetLoginToken:
|
||||
"""
|
||||
用户登录
|
||||
|
||||
:param request: 请求对象
|
||||
:param db: 数据库会话
|
||||
:param response: 响应对象
|
||||
:param obj: 登录参数
|
||||
:param background_tasks: 后台任务
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}')
|
||||
user = None
|
||||
try:
|
||||
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 user_dao.update_login_time(db, obj.username)
|
||||
await db.refresh(user)
|
||||
access_token = await create_access_token(
|
||||
user.id,
|
||||
user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
nickname=user.nickname,
|
||||
last_login_time=timezone.to_str(user.last_login_time),
|
||||
ip=request.state.ip,
|
||||
os=request.state.os,
|
||||
browser=request.state.browser,
|
||||
device=request.state.device,
|
||||
)
|
||||
refresh_token = await create_refresh_token(access_token.session_uuid, user.id, user.is_multi_login)
|
||||
response.set_cookie(
|
||||
key=settings.COOKIE_REFRESH_TOKEN_KEY,
|
||||
value=refresh_token.refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(refresh_token.refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
except errors.NotFoundError as e:
|
||||
log.error('登陆错误: 用户名不存在')
|
||||
raise errors.NotFoundError(msg=e.msg)
|
||||
except (errors.RequestError, errors.CustomError) as e:
|
||||
if not user:
|
||||
log.error('登陆错误: 用户密码有误')
|
||||
task = BackgroundTask(
|
||||
login_log_service.create,
|
||||
**dict(
|
||||
db=db,
|
||||
request=request,
|
||||
user_uuid=user.uuid if user else uuid4_str(),
|
||||
username=obj.username,
|
||||
login_time=timezone.now(),
|
||||
status=LoginLogStatusType.fail.value,
|
||||
msg=e.msg,
|
||||
),
|
||||
)
|
||||
raise errors.RequestError(code=e.code, msg=e.msg, background=task)
|
||||
except Exception as e:
|
||||
log.error(f'登陆错误: {e}')
|
||||
raise e
|
||||
else:
|
||||
background_tasks.add_task(
|
||||
login_log_service.create,
|
||||
**dict(
|
||||
db=db,
|
||||
request=request,
|
||||
user_uuid=user.uuid,
|
||||
username=obj.username,
|
||||
login_time=timezone.now(),
|
||||
status=LoginLogStatusType.success.value,
|
||||
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,
|
||||
user=user, # type: ignore
|
||||
)
|
||||
return data
|
||||
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
|
||||
|
||||
@staticmethod
|
||||
async def get_codes(*, request: Request) -> list[str]:
|
||||
"""
|
||||
获取用户权限码
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
codes = set()
|
||||
if request.user.is_superuser:
|
||||
async with async_db_session.begin() as db:
|
||||
menus = await menu_dao.get_all(db, None, None)
|
||||
for menu in menus:
|
||||
if menu.perms:
|
||||
codes.add(*menu.perms.split(','))
|
||||
user, days_remaining = await self.user_verify(db, obj.username, obj.password)
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
await db.refresh(user)
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
nickname=user.nickname,
|
||||
last_login_time=timezone.to_str(user.last_login_time),
|
||||
ip=ctx.ip,
|
||||
os=ctx.os,
|
||||
browser=ctx.browser,
|
||||
device=ctx.device,
|
||||
)
|
||||
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_data.refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
except errors.NotFoundError as e:
|
||||
log.error('登陆错误: 用户名不存在')
|
||||
raise errors.NotFoundError(msg=e.msg)
|
||||
except (errors.RequestError, errors.CustomError) as e:
|
||||
if not user:
|
||||
log.error(f'登陆错误: {e.msg}')
|
||||
raise errors.RequestError(code=e.code, msg=e.msg)
|
||||
except Exception as e:
|
||||
log.error(f'登陆错误: {e}')
|
||||
raise
|
||||
else:
|
||||
roles = request.user.roles
|
||||
if roles:
|
||||
for role in roles:
|
||||
for menu in role.menus:
|
||||
if menu.perms:
|
||||
codes.add(*menu.perms.split(','))
|
||||
|
||||
return list(codes)
|
||||
data = GetLoginToken(
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
async def refresh_token(*, request: Request) -> GetNewToken:
|
||||
async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken:
|
||||
"""
|
||||
刷新令牌
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param response: FastAPI 响应对象
|
||||
:return:
|
||||
"""
|
||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||
if not refresh_token:
|
||||
raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
|
||||
|
||||
token_payload = jwt_decode(refresh_token)
|
||||
async with async_db_session() as db:
|
||||
user = await user_dao.get(db, token_payload.id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
elif not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
if not user.is_multi_login:
|
||||
if await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
|
||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||
new_token = await create_new_token(
|
||||
refresh_token,
|
||||
token_payload.session_uuid,
|
||||
user.id,
|
||||
user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
nickname=user.nickname,
|
||||
last_login_time=timezone.to_str(user.last_login_time),
|
||||
ip=request.state.ip,
|
||||
os=request.state.os,
|
||||
browser=request.state.browser,
|
||||
device_type=request.state.device,
|
||||
)
|
||||
data = GetNewToken(
|
||||
access_token=new_token.new_access_token,
|
||||
access_token_expire_time=new_token.new_access_token_expire_time,
|
||||
session_uuid=new_token.session_uuid,
|
||||
)
|
||||
return data
|
||||
user = await user_dao.get(db, token_payload.user_id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
token_keys = await redis_client.get_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
||||
if not user.is_multi_login and [
|
||||
key for key in token_keys if not key.endswith(f':{token_payload.session_uuid}')
|
||||
]:
|
||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||
new_token = await create_new_token(
|
||||
refresh_token,
|
||||
token_payload.session_uuid,
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
nickname=user.nickname,
|
||||
last_login_time=timezone.to_str(user.last_login_time),
|
||||
ip=ctx.ip,
|
||||
os=ctx.os,
|
||||
browser=ctx.browser,
|
||||
device_type=ctx.device,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=settings.COOKIE_REFRESH_TOKEN_KEY,
|
||||
value=new_token.new_refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
data = GetNewToken(
|
||||
access_token=new_token.new_access_token,
|
||||
access_token_expire_time=new_token.new_access_token_expire_time,
|
||||
session_uuid=new_token.session_uuid,
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
async def logout(*, request: Request, response: Response) -> None:
|
||||
@@ -242,7 +215,7 @@ class AuthService:
|
||||
try:
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
user_id = token_payload.id
|
||||
user_id = token_payload.user_id
|
||||
session_uuid = token_payload.session_uuid
|
||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||
except errors.TokenError:
|
||||
@@ -253,7 +226,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,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import Select
|
||||
|
||||
from backend.app.admin.crud.crud_data_rule import data_rule_dao
|
||||
from backend.app.admin.model import DataRule
|
||||
from backend.app.admin.schema.data_rule import (
|
||||
CreateDataRuleParam,
|
||||
DeleteDataRuleParam,
|
||||
GetDataRuleColumnDetail,
|
||||
UpdateDataRuleParam,
|
||||
)
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
from backend.utils.import_parse import dynamic_import_data_model
|
||||
|
||||
|
||||
class DataRuleService:
|
||||
"""数据规则服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, pk: int) -> DataRule:
|
||||
"""
|
||||
获取数据规则详情
|
||||
|
||||
:param pk: 规则 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
data_rule = await data_rule_dao.get(db, pk)
|
||||
if not data_rule:
|
||||
raise errors.NotFoundError(msg='数据规则不存在')
|
||||
return data_rule
|
||||
|
||||
@staticmethod
|
||||
async def get_models() -> list[str]:
|
||||
"""获取所有数据规则可用模型"""
|
||||
return list(settings.DATA_PERMISSION_MODELS.keys())
|
||||
|
||||
@staticmethod
|
||||
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
|
||||
"""
|
||||
获取数据规则可用模型的字段列表
|
||||
|
||||
:param model: 模型名称
|
||||
:return:
|
||||
"""
|
||||
if model not in settings.DATA_PERMISSION_MODELS:
|
||||
raise errors.NotFoundError(msg='数据规则可用模型不存在')
|
||||
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
|
||||
|
||||
model_columns = [
|
||||
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
|
||||
for column in model_ins.__table__.columns
|
||||
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
|
||||
]
|
||||
return model_columns
|
||||
|
||||
@staticmethod
|
||||
async def get_select(*, name: str | None) -> Select:
|
||||
"""
|
||||
获取数据规则列表查询条件
|
||||
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
return await data_rule_dao.get_list(name=name)
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> Sequence[DataRule]:
|
||||
"""获取所有数据规则"""
|
||||
async with async_db_session() as db:
|
||||
data_rules = await data_rule_dao.get_all(db)
|
||||
return data_rules
|
||||
|
||||
@staticmethod
|
||||
async def create(*, obj: CreateDataRuleParam) -> None:
|
||||
"""
|
||||
创建数据规则
|
||||
|
||||
:param obj: 规则创建参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
data_rule = await data_rule_dao.get_by_name(db, obj.name)
|
||||
if data_rule:
|
||||
raise errors.ConflictError(msg='数据规则已存在')
|
||||
await data_rule_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, pk: int, obj: UpdateDataRuleParam) -> int:
|
||||
"""
|
||||
更新数据规则
|
||||
|
||||
:param pk: 规则 ID
|
||||
:param obj: 规则更新参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
data_rule = await data_rule_dao.get(db, pk)
|
||||
if not data_rule:
|
||||
raise errors.NotFoundError(msg='数据规则不存在')
|
||||
if data_rule.name != obj.name:
|
||||
if await data_rule_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='数据规则已存在')
|
||||
count = await data_rule_dao.update(db, pk, obj)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, obj: DeleteDataRuleParam) -> int:
|
||||
"""
|
||||
批量删除数据规则
|
||||
|
||||
:param obj: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
count = await data_rule_dao.delete(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
data_rule_service: DataRuleService = DataRuleService()
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import Select
|
||||
|
||||
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 (
|
||||
CreateDataScopeParam,
|
||||
DeleteDataScopeParam,
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class DataScopeService:
|
||||
"""数据范围服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, pk: int) -> DataScope:
|
||||
"""
|
||||
获取数据范围详情
|
||||
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
data_scope = await data_scope_dao.get(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
return data_scope
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> Sequence[DataScope]:
|
||||
"""获取所有数据范围"""
|
||||
async with async_db_session() as db:
|
||||
data_scopes = await data_scope_dao.get_all(db)
|
||||
return data_scopes
|
||||
|
||||
@staticmethod
|
||||
async def get_rules(*, pk: int) -> DataScope:
|
||||
"""
|
||||
获取数据范围规则
|
||||
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
data_scope = await data_scope_dao.get_with_relation(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
return data_scope
|
||||
|
||||
@staticmethod
|
||||
async def get_select(*, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取数据范围列表查询条件
|
||||
|
||||
:param name: 范围名称
|
||||
:param status: 范围状态
|
||||
:return:
|
||||
"""
|
||||
return await data_scope_dao.get_list(name, status)
|
||||
|
||||
@staticmethod
|
||||
async def create(*, obj: CreateDataScopeParam) -> None:
|
||||
"""
|
||||
创建数据范围
|
||||
|
||||
:param obj: 数据范围参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
data_scope = await data_scope_dao.get_by_name(db, obj.name)
|
||||
if data_scope:
|
||||
raise errors.ConflictError(msg='数据范围已存在')
|
||||
await data_scope_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, pk: int, obj: UpdateDataScopeParam) -> int:
|
||||
"""
|
||||
更新数据范围
|
||||
|
||||
:param pk: 范围 ID
|
||||
:param obj: 数据范围更新参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
data_scope = await data_scope_dao.get(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
if data_scope.name != obj.name:
|
||||
if 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}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_data_scope_rule(*, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
"""
|
||||
更新数据范围规则
|
||||
|
||||
:param pk: 范围 ID
|
||||
:param rule_ids: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
count = await data_scope_dao.update_rules(db, pk, rule_ids)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, obj: DeleteDataScopeParam) -> int:
|
||||
"""
|
||||
批量删除数据范围
|
||||
|
||||
:param obj: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}')
|
||||
return count
|
||||
|
||||
|
||||
data_scope_service: DataScopeService = DataScopeService()
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
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.db import async_db_session
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.build_tree import get_tree_data
|
||||
|
||||
|
||||
class DeptService:
|
||||
"""部门服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, pk: int) -> Dept:
|
||||
"""
|
||||
获取部门详情
|
||||
|
||||
:param pk: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
dept = await dept_dao.get(db, pk)
|
||||
if not dept:
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
return dept
|
||||
|
||||
@staticmethod
|
||||
async def get_tree(
|
||||
*, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取部门树形结构
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param name: 部门名称
|
||||
:param leader: 部门负责人
|
||||
:param phone: 联系电话
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
dept_select = await dept_dao.get_all(request, db, name, leader, phone, status)
|
||||
tree_data = get_tree_data(dept_select)
|
||||
return tree_data
|
||||
|
||||
@staticmethod
|
||||
async def create(*, obj: CreateDeptParam) -> None:
|
||||
"""
|
||||
创建部门
|
||||
|
||||
:param obj: 部门创建参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
dept = await dept_dao.get_by_name(db, obj.name)
|
||||
if dept:
|
||||
raise errors.ConflictError(msg='部门名称已存在')
|
||||
if obj.parent_id:
|
||||
parent_dept = await dept_dao.get(db, obj.parent_id)
|
||||
if not parent_dept:
|
||||
raise errors.NotFoundError(msg='父级部门不存在')
|
||||
await dept_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, pk: int, obj: UpdateDeptParam) -> int:
|
||||
"""
|
||||
更新部门
|
||||
|
||||
:param pk: 部门 ID
|
||||
:param obj: 部门更新参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
dept = await dept_dao.get(db, pk)
|
||||
if not dept:
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
if dept.name != obj.name:
|
||||
if await dept_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='部门名称已存在')
|
||||
if obj.parent_id:
|
||||
parent_dept = await dept_dao.get(db, obj.parent_id)
|
||||
if not parent_dept:
|
||||
raise errors.NotFoundError(msg='父级部门不存在')
|
||||
if obj.parent_id == dept.id:
|
||||
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
||||
count = await dept_dao.update(db, pk, obj)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, pk: int) -> int:
|
||||
"""
|
||||
删除部门
|
||||
|
||||
:param pk: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
dept = await dept_dao.get_with_relation(db, pk)
|
||||
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
|
||||
|
||||
|
||||
dept_service: DeptService = DeptService()
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_login_log import login_log_dao
|
||||
from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginLogParam
|
||||
from backend.common.log import log
|
||||
from backend.database.db import async_db_session
|
||||
|
||||
|
||||
class LoginLogService:
|
||||
"""登录日志服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
|
||||
"""
|
||||
获取登录日志列表查询条件
|
||||
|
||||
:param username: 用户名
|
||||
:param status: 状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
return await login_log_dao.get_list(username=username, status=status, ip=ip)
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
request: Request,
|
||||
user_uuid: str,
|
||||
username: str,
|
||||
login_time: datetime,
|
||||
status: int,
|
||||
msg: str,
|
||||
) -> None:
|
||||
"""
|
||||
创建登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param user_uuid: 用户 UUID
|
||||
:param username: 用户名
|
||||
:param login_time: 登录时间
|
||||
:param status: 状态
|
||||
:param msg: 消息
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
obj = CreateLoginLogParam(
|
||||
user_uuid=user_uuid,
|
||||
username=username,
|
||||
status=status,
|
||||
ip=request.state.ip,
|
||||
country=request.state.country,
|
||||
region=request.state.region,
|
||||
city=request.state.city,
|
||||
user_agent=request.state.user_agent,
|
||||
browser=request.state.browser,
|
||||
os=request.state.os,
|
||||
device=request.state.device,
|
||||
msg=msg,
|
||||
login_time=login_time,
|
||||
)
|
||||
await login_log_dao.create(db, obj)
|
||||
except Exception as e:
|
||||
log.error(f'登录日志创建失败: {e}')
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, obj: DeleteLoginLogParam) -> int:
|
||||
"""
|
||||
批量删除登录日志
|
||||
|
||||
:param obj: 日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
count = await login_log_dao.delete(db, obj.pks)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete_all() -> None:
|
||||
"""清空所有登录日志"""
|
||||
async with async_db_session.begin() as db:
|
||||
await login_log_dao.delete_all(db)
|
||||
|
||||
|
||||
login_log_service: LoginLogService = LoginLogService()
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
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.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.build_tree import get_tree_data, get_vben5_tree_data
|
||||
|
||||
|
||||
class MenuService:
|
||||
"""菜单服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, pk: int) -> Menu:
|
||||
"""
|
||||
获取菜单详情
|
||||
|
||||
:param pk: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
menu = await menu_dao.get(db, menu_id=pk)
|
||||
if not menu:
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
return menu
|
||||
|
||||
@staticmethod
|
||||
async def get_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取菜单树形结构
|
||||
|
||||
:param title: 菜单标题
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
menu_data = await menu_dao.get_all(db, title=title, status=status)
|
||||
menu_tree = get_tree_data(menu_data)
|
||||
return menu_tree
|
||||
|
||||
@staticmethod
|
||||
async def get_sidebar(*, request: Request) -> list[dict[str, Any] | None]:
|
||||
"""
|
||||
获取用户的菜单侧边栏
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
if request.user.is_superuser:
|
||||
menu_data = await menu_dao.get_sidebar(db, None)
|
||||
else:
|
||||
roles = request.user.roles
|
||||
menu_ids = set()
|
||||
if roles:
|
||||
for role in roles:
|
||||
for menu in role.menus:
|
||||
menu_ids.add(menu.id)
|
||||
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
|
||||
menu_tree = get_vben5_tree_data(menu_data)
|
||||
return menu_tree
|
||||
|
||||
@staticmethod
|
||||
async def create(*, obj: CreateMenuParam) -> None:
|
||||
"""
|
||||
创建菜单
|
||||
|
||||
:param obj: 菜单创建参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
title = await menu_dao.get_by_title(db, obj.title)
|
||||
if title:
|
||||
raise errors.ConflictError(msg='菜单标题已存在')
|
||||
if obj.parent_id:
|
||||
parent_menu = await menu_dao.get(db, obj.parent_id)
|
||||
if not parent_menu:
|
||||
raise errors.NotFoundError(msg='父级菜单不存在')
|
||||
await menu_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, pk: int, obj: UpdateMenuParam) -> int:
|
||||
"""
|
||||
更新菜单
|
||||
|
||||
:param pk: 菜单 ID
|
||||
:param obj: 菜单更新参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
menu = await menu_dao.get(db, pk)
|
||||
if not menu:
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
if menu.title != obj.title:
|
||||
if await menu_dao.get_by_title(db, obj.title):
|
||||
raise errors.ConflictError(msg='菜单标题已存在')
|
||||
if obj.parent_id:
|
||||
parent_menu = await menu_dao.get(db, obj.parent_id)
|
||||
if not parent_menu:
|
||||
raise errors.NotFoundError(msg='父级菜单不存在')
|
||||
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}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, pk: int) -> int:
|
||||
"""
|
||||
删除菜单
|
||||
|
||||
:param pk: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}')
|
||||
return count
|
||||
|
||||
|
||||
menu_service: MenuService = MenuService()
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import Select
|
||||
|
||||
from backend.app.admin.crud.crud_opera_log import opera_log_dao
|
||||
from backend.app.admin.schema.opera_log import CreateOperaLogParam, DeleteOperaLogParam
|
||||
from backend.database.db import async_db_session
|
||||
|
||||
|
||||
class OperaLogService:
|
||||
"""操作日志服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
|
||||
"""
|
||||
获取操作日志列表查询条件
|
||||
|
||||
:param username: 用户名
|
||||
:param status: 状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
return await opera_log_dao.get_list(username=username, status=status, ip=ip)
|
||||
|
||||
@staticmethod
|
||||
async def create(*, obj: CreateOperaLogParam) -> None:
|
||||
"""
|
||||
创建操作日志
|
||||
|
||||
:param obj: 操作日志创建参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
await opera_log_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def bulk_create(*, objs: list[CreateOperaLogParam]) -> None:
|
||||
"""
|
||||
批量创建操作日志
|
||||
|
||||
:param objs: 操作日志创建参数列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
await opera_log_dao.bulk_create(db, objs)
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, obj: DeleteOperaLogParam) -> int:
|
||||
"""
|
||||
批量删除操作日志
|
||||
|
||||
:param obj: 日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
count = await opera_log_dao.delete(db, obj.pks)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete_all() -> None:
|
||||
"""清空所有操作日志"""
|
||||
async with async_db_session.begin() as db:
|
||||
await opera_log_dao.delete_all(db)
|
||||
|
||||
|
||||
opera_log_service: OperaLogService = OperaLogService()
|
||||
@@ -1,22 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
|
||||
from fastapi import UploadFile
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.common.enums import PluginType, StatusType
|
||||
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.core import get_required_plugins
|
||||
from backend.plugin.installer import install_git_plugin, install_zip_plugin, remove_plugin, zip_plugin
|
||||
from backend.plugin.requirements import uninstall_requirements_async
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
@@ -26,14 +25,21 @@ class PluginService:
|
||||
@staticmethod
|
||||
async def get_all() -> list[dict[str, Any]]:
|
||||
"""获取所有插件"""
|
||||
keys = []
|
||||
|
||||
changed_key = f'{settings.PLUGIN_REDIS_PREFIX}:changed'
|
||||
keys = [key for key in await redis_client.get_by_prefix(settings.PLUGIN_REDIS_PREFIX) if key != changed_key]
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
result = []
|
||||
plugin_infos = await redis_client.mget(*keys)
|
||||
for info in plugin_infos:
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*'):
|
||||
keys.append(key)
|
||||
|
||||
for info in await redis_client.mget(*keys):
|
||||
result.append(json.loads(info))
|
||||
plugin_info = json.loads(info)
|
||||
if isinstance(plugin_info, dict):
|
||||
result.append(plugin_info)
|
||||
|
||||
return result
|
||||
|
||||
@@ -52,6 +58,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 压缩包不能为空')
|
||||
@@ -61,31 +69,37 @@ class PluginService:
|
||||
return await install_git_plugin(repo_url)
|
||||
|
||||
@staticmethod
|
||||
async def uninstall(*, plugin: str):
|
||||
async def uninstall(*, plugin: str) -> None:
|
||||
"""
|
||||
卸载插件
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
|
||||
if not os.path.exists(plugin_dir):
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.RequestError(msg='禁止在非开发环境下卸载插件')
|
||||
if plugin in get_required_plugins():
|
||||
raise errors.RequestError(msg=f'插件 {plugin} 为必需插件,禁止卸载')
|
||||
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
|
||||
if not await plugin_dir.exists():
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
await uninstall_requirements_async(plugin)
|
||||
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
|
||||
shutil.move(plugin_dir, bacup_dir)
|
||||
backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip'
|
||||
await run_in_threadpool(zip_plugin, plugin_dir, backup_file)
|
||||
await run_in_threadpool(remove_plugin, plugin_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):
|
||||
async def update_status(*, plugin: str) -> None:
|
||||
"""
|
||||
更新插件状态
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
|
||||
plugin_key = f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}'
|
||||
plugin_info = await redis_client.get(plugin_key)
|
||||
if not plugin_info:
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
plugin_info = json.loads(plugin_info)
|
||||
@@ -97,7 +111,8 @@ class PluginService:
|
||||
else str(StatusType.disable.value)
|
||||
)
|
||||
plugin_info['plugin']['enable'] = new_status
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}', json.dumps(plugin_info, ensure_ascii=False))
|
||||
await redis_client.set(plugin_key, json.dumps(plugin_info, ensure_ascii=False))
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
@staticmethod
|
||||
async def build(*, plugin: str) -> io.BytesIO:
|
||||
@@ -107,19 +122,12 @@ class PluginService:
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
|
||||
if not os.path.exists(plugin_dir):
|
||||
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
|
||||
if not await plugin_dir.exists():
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
|
||||
bio = io.BytesIO()
|
||||
with zipfile.ZipFile(bio, 'w') as zf:
|
||||
for root, dirs, files in os.walk(plugin_dir):
|
||||
dirs[:] = [d for d in dirs if d != '__pycache__']
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, start=plugin_dir)
|
||||
zf.write(file_path, os.path.join(plugin, arcname))
|
||||
|
||||
await run_in_threadpool(zip_plugin, plugin_dir, bio)
|
||||
bio.seek(0)
|
||||
return bio
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Any, Sequence
|
||||
|
||||
from sqlalchemy import Select
|
||||
|
||||
from backend.app.admin.crud.crud_data_scope import data_scope_dao
|
||||
from backend.app.admin.crud.crud_menu import menu_dao
|
||||
from backend.app.admin.crud.crud_role import role_dao
|
||||
from backend.app.admin.model import Role
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleParam,
|
||||
DeleteRoleParam,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
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.build_tree import get_tree_data
|
||||
|
||||
|
||||
class RoleService:
|
||||
"""角色服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, pk: int) -> Role:
|
||||
"""
|
||||
获取角色详情
|
||||
|
||||
:param pk: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
role = await role_dao.get_with_relation(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> Sequence[Role]:
|
||||
"""获取所有角色"""
|
||||
async with async_db_session() as db:
|
||||
roles = await role_dao.get_all(db)
|
||||
return roles
|
||||
|
||||
@staticmethod
|
||||
async def get_select(*, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取角色列表查询条件
|
||||
|
||||
:param name: 角色名称
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
return await role_dao.get_list(name=name, status=status)
|
||||
|
||||
@staticmethod
|
||||
async def get_menu_tree(*, pk: int) -> list[dict[str, Any] | None]:
|
||||
"""
|
||||
获取角色的菜单树形结构
|
||||
|
||||
:param pk: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
role = await role_dao.get_with_relation(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
menu_tree = get_tree_data(role.menus) if role.menus else []
|
||||
return menu_tree
|
||||
|
||||
@staticmethod
|
||||
async def get_scopes(*, pk: int) -> list[int]:
|
||||
"""
|
||||
获取角色数据范围列表
|
||||
|
||||
:param pk:
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
role = await role_dao.get_with_relation(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
scope_ids = [scope.id for scope in role.scopes]
|
||||
return scope_ids
|
||||
|
||||
@staticmethod
|
||||
async def create(*, obj: CreateRoleParam) -> None:
|
||||
"""
|
||||
创建角色
|
||||
|
||||
:param obj: 角色创建参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
role = await role_dao.get_by_name(db, obj.name)
|
||||
if role:
|
||||
raise errors.ConflictError(msg='角色已存在')
|
||||
await role_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, pk: int, obj: UpdateRoleParam) -> int:
|
||||
"""
|
||||
更新角色
|
||||
|
||||
:param pk: 角色 ID
|
||||
:param obj: 角色更新参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
if role.name != obj.name:
|
||||
if 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}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
"""
|
||||
更新角色菜单
|
||||
|
||||
:param pk: 角色 ID
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
for menu_id in menu_ids.menus:
|
||||
menu = await menu_dao.get(db, menu_id)
|
||||
if not menu:
|
||||
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}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||
"""
|
||||
更新角色数据范围
|
||||
|
||||
:param pk: 角色 ID
|
||||
:param scope_ids: 权限规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
for scope_id in scope_ids.scopes:
|
||||
scope = await data_scope_dao.get(db, scope_id)
|
||||
if not scope:
|
||||
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}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, obj: DeleteRoleParam) -> int:
|
||||
"""
|
||||
批量删除角色
|
||||
|
||||
:param obj: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}')
|
||||
return count
|
||||
|
||||
|
||||
role_service: RoleService = RoleService()
|
||||
@@ -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.set(
|
||||
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
|
||||
str(failure_count),
|
||||
ex=settings.USER_LOCK_SECONDS,
|
||||
)
|
||||
|
||||
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
||||
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
||||
await redis_client.set(
|
||||
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
|
||||
timezone.to_str(locked_until),
|
||||
ex=settings.USER_LOCK_SECONDS,
|
||||
)
|
||||
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,27 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import random
|
||||
|
||||
from typing import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_dept import dept_dao
|
||||
from backend.app.admin.crud.crud_role import role_dao
|
||||
from backend.app.admin.crud.crud_user import user_dao
|
||||
from backend.app.admin.model import Role, User
|
||||
from backend.app.admin.model import User
|
||||
from backend.app.admin.schema.user import (
|
||||
AddUserParam,
|
||||
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.enums import UserPermissionType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.response.response_code import CustomErrorCode
|
||||
from backend.common.security.jwt import get_token, jwt_decode, password_verify, superuser_verify
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.common.security.jwt import get_token, jwt_decode
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
|
||||
@@ -29,294 +25,252 @@ class UserService:
|
||||
"""用户服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_userinfo(*, pk: int | None = None, username: str | None = None) -> User:
|
||||
async def get_userinfo(*, db: AsyncSession, pk: int | None = None, username: str | None = None) -> User:
|
||||
"""
|
||||
获取用户信息
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
user = await user_dao.get_with_relation(db, user_id=pk, username=username)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user
|
||||
user = await user_dao.get(db, pk) if pk else await user_dao.get_by_username(db, username or '')
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def get_roles(*, pk: int) -> Sequence[Role]:
|
||||
async def get_list(*, db: AsyncSession, username: str, phone: str, status: int) -> dict[str, Any]:
|
||||
"""
|
||||
获取用户所有角色
|
||||
获取用户列表
|
||||
|
||||
:param pk: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session() as db:
|
||||
user = await user_dao.get_with_relation(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user.roles
|
||||
|
||||
@staticmethod
|
||||
async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select:
|
||||
"""
|
||||
获取用户列表查询条件
|
||||
|
||||
:param dept: 部门 ID
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:param phone: 手机号
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status)
|
||||
user_select = await user_dao.get_select(username=username, phone=phone, status=status)
|
||||
return await paging_data(db, user_select)
|
||||
|
||||
@staticmethod
|
||||
async def create(*, request: Request, obj: AddUserParam) -> None:
|
||||
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
|
||||
"""
|
||||
创建用户
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param obj: 用户添加参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
superuser_verify(request)
|
||||
if await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}'
|
||||
if not obj.password:
|
||||
raise errors.RequestError(msg='密码不允许为空')
|
||||
if not await dept_dao.get(db, obj.dept_id):
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
for role_id in obj.roles:
|
||||
if not await role_dao.get(db, role_id):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
await user_dao.add(db, obj)
|
||||
if await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
if obj.email and await user_dao.check_email(db, obj.email):
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
if not obj.password:
|
||||
raise errors.RequestError(msg='密码不允许为空')
|
||||
obj.nickname = obj.nickname or obj.username
|
||||
await user_dao.add(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, request: Request, pk: int, obj: UpdateUserParam) -> int:
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateUserParam) -> int:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param obj: 用户更新参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
superuser_verify(request)
|
||||
user = await user_dao.get_with_relation(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if obj.username != user.username:
|
||||
if await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(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)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
user = await user_dao.get(db, 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.email and obj.email != user.email:
|
||||
email_user = await user_dao.check_email(db, obj.email)
|
||||
if email_user:
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
count = await user_dao.update(db, user.id, obj)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_permission(*, request: Request, pk: int, type: UserPermissionType) -> int:
|
||||
async def update_permission(*, db: AsyncSession, request: Request, pk: int, type: UserPermissionType) -> int: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
更新用户权限
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param pk: 用户 ID
|
||||
:param type: 权限类型
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
superuser_verify(request)
|
||||
match type:
|
||||
case UserPermissionType.superuser:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_super(db, pk, not user.status)
|
||||
case UserPermissionType.staff:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_staff(db, pk, not user.is_staff)
|
||||
case UserPermissionType.status:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1)
|
||||
case UserPermissionType.multi_login:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
|
||||
new_multi_login = not multi_login
|
||||
count = await user_dao.set_multi_login(db, pk, new_multi_login)
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
if pk == user.id:
|
||||
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
||||
if not new_multi_login:
|
||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||
await redis_client.delete_prefix(
|
||||
key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}'
|
||||
)
|
||||
else:
|
||||
# 系统管理员修改他人时,他人 token 全部失效
|
||||
if not new_multi_login:
|
||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||
await redis_client.delete_prefix(key_prefix)
|
||||
case _:
|
||||
raise errors.RequestError(msg='权限类型不存在')
|
||||
match type:
|
||||
case UserPermissionType.superuser:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
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.is_superuser)
|
||||
case UserPermissionType.staff:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_staff(db, pk, is_staff=not user.is_staff)
|
||||
case UserPermissionType.status:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1)
|
||||
case UserPermissionType.multi_login:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
|
||||
new_multi_login = not multi_login
|
||||
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
if pk == user.id:
|
||||
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
||||
if not new_multi_login:
|
||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||
await redis_client.delete_by_prefix(
|
||||
key_prefix,
|
||||
exclude_keys=f'{key_prefix}:{token_payload.session_uuid}',
|
||||
)
|
||||
else:
|
||||
# 系统管理员修改他人时,他人 token 全部失效
|
||||
if not new_multi_login:
|
||||
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
|
||||
await redis_client.delete_by_prefix(key_prefix)
|
||||
case _:
|
||||
raise errors.RequestError(msg='权限类型不存在')
|
||||
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def reset_password(*, request: Request, pk: int, password: str) -> int:
|
||||
async def reset_password(*, db: AsyncSession, pk: int, password: str) -> int:
|
||||
"""
|
||||
重置用户密码
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param password: 新密码
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
superuser_verify(request)
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
count = await user_dao.reset_password(db, user.id, password)
|
||||
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)
|
||||
return count
|
||||
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)
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_nickname(*, request: Request, nickname: str) -> int:
|
||||
async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
更新当前用户昵称
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}')
|
||||
return count
|
||||
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(*, request: Request, avatar: str) -> int:
|
||||
async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
|
||||
"""
|
||||
更新当前用户头像
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param avatar: 头像地址
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}')
|
||||
return count
|
||||
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(*, request: Request, captcha: str, email: str) -> int:
|
||||
async def update_email(*, db: AsyncSession, user_id: int, email: str) -> int:
|
||||
"""
|
||||
更新当前用户邮箱
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param captcha: 邮箱验证码
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param email: 邮箱
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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}:{request.state.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}:{request.state.ip}')
|
||||
count = await user_dao.update_email(db, token_payload.id, email)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
email_user = await user_dao.check_email(db, email)
|
||||
if email_user and email_user.id != user_id:
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
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(*, request: Request, obj: ResetPasswordParam) -> int:
|
||||
async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
|
||||
"""
|
||||
更新当前用户密码
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param obj: 密码重置参数
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
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):
|
||||
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)
|
||||
key_prefix = [
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
|
||||
]
|
||||
for prefix in key_prefix:
|
||||
await redis_client.delete_prefix(prefix)
|
||||
return count
|
||||
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='两次密码输入不一致')
|
||||
|
||||
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)
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, pk: int) -> int:
|
||||
async def delete(*, db: AsyncSession, pk: int) -> int:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
async with async_db_session.begin() as db:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
count = await user_dao.delete(db, user.id)
|
||||
key_prefix = [
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
|
||||
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
|
||||
]
|
||||
for key in key_prefix:
|
||||
await redis_client.delete_prefix(key)
|
||||
return count
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
count = await user_dao.delete(db, user.id)
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
|
||||
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
|
||||
user_service: UserService = UserService()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_logout(client: TestClient, token_headers: dict[str, str]) -> None:
|
||||
response = client.post('/auth/logout', headers=token_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()['code'] == 200
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio.session import AsyncSession
|
||||
|
||||
from backend.database.db import create_async_engine_and_session, create_database_url
|
||||
|
||||
TEST_SQLALCHEMY_DATABASE_URL = create_database_url(unittest=True)
|
||||
|
||||
_, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""session 生成器"""
|
||||
async with async_test_db_session() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,17 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
|
||||
class UserCacheManager:
|
||||
"""用户缓存管理"""
|
||||
|
||||
@staticmethod
|
||||
async def clear(user_ids: Sequence[int]) -> None:
|
||||
"""清理用户缓存"""
|
||||
if user_ids:
|
||||
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in 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} 次使用的密码相同'
|
||||
)
|
||||
@@ -1,11 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.router import v1 as admin_v1
|
||||
from backend.app.task.api.router import v1 as task_v1
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(admin_v1)
|
||||
router.include_router(task_v1)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
## 任务介绍
|
||||
|
||||
当前任务使用 Celery
|
||||
实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi_best_architecture/discussions/225)
|
||||
|
||||
## 定时任务
|
||||
|
||||
在 `backend/app/task/tasks/beat.py` 文件内编写相关定时任务
|
||||
|
||||
### 简单任务
|
||||
|
||||
在 `backend/app/task/tasks/tasks.py` 文件内编写相关任务代码
|
||||
|
||||
### 层级任务
|
||||
|
||||
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
|
||||
|
||||
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
|
||||
2. 新建目录后,务必更新 `conf.py` 配置中的 `CELERY_TASKS_PACKAGES`,将新建目录模块路径添加到此列表
|
||||
3. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
|
||||
|
||||
## 消息代理
|
||||
|
||||
你可以通过 `CELERY_BROKER` 控制消息代理选择,它支持 redis 和 rabbitmq
|
||||
|
||||
对于本地调试,建议使用 redis
|
||||
|
||||
对于线上环境,强制使用 rabbitmq
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
|
||||
from .actions import * # noqa: F403
|
||||
|
||||
# 导入项目根目录
|
||||
sys.path.append(str(BASE_PATH.parent))
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.app.task.celery import celery_app
|
||||
from backend.common.socketio.server import sio
|
||||
|
||||
|
||||
@sio.event
|
||||
async def task_worker_status(sid, data):
|
||||
"""任务 Worker 状态事件"""
|
||||
worker = await run_in_threadpool(celery_app.control.ping)
|
||||
await sio.emit('task_worker_status', worker, sid)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user