This commit is contained in:
mizhexiaoxiao 2025-02-25 19:30:06 +08:00
parent 7c3818f7c4
commit f6ce09a0f5
16 changed files with 1556 additions and 180 deletions

View File

@ -20,9 +20,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core-apt \
&& apt-get update \
&& apt-get install -y --no-install-recommends gcc python3-dev bash nginx vim curl procps net-tools
RUN pip install poetry -i https://pypi.tuna.tsinghua.edu.cn/simple \
&& poetry config virtualenvs.create false \
&& poetry install
RUN pip install uv -i https://pypi.tuna.tsinghua.edu.cn/simple \
&& uv add --requirements requirements.txt
COPY --from=web /opt/vue-fastapi-admin/web/dist /opt/vue-fastapi-admin/web/dist
ADD /deploy/web.conf /etc/nginx/sites-available/web.conf

View File

@ -36,14 +36,15 @@ targets:
.PHONY: install
install: ## Install dependencies
poetry install
uv add pyproject.toml
.PHONY: run
run: start
.PHONY: start
start: ## Starts the server
poetry run python run.py
python run.py
# Check, lint and format targets
# ------------------------------
@ -53,28 +54,24 @@ check: check-format lint
.PHONY: check-format
check-format: ## Dry-run code formatter
poetry run black ./ --check
poetry run isort ./ --profile black --check
black ./ --check
isort ./ --profile black --check
.PHONY: lint
lint: ## Run ruff
poetry run ruff check ./app
ruff check ./app
.PHONY: format
format: ## Run code formatter
poetry run black ./
poetry run isort ./ --profile black
black ./
isort ./ --profile black
.PHONY: check-lockfile
check-lockfile: ## Compares lock file with pyproject.toml
poetry lock --check
.PHONY: test
test: ## Run the test suite
$(eval include .env)
$(eval export $(sh sed 's/=.*//' .env))
poetry run pytest -vv -s --cache-clear ./
pytest -vv -s --cache-clear ./
.PHONY: clean-db
clean-db: ## 删除migrations文件夹和db.sqlite3
@ -83,8 +80,8 @@ clean-db: ## 删除migrations文件夹和db.sqlite3
.PHONY: migrate
migrate: ## 运行aerich migrate命令生成迁移文件
poetry run aerich migrate
aerich migrate
.PHONY: upgrade
upgrade: ## 运行aerich upgrade命令应用迁移
poetry run aerich upgrade
aerich upgrade

View File

@ -86,19 +86,48 @@ password123456
#### Backend
The backend service requires the following environment:
- Python 3.11
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer)
#### Method 1 (Recommended): Install Dependencies with uv
1. Install uv
```sh
pip install uv
```
2. Create and activate virtual environment
```sh
uv venv
source .venv/bin/activate # Linux/Mac
# or
.\.venv\Scripts\activate # Windows
```
3. Install dependencies
```sh
uv add pyproject.toml
```
4. Start the backend service
```sh
python run.py
```
#### Method 2: Install Dependencies with Pip
1. Create a Python virtual environment:
```sh
poetry shell
python3 -m venv venv
source venv/bin/activate # Linux/Mac
# or
.\venv\Scripts\activate # Windows
```
2. Install project dependencies:
```sh
poetry install
pip install -r requirements.txt
```
3. Start the backend service:
```sh
make run
python run.py
```
The backend service is now running, and you can visit http://localhost:9999/docs to view the API documentation.

View File

@ -87,33 +87,49 @@ password123456
启动项目需要以下环境:
- Python 3.11
#### 方法一(推荐):[Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) 安装依赖
#### 方法一(推荐):使用 uv 安装依赖
1. 安装 uv
```sh
pip install uv
```
2. 创建并激活虚拟环境
```sh
uv venv
source .venv/bin/activate # Linux/Mac
# 或
.\.venv\Scripts\activate # Windows
```
3. 安装依赖
```sh
uv add pyproject.toml
```
4. 启动服务
```sh
python run.py
```
#### 方法二:使用 Pip 安装依赖
1. 创建虚拟环境
```sh
poetry shell
```
2. 安装依赖
```sh
poetry install
```
3. 启动服务
```sh
make run
```
#### 方法二Pip 安装依赖
1. 创建虚拟环境
```sh
python3.11 -m venv venv
python3 -m venv venv
```
2. 激活虚拟环境
```sh
source venv/bin/activate
source venv/bin/activate # Linux/Mac
# 或
.\venv\Scripts\activate # Windows
```
3. 安装依赖
```sh
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
```
3. 启动服务
4. 启动服务
```sh
python run.py
```

View File

@ -30,7 +30,7 @@ async def get_audit_log_list(
if summary:
q &= Q(summary__icontains=summary)
if status:
q &= Q(status__icontains=status)
q &= Q(status=status)
if start_time and end_time:
q &= Q(created_at__range=[start_time, end_time])
elif start_time:

View File

@ -43,6 +43,7 @@ def make_middlewares():
HttpAuditLogMiddleware,
methods=["GET", "POST", "PUT", "DELETE"],
exclude_paths=[
"/api/v1/base/access_token",
"/docs",
"/openapi.json",
],

View File

@ -1,5 +1,7 @@
import json
import re
from datetime import datetime
from typing import Any, AsyncGenerator
from fastapi import FastAPI
from fastapi.responses import Response
@ -45,10 +47,77 @@ class BackGroundTaskMiddleware(SimpleBaseMiddleware):
class HttpAuditLogMiddleware(BaseHTTPMiddleware):
def __init__(self, app, methods: list, exclude_paths: list):
def __init__(self, app, methods: list[str], exclude_paths: list[str]):
super().__init__(app)
self.methods = methods
self.exclude_paths = exclude_paths
self.audit_log_paths = ["/api/v1/auditlog/list"]
self.max_body_size = 1024 * 1024 # 1MB 响应体大小限制
async def get_request_args(self, request: Request) -> dict:
args = {}
# 获取查询参数
for key, value in request.query_params.items():
args[key] = value
# 获取请求体
if request.method in ["POST", "PUT", "PATCH"]:
try:
body = await request.json()
args.update(body)
except json.JSONDecodeError:
try:
body = await request.form()
args.update(body)
except Exception:
pass
return args
async def get_response_body(self, request: Request, response: Response) -> Any:
# 检查Content-Length
content_length = response.headers.get("content-length")
if content_length and int(content_length) > self.max_body_size:
return {"code": 0, "msg": "Response too large to log", "data": None}
if hasattr(response, "body"):
body = response.body
else:
body_chunks = []
async for chunk in response.body_iterator:
if not isinstance(chunk, bytes):
chunk = chunk.encode(response.charset)
body_chunks.append(chunk)
response.body_iterator = self._async_iter(body_chunks)
body = b"".join(body_chunks)
if any(request.url.path.startswith(path) for path in self.audit_log_paths):
try:
data = self.lenient_json(body)
# 只保留基本信息,去除详细的响应内容
if isinstance(data, dict):
data.pop("response_body", None)
if "data" in data and isinstance(data["data"], list):
for item in data["data"]:
item.pop("response_body", None)
return data
except Exception:
return None
return self.lenient_json(body)
def lenient_json(self, v: Any) -> Any:
if isinstance(v, (str, bytes)):
try:
return json.loads(v)
except (ValueError, TypeError):
pass
return v
async def _async_iter(self, items: list[bytes]) -> AsyncGenerator[bytes, None]:
for item in items:
yield item
async def get_request_log(self, request: Request, response: Response) -> dict:
"""
@ -73,23 +142,29 @@ class HttpAuditLogMiddleware(BaseHTTPMiddleware):
user_obj: User = await AuthControl.is_authed(token)
data["user_id"] = user_obj.id if user_obj else 0
data["username"] = user_obj.username if user_obj else ""
except Exception as e:
except Exception:
data["user_id"] = 0
data["username"] = ""
return data
async def before_request(self, request: Request):
pass
request_args = await self.get_request_args(request)
request.state.request_args = request_args
async def after_request(self, request: Request, response: Response, process_time: int):
if request.method in self.methods: # 请求方法为配置的记录方法
if request.method in self.methods:
for path in self.exclude_paths:
if re.search(path, request.url.path, re.I) is not None:
return
data: dict = await self.get_request_log(request=request, response=response)
data["response_time"] = process_time # 响应时间
data["response_time"] = process_time
data["request_args"] = request.state.request_args
data["response_body"] = await self.get_response_body(request, response)
await AuditLog.create(**data)
return response
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
start_time: datetime = datetime.now()
await self.before_request(request)

View File

@ -49,7 +49,7 @@ class Menu(BaseModel, TimestampMixin):
icon = fields.CharField(max_length=100, null=True, description="菜单图标")
path = fields.CharField(max_length=100, description="菜单路径", index=True)
order = fields.IntField(default=0, description="排序", index=True)
parent_id = fields.IntField(default=0, max_length=10, description="父菜单ID", index=True)
parent_id = fields.IntField(default=0, description="父菜单ID", index=True)
is_hidden = fields.BooleanField(default=False, description="是否隐藏")
component = fields.CharField(max_length=100, description="组件")
keepalive = fields.BooleanField(default=True, description="存活")
@ -85,3 +85,5 @@ class AuditLog(BaseModel, TimestampMixin):
path = fields.CharField(max_length=255, default="", description="请求路径", index=True)
status = fields.IntField(default=-1, description="状态码", index=True)
response_time = fields.IntField(default=0, description="响应时间(单位ms)", index=True)
request_args = fields.JSONField(null=True, description="请求参数")
response_body = fields.JSONField(null=True, description="返回数据")

View File

@ -10,8 +10,7 @@ class BaseApi(BaseModel):
tags: str = Field(..., description="API标签", example="User")
class ApiCreate(BaseApi):
...
class ApiCreate(BaseApi): ...
class ApiUpdate(BaseApi):

View File

@ -8,8 +8,7 @@ class BaseDept(BaseModel):
parent_id: int = Field(0, description="父部门ID")
class DeptCreate(BaseDept):
...
class DeptCreate(BaseDept): ...
class DeptUpdate(BaseDept):

View File

@ -1,55 +1,87 @@
[tool.poetry]
[project]
name = "vue-fastapi-admin"
version = "0.1.0"
description = "Vue Fastapi admin"
authors = ["mizhexiaoxiao <mizhexiaoxiao@gmail.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "0.111.0"
tortoise-orm = "^0.20.1"
pydantic = "^2.7.1"
email-validator = "^2.0.0.post2"
passlib = "^1.7.4"
pyjwt = "^2.7.0"
black = "^23.7.0"
isort = "^5.12.0"
ruff = "^0.0.281"
loguru = "^0.7.0"
pydantic-settings = "^2.0.3"
argon2-cffi = "^23.1.0"
pydantic-core = "^2.18.2"
annotated-types = "^0.6.0"
setuptools = "^70.0.0"
uvicorn = "^0.30.1"
h11 = "^0.14.0"
aerich = "^0.7.2"
authors = [
{name = "mizhexiaoxiao", email = "mizhexiaoxiao@gmail.com"},
]
requires-python = ">=3.11"
dependencies = [
"fastapi==0.111.0",
"tortoise-orm==0.23.0",
"pydantic==2.10.5",
"email-validator==2.2.0",
"passlib==1.7.4",
"pyjwt==2.10.1",
"black==24.10.0",
"isort==5.13.2",
"ruff==0.9.1",
"loguru==0.7.3",
"pydantic-settings==2.7.1",
"argon2-cffi==23.1.0",
"pydantic-core==2.27.2",
"annotated-types==0.7.0",
"setuptools==75.8.0",
"uvicorn==0.34.0",
"h11==0.14.0",
"aerich==0.8.1",
"aiosqlite==0.20.0",
"anyio==4.8.0",
"argon2-cffi-bindings==21.2.0",
"asyncclick==8.1.8",
"certifi==2024.12.14",
"cffi==1.17.1",
"click==8.1.8",
"dictdiffer==0.9.0",
"dnspython==2.7.0",
"fastapi-cli==0.0.7",
"httpcore==1.0.7",
"httptools==0.6.4",
"httpx==0.28.1",
"idna==3.10",
"iso8601==2.1.0",
"jinja2==3.1.5",
"markdown-it-py==3.0.0",
"markupsafe==3.0.2",
"mdurl==0.1.2",
"mypy-extensions==1.0.0",
"orjson==3.10.14",
"packaging==24.2",
"pathspec==0.12.1",
"platformdirs==4.3.6",
"pycparser==2.22",
"pygments==2.19.1",
"pypika-tortoise==0.3.2",
"python-dotenv==1.0.1",
"python-multipart==0.0.20",
"pytz==2024.2",
"pyyaml==6.0.2",
"rich==13.9.4",
"rich-toolkit==0.13.2",
"shellingham==1.5.4",
"sniffio==1.3.1",
"starlette==0.37.2",
"typer==0.15.1",
"typing-extensions==4.12.2",
"ujson==5.10.0",
"uvloop==0.21.0",
"watchfiles==1.0.4",
"websockets==14.1",
"pyproject-toml>=0.1.0",
]
[tool.black]
line-length = 120
target-version = ["py310", "py311"]
[[tool.poetry.source]]
name = "tsinghua"
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
priority = "primary"
[tool.ruff]
line-length = 120
extend-select = [
# "I", # isort
# "B", # flake8-bugbear
# "C4", # flake8-comprehensions
# "PGH", # pygrep-hooks
# "RUF", # ruff
# "W", # pycodestyle
# "YTT", # flake8-2020
]
ignore = [
lint.extend-select = []
lint.ignore = [
"F403",
"F405",
]
[tool.aerich]
tortoise_orm = "app.settings.TORTOISE_ORM"
location = "./migrations"

View File

@ -1,60 +1,60 @@
aerich==0.7.2
aiosqlite==0.17.0
annotated-types==0.6.0
anyio==4.4.0
aerich==0.8.1
aiosqlite==0.20.0
annotated-types==0.7.0
anyio==4.8.0
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
black==23.12.1
certifi==2024.7.4
cffi==1.17.0
click==8.1.7
asyncclick==8.1.8
black==24.10.0
certifi==2024.12.14
cffi==1.17.1
click==8.1.8
dictdiffer==0.9.0
dnspython==2.6.1
email_validator==2.2.0
dnspython==2.7.0
email-validator==2.2.0
fastapi==0.111.0
fastapi-cli==0.0.5
fastapi-cli==0.0.7
h11==0.14.0
httpcore==1.0.5
httptools==0.6.1
httpx==0.27.0
idna==3.8
iso8601==1.1.0
httpcore==1.0.7
httptools==0.6.4
httpx==0.28.1
idna==3.10
iso8601==2.1.0
isort==5.13.2
Jinja2==3.1.4
loguru==0.7.2
jinja2==3.1.5
loguru==0.7.3
markdown-it-py==3.0.0
MarkupSafe==2.1.5
markupsafe==3.0.2
mdurl==0.1.2
mypy-extensions==1.0.0
orjson==3.10.7
packaging==24.1
orjson==3.10.14
packaging==24.2
passlib==1.7.4
pathspec==0.12.1
platformdirs==4.2.2
platformdirs==4.3.6
pycparser==2.22
pydantic==2.9.0b1
pydantic-settings==2.4.0
pydantic_core==2.23.0
Pygments==2.18.0
PyJWT==2.9.0
pypika-tortoise==0.1.6
pydantic==2.10.5
pydantic-core==2.27.2
pydantic-settings==2.7.1
pygments==2.19.1
pyjwt==2.10.1
pypika-tortoise==0.3.2
python-dotenv==1.0.1
python-multipart==0.0.9
pytz==2024.1
PyYAML==6.0.2
rich==13.8.0
ruff==0.0.281
setuptools==70.3.0
python-multipart==0.0.20
pytz==2024.2
pyyaml==6.0.2
rich==13.9.4
rich-toolkit==0.13.2
ruff==0.9.1
setuptools==75.8.0
shellingham==1.5.4
sniffio==1.3.1
starlette==0.37.2
tomlkit==0.13.2
tortoise-orm==0.20.1
typer==0.12.5
typing_extensions==4.12.2
tzdata==2024.1
tortoise-orm==0.23.0
typer==0.15.1
typing-extensions==4.12.2
ujson==5.10.0
uvicorn==0.30.6
uvloop==0.20.0
watchfiles==0.23.0
websockets==13.0
uvicorn==0.34.0
uvloop==0.21.0; sys_platform != 'win32'
watchfiles==1.0.4
websockets==14.1

11
run.py
View File

@ -1,4 +1,13 @@
import uvicorn
from uvicorn.config import LOGGING_CONFIG
if __name__ == "__main__":
uvicorn.run("app:app", host="0.0.0.0", port=9999, reload=True, log_config="uvicorn_loggin_config.json")
# 修改默认日志配置
LOGGING_CONFIG["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s"
LOGGING_CONFIG["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
LOGGING_CONFIG["formatters"]["access"][
"fmt"
] = '%(asctime)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s'
LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
uvicorn.run("app:app", host="0.0.0.0", port=9988, reload=True, log_config=LOGGING_CONFIG)

1197
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +0,0 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": null
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": "%(asctime)s - %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s"
}
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr"
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout"
}
},
"loggers": {
"uvicorn": {
"handlers": [
"default"
],
"level": "INFO"
},
"uvicorn.error": {
"level": "INFO"
},
"uvicorn.access": {
"handlers": [
"access"
],
"level": "INFO",
"propagate": false
}
}
}

View File

@ -1,6 +1,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { NInput, NSelect } from 'naive-ui'
import { NInput, NSelect, NPopover } from 'naive-ui'
import TheIcon from '@/components/icon/TheIcon.vue'
import CommonPage from '@/components/page/CommonPage.vue'
import QueryBarItem from '@/components/query-bar/QueryBarItem.vue'
@ -78,6 +79,16 @@ const methodOptions = [
},
]
function formatJSON(data) {
try {
return typeof data === 'string'
? JSON.stringify(JSON.parse(data), null, 2)
: JSON.stringify(data, null, 2)
} catch (e) {
return data || '无数据'
}
}
const columns = [
{
title: '用户名称',
@ -121,6 +132,62 @@ const columns = [
width: 'auto',
ellipsis: { tooltip: true },
},
{
title: '请求体',
key: 'request_body',
align: 'center',
width: 80,
render: (row) => {
return h(
NPopover,
{
trigger: 'hover',
placement: 'right',
},
{
trigger: () =>
h('div', { style: 'cursor: pointer;' }, [h(TheIcon, { icon: 'carbon:data-view' })]),
default: () =>
h(
'pre',
{
style:
'max-height: 400px; overflow: auto; background-color: #f5f5f5; padding: 8px; border-radius: 4px;',
},
formatJSON(row.request_args)
),
}
)
},
},
{
title: '响应体',
key: 'response_body',
align: 'center',
width: 80,
render: (row) => {
return h(
NPopover,
{
trigger: 'hover',
placement: 'right',
},
{
trigger: () =>
h('div', { style: 'cursor: pointer;' }, [h(TheIcon, { icon: 'carbon:data-view' })]),
default: () =>
h(
'pre',
{
style:
'max-height: 400px; overflow: auto; background-color: #f5f5f5; padding: 8px; border-radius: 4px;',
},
formatJSON(row.response_body)
),
}
)
},
},
{
title: '响应时间(s)',
key: 'response_time',
@ -203,7 +270,7 @@ const columns = [
@keypress.enter="$table?.handleSearch()"
/>
</QueryBarItem>
<QueryBarItem label="创建时间" :label-width="70">
<QueryBarItem label="操作时间" :label-width="70">
<NDatePicker
v-model:value="datetimeRange"
type="datetimerange"