@ -0,0 +1 @@ |
||||
web/node_modules |
@ -0,0 +1 @@ |
||||
*.html linguist-language=python |
@ -0,0 +1,16 @@ |
||||
__pycache__/ |
||||
.idea/ |
||||
venv/ |
||||
.mypy_cache/ |
||||
.vscode |
||||
.ruff_cache/ |
||||
.pytest_cache/ |
||||
migrations/ |
||||
|
||||
db.sqlite3 |
||||
db.sqlite3-journal |
||||
db.sqlite3-shm |
||||
db.sqlite3-wal |
||||
|
||||
.DS_Store |
||||
._.DS_Store |
@ -0,0 +1,33 @@ |
||||
FROM node:18.12.0-alpine3.16 AS web |
||||
|
||||
WORKDIR /opt/vue-fastapi-admin |
||||
COPY /web ./web |
||||
RUN cd /opt/vue-fastapi-admin/web && npm i --registry=https://registry.npmmirror.com && npm run build |
||||
|
||||
|
||||
FROM python:3.11-slim-bullseye |
||||
|
||||
WORKDIR /opt/vue-fastapi-admin |
||||
ADD . . |
||||
COPY /deploy/entrypoint.sh . |
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core-apt \ |
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked,id=core-apt \ |
||||
sed -i "s@http://.*.debian.org@http://mirrors.ustc.edu.cn@g" /etc/apt/sources.list \ |
||||
&& rm -f /etc/apt/apt.conf.d/docker-clean \ |
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ |
||||
&& echo "Asia/Shanghai" > /etc/timezone \ |
||||
&& apt-get update \ |
||||
&& apt-get install -y --no-install-recommends gcc python3-dev bash nginx vim curl procps net-tools |
||||
|
||||
RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple |
||||
|
||||
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 |
||||
RUN rm -f /etc/nginx/sites-enabled/default \ |
||||
&& ln -s /etc/nginx/sites-available/web.conf /etc/nginx/sites-enabled/ |
||||
|
||||
ENV LANG=zh_CN.UTF-8 |
||||
EXPOSE 80 |
||||
|
||||
ENTRYPOINT [ "sh", "entrypoint.sh" ] |
@ -0,0 +1,21 @@ |
||||
MIT License |
||||
|
||||
Copyright (c) 2023 mizhexiaoxiao |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
@ -0,0 +1,87 @@ |
||||
# Build configuration
|
||||
# -------------------
|
||||
|
||||
APP_NAME := `sed -n 's/^ *name.*=.*"\([^"]*\)".*/\1/p' pyproject.toml`
|
||||
APP_VERSION := `sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml`
|
||||
GIT_REVISION = `git rev-parse HEAD`
|
||||
|
||||
# Introspection targets
|
||||
# ---------------------
|
||||
|
||||
.PHONY: help |
||||
help: header targets |
||||
|
||||
.PHONY: header |
||||
header: |
||||
@echo "\033[34mEnvironment\033[0m"
|
||||
@echo "\033[34m---------------------------------------------------------------\033[0m"
|
||||
@printf "\033[33m%-23s\033[0m" "APP_NAME"
|
||||
@printf "\033[35m%s\033[0m" $(APP_NAME)
|
||||
@echo ""
|
||||
@printf "\033[33m%-23s\033[0m" "APP_VERSION"
|
||||
@printf "\033[35m%s\033[0m" $(APP_VERSION)
|
||||
@echo ""
|
||||
@printf "\033[33m%-23s\033[0m" "GIT_REVISION"
|
||||
@printf "\033[35m%s\033[0m" $(GIT_REVISION)
|
||||
@echo "\n"
|
||||
|
||||
.PHONY: targets |
||||
targets: |
||||
@echo "\033[34mDevelopment Targets\033[0m"
|
||||
@echo "\033[34m---------------------------------------------------------------\033[0m"
|
||||
@perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# Development targets
|
||||
# -------------
|
||||
|
||||
.PHONY: install |
||||
install: ## Install dependencies
|
||||
uv add pyproject.toml
|
||||
|
||||
|
||||
.PHONY: run |
||||
run: start |
||||
|
||||
.PHONY: start |
||||
start: ## Starts the server
|
||||
python run.py
|
||||
|
||||
# Check, lint and format targets
|
||||
# ------------------------------
|
||||
|
||||
.PHONY: check |
||||
check: check-format lint |
||||
|
||||
.PHONY: check-format |
||||
check-format: ## Dry-run code formatter
|
||||
black ./ --check
|
||||
isort ./ --profile black --check
|
||||
|
||||
.PHONY: lint |
||||
lint: ## Run ruff
|
||||
ruff check ./app
|
||||
|
||||
.PHONY: format |
||||
format: ## Run code formatter
|
||||
black ./
|
||||
isort ./ --profile black
|
||||
|
||||
|
||||
.PHONY: test |
||||
test: ## Run the test suite
|
||||
$(eval include .env)
|
||||
$(eval export $(sh sed 's/=.*//' .env))
|
||||
pytest -vv -s --cache-clear ./
|
||||
|
||||
.PHONY: clean-db |
||||
clean-db: ## 删除migrations文件夹和db.sqlite3
|
||||
find . -type d -name "migrations" -exec rm -rf {} +
|
||||
rm -f db.sqlite3 db.sqlite3-shm db.sqlite3-wal
|
||||
|
||||
.PHONY: migrate |
||||
migrate: ## 运行aerich migrate命令生成迁移文件
|
||||
aerich migrate
|
||||
|
||||
.PHONY: upgrade |
||||
upgrade: ## 运行aerich upgrade命令应用迁移
|
||||
aerich upgrade
|
@ -0,0 +1,219 @@ |
||||
<p align="center"> |
||||
<a href="https://github.com/mizhexiaoxiao/vue-fastapi-admin"> |
||||
<img alt="Vue FastAPI Admin Logo" width="200" src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/logo.svg"> |
||||
</a> |
||||
</p> |
||||
|
||||
<h1 align="center">vue-fastapi-admin</h1> |
||||
|
||||
English | [简体中文](./README.md) |
||||
|
||||
vue-fastapi-admin is a modern front-end and back-end separation development platform that combines FastAPI, Vue3, and Naive UI. It incorporates RBAC (Role-Based Access Control) management, dynamic routing, and JWT (JSON Web Token) authentication, making it ideal for rapid development of small to medium-sized applications and also serves as a valuable learning resource. |
||||
|
||||
### Features |
||||
- **Popular Tech Stack**: The backend is developed with the high-performance asynchronous framework FastAPI using Python 3.11, while the front-end is powered by cutting-edge technologies such as Vue3 and Vite, complemented by the efficient package manager, pnpm. |
||||
- **Code Standards**: The project is equipped with various plugins for code standardization and quality control, ensuring consistency and enhancing team collaboration efficiency. |
||||
- **Dynamic Routing**: Backend dynamic routing combined with the RBAC model allows for fine-grained control of menus and routing. |
||||
- **JWT Authentication**: User identity verification and authorization are handled through JWT, enhancing the application's security. |
||||
- **Granular Permission Control**: Implements detailed permission management including button and interface level controls, ensuring different roles and users have appropriate permissions. |
||||
|
||||
### Live Demo |
||||
- URL: http://139.9.100.77:9999 |
||||
- Username: admin |
||||
- Password: 123456 |
||||
|
||||
### Screenshots |
||||
|
||||
#### Login Page |
||||
 |
||||
|
||||
#### Workbench |
||||
 |
||||
|
||||
#### User Management |
||||
 |
||||
|
||||
#### Role Management |
||||
 |
||||
|
||||
#### Menu Management |
||||
 |
||||
|
||||
#### API Management |
||||
 |
||||
|
||||
### Quick Start |
||||
Please follow the instructions below for installation and configuration: |
||||
|
||||
#### Method 1:dockerhub pull image |
||||
|
||||
```sh |
||||
docker pull mizhexiaoxiao/vue-fastapi-admin:latest |
||||
docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 mizhexiaoxiao/vue-fastapi-admin |
||||
``` |
||||
|
||||
#### Method 2: Build Image Using Dockerfile |
||||
##### Install Docker |
||||
|
||||
```sh |
||||
yum install -y docker-ce |
||||
systemctl start docker |
||||
``` |
||||
|
||||
##### Build the Image |
||||
|
||||
```sh |
||||
git clone https://github.com/mizhexiaoxiao/vue-fastapi-admin.git |
||||
cd vue-fastapi-admin |
||||
docker build --no-cache . -t vue-fastapi-admin |
||||
``` |
||||
|
||||
##### Start the Container |
||||
|
||||
```sh |
||||
docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 vue-fastapi-admin |
||||
``` |
||||
|
||||
##### Access the Service |
||||
|
||||
http://localhost:9999 |
||||
|
||||
username:admin |
||||
|
||||
password:123456 |
||||
|
||||
### Local Setup |
||||
#### Backend |
||||
The backend service requires the following environment: |
||||
- Python 3.11 |
||||
|
||||
#### 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 |
||||
python3 -m venv venv |
||||
source venv/bin/activate # Linux/Mac |
||||
# or |
||||
.\venv\Scripts\activate # Windows |
||||
``` |
||||
|
||||
2. Install project dependencies: |
||||
```sh |
||||
pip install -r requirements.txt |
||||
``` |
||||
|
||||
3. Start the backend service: |
||||
```sh |
||||
python run.py |
||||
``` |
||||
The backend service is now running, and you can visit http://localhost:9999/docs to view the API documentation. |
||||
|
||||
#### Frontend |
||||
The frontend project requires a Node.js environment (recommended version 18.8.0 or higher). |
||||
- node v18.8.0+ |
||||
|
||||
1. Navigate to the frontend project directory: |
||||
```sh |
||||
cd web |
||||
``` |
||||
|
||||
2. Install project dependencies (pnpm is recommended: https://pnpm.io/zh/installation) |
||||
```sh |
||||
npm i -g pnpm # If pnpm is already installed, skip this step |
||||
pnpm i # Or use npm i |
||||
``` |
||||
|
||||
3. Start the frontend development server: |
||||
```sh |
||||
pnpm dev |
||||
``` |
||||
|
||||
### Directory Structure Explanation |
||||
|
||||
``` |
||||
├── app // Application directory |
||||
│ ├── api // API interface directory |
||||
│ │ └── v1 // Version 1 of the API interfaces |
||||
│ │ ├── apis // API-related interfaces |
||||
│ │ ├── base // Base information interfaces |
||||
│ │ ├── menus // Menu related interfaces |
||||
│ │ ├── roles // Role related interfaces |
||||
│ │ └── users // User related interfaces |
||||
│ ├── controllers // Controllers directory |
||||
│ ├── core // Core functionality module |
||||
│ ├── log // Log directory |
||||
│ ├── models // Data models directory |
||||
│ ├── schemas // Data schema/structure definitions |
||||
│ ├── settings // Configuration settings directory |
||||
│ └── utils // Utilities directory |
||||
├── deploy // Deployment related directory |
||||
│ └── sample-picture // Sample picture directory |
||||
└── web // Front-end web directory |
||||
├── build // Build scripts and configuration directory |
||||
│ ├── config // Build configurations |
||||
│ ├── plugin // Build plugins |
||||
│ └── script // Build scripts |
||||
├── public // Public resources directory |
||||
│ └── resource // Public resource files |
||||
├── settings // Front-end project settings |
||||
└── src // Source code directory |
||||
├── api // API interface definitions |
||||
├── assets // Static resources directory |
||||
│ ├── images // Image resources |
||||
│ ├── js // JavaScript files |
||||
│ └── svg // SVG vector files |
||||
├── components // Components directory |
||||
│ ├── common // Common components |
||||
│ ├── icon // Icon components |
||||
│ ├── page // Page components |
||||
│ ├── query-bar // Query bar components |
||||
│ └── table // Table components |
||||
├── composables // Composable functionalities |
||||
├── directives // Directives directory |
||||
├── layout // Layout directory |
||||
│ └── components // Layout components |
||||
├── router // Routing directory |
||||
│ ├── guard // Route guards |
||||
│ └── routes // Route definitions |
||||
├── store // State management (pinia) |
||||
│ └── modules // State modules |
||||
├── styles // Style files directory |
||||
├── utils // Utilities directory |
||||
│ ├── auth // Authentication related utilities |
||||
│ ├── common // Common utilities |
||||
│ ├── http // Encapsulated axios |
||||
│ └── storage // Encapsulated localStorage and sessionStorage |
||||
└── views // Views/Pages directory |
||||
├── error-page // Error pages |
||||
├── login // Login page |
||||
├── profile // Profile page |
||||
├── system // System management page |
||||
└── workbench // Workbench page |
||||
``` |
||||
|
||||
### Visitors Count |
||||
|
||||
<img align="left" src = "https://profile-counter.glitch.me/vue-fastapi-admin/count.svg" alt="Loading"> |
@ -0,0 +1,244 @@ |
||||
<p align="center"> |
||||
<a href="https://github.com/mizhexiaoxiao/vue-fastapi-admin"> |
||||
<img alt="Vue FastAPI Admin Logo" width="200" src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/logo.svg"> |
||||
</a> |
||||
</p> |
||||
|
||||
<h1 align="center">vue-fastapi-admin</h1> |
||||
|
||||
[English](./README-en.md) | 简体中文 |
||||
|
||||
基于 FastAPI + Vue3 + Naive UI 的现代化前后端分离开发平台,融合了 RBAC 权限管理、动态路由和 JWT 鉴权,助力中小型应用快速搭建,也可用于学习参考。 |
||||
|
||||
### 特性 |
||||
- **最流行技术栈**:基于 Python 3.11 和 FastAPI 高性能异步框架,结合 Vue3 和 Vite 等前沿技术进行开发,同时使用高效的 npm 包管理器 pnpm。 |
||||
- **代码规范**:项目内置丰富的规范插件,确保代码质量和一致性,有效提高团队协作效率。 |
||||
- **动态路由**:后端动态路由,结合 RBAC(Role-Based Access Control)权限模型,提供精细的菜单路由控制。 |
||||
- **JWT鉴权**:使用 JSON Web Token(JWT)进行身份验证和授权,增强应用的安全性。 |
||||
- **细粒度权限控制**:实现按钮和接口级别的权限控制,确保不同用户或角色在界面操作和接口访问时具有不同的权限限制。 |
||||
|
||||
### 在线预览 |
||||
- [http://47.111.145.81:3000](http://47.111.145.81:3000) |
||||
- username: admin |
||||
- password: 123456 |
||||
|
||||
### 登录页 |
||||
|
||||
 |
||||
### 工作台 |
||||
|
||||
 |
||||
|
||||
### 用户管理 |
||||
|
||||
 |
||||
### 角色管理 |
||||
|
||||
 |
||||
|
||||
### 菜单管理 |
||||
|
||||
 |
||||
|
||||
### API管理 |
||||
|
||||
 |
||||
|
||||
### 快速开始 |
||||
#### 方法一:dockerhub拉取镜像 |
||||
|
||||
```sh |
||||
docker pull mizhexiaoxiao/vue-fastapi-admin:latest |
||||
docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 mizhexiaoxiao/vue-fastapi-admin |
||||
``` |
||||
|
||||
#### 方法二:dockerfile构建镜像 |
||||
##### docker安装(版本17.05+) |
||||
|
||||
```sh |
||||
yum install -y docker-ce |
||||
systemctl start docker |
||||
``` |
||||
|
||||
##### 构建镜像 |
||||
|
||||
```sh |
||||
git clone https://github.com/mizhexiaoxiao/vue-fastapi-admin.git |
||||
cd vue-fastapi-admin |
||||
docker build --no-cache . -t vue-fastapi-admin |
||||
``` |
||||
|
||||
##### 启动容器 |
||||
|
||||
```sh |
||||
docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 vue-fastapi-admin |
||||
``` |
||||
|
||||
##### 访问 |
||||
|
||||
http://localhost:9999 |
||||
|
||||
username:admin |
||||
|
||||
password:123456 |
||||
|
||||
### 本地启动 |
||||
#### 后端 |
||||
启动项目需要以下环境: |
||||
- Python 3.11 |
||||
|
||||
#### 方法一(推荐):使用 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 |
||||
python3 -m venv venv |
||||
``` |
||||
|
||||
2. 激活虚拟环境 |
||||
```sh |
||||
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 |
||||
``` |
||||
|
||||
4. 启动服务 |
||||
```sh |
||||
python run.py |
||||
``` |
||||
|
||||
服务现在应该正在运行,访问 http://localhost:9999/docs 查看API文档 |
||||
|
||||
#### 前端 |
||||
启动项目需要以下环境: |
||||
- node v18.8.0+ |
||||
|
||||
1. 进入前端目录 |
||||
```sh |
||||
cd web |
||||
``` |
||||
|
||||
2. 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation) |
||||
```sh |
||||
npm i -g pnpm # 已安装可忽略 |
||||
pnpm i # 或者 npm i |
||||
``` |
||||
|
||||
3. 启动 |
||||
```sh |
||||
pnpm dev |
||||
``` |
||||
|
||||
### 目录说明 |
||||
|
||||
``` |
||||
├── app // 应用程序目录 |
||||
│ ├── api // API接口目录 |
||||
│ │ └── v1 // 版本1的API接口 |
||||
│ │ ├── apis // API相关接口 |
||||
│ │ ├── base // 基础信息接口 |
||||
│ │ ├── menus // 菜单相关接口 |
||||
│ │ ├── roles // 角色相关接口 |
||||
│ │ └── users // 用户相关接口 |
||||
│ ├── controllers // 控制器目录 |
||||
│ ├── core // 核心功能模块 |
||||
│ ├── log // 日志目录 |
||||
│ ├── models // 数据模型目录 |
||||
│ ├── schemas // 数据模式/结构定义 |
||||
│ ├── settings // 配置设置目录 |
||||
│ └── utils // 工具类目录 |
||||
├── deploy // 部署相关目录 |
||||
│ └── sample-picture // 示例图片目录 |
||||
└── web // 前端网页目录 |
||||
├── build // 构建脚本和配置目录 |
||||
│ ├── config // 构建配置 |
||||
│ ├── plugin // 构建插件 |
||||
│ └── script // 构建脚本 |
||||
├── public // 公共资源目录 |
||||
│ └── resource // 公共资源文件 |
||||
├── settings // 前端项目配置 |
||||
└── src // 源代码目录 |
||||
├── api // API接口定义 |
||||
├── assets // 静态资源目录 |
||||
│ ├── images // 图片资源 |
||||
│ ├── js // JavaScript文件 |
||||
│ └── svg // SVG矢量图文件 |
||||
├── components // 组件目录 |
||||
│ ├── common // 通用组件 |
||||
│ ├── icon // 图标组件 |
||||
│ ├── page // 页面组件 |
||||
│ ├── query-bar // 查询栏组件 |
||||
│ └── table // 表格组件 |
||||
├── composables // 可组合式功能块 |
||||
├── directives // 指令目录 |
||||
├── layout // 布局目录 |
||||
│ └── components // 布局组件 |
||||
├── router // 路由目录 |
||||
│ ├── guard // 路由守卫 |
||||
│ └── routes // 路由定义 |
||||
├── store // 状态管理(pinia) |
||||
│ └── modules // 状态模块 |
||||
├── styles // 样式文件目录 |
||||
├── utils // 工具类目录 |
||||
│ ├── auth // 认证相关工具 |
||||
│ ├── common // 通用工具 |
||||
│ ├── http // 封装axios |
||||
│ └── storage // 封装localStorage和sessionStorage |
||||
└── views // 视图/页面目录 |
||||
├── error-page // 错误页面 |
||||
├── login // 登录页面 |
||||
├── profile // 个人资料页面 |
||||
├── system // 系统管理页面 |
||||
└── workbench // 工作台页面 |
||||
``` |
||||
|
||||
### 进群交流 |
||||
进群的条件是给项目一个star,小小的star是作者维护下去的动力。 |
||||
|
||||
你可以在群里提出任何疑问,我会尽快回复答疑。 |
||||
|
||||
<img width="300" src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/group.jpg"> |
||||
|
||||
## 打赏 |
||||
如果项目有帮助到你,可以请作者喝杯咖啡~ |
||||
|
||||
<div style="display: flex"> |
||||
<img src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/1.jpg" width="300"> |
||||
<img src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/2.jpg" width="300"> |
||||
</div> |
||||
|
||||
## 定制开发 |
||||
如果有基于该项目的定制需求或其他合作,请添加下方微信,备注来意 |
||||
|
||||
<img width="300" src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/3.jpg"> |
||||
|
||||
### Visitors Count |
||||
|
||||
<img align="left" src = "https://profile-counter.glitch.me/vue-fastapi-admin/count.svg" alt="Loading"> |
@ -0,0 +1,61 @@ |
||||
from contextlib import asynccontextmanager |
||||
from fastapi import FastAPI |
||||
from tortoise import Tortoise |
||||
from redis.asyncio import Redis |
||||
|
||||
from app.core.exceptions import SettingNotFound |
||||
from app.core.init_app import ( |
||||
init_data, |
||||
make_middlewares, |
||||
register_exceptions, |
||||
register_routers, |
||||
) |
||||
|
||||
try: |
||||
from app.settings.config import settings |
||||
except ImportError: |
||||
raise SettingNotFound("Can not import settings") |
||||
|
||||
|
||||
@asynccontextmanager |
||||
async def lifespan(app: FastAPI): |
||||
try: |
||||
# 初始化 Redis 连接 |
||||
app.state.redis_client = Redis( |
||||
host=settings.REDIS_HOST, |
||||
port=settings.REDIS_PORT, |
||||
db=settings.REDIS_DB, |
||||
decode_responses=True, |
||||
password=settings.REDIS_PASSWORD |
||||
) |
||||
# 测试 Redis 连接 |
||||
await app.state.redis_client.ping() |
||||
print("✅ Redis 连接成功") |
||||
except Exception as e: |
||||
print(f"❌ Redis 连接失败: {e}") |
||||
# 执行其他初始化操作 |
||||
await init_data() |
||||
yield |
||||
# 关闭 Redis 连接 |
||||
if hasattr(app.state, 'redis_client'): |
||||
await app.state.redis_client.close() |
||||
print("🛑 Redis 连接关闭") |
||||
# 关闭 Tortoise 连接 |
||||
await Tortoise.close_connections() |
||||
|
||||
|
||||
def create_app() -> FastAPI: |
||||
app = FastAPI( |
||||
title=settings.APP_TITLE, |
||||
description=settings.APP_DESCRIPTION, |
||||
version=settings.VERSION, |
||||
openapi_url="/openapi.json", |
||||
middleware=make_middlewares(), |
||||
lifespan=lifespan, |
||||
) |
||||
register_exceptions(app) |
||||
register_routers(app, prefix="/api") |
||||
return app |
||||
|
||||
|
||||
app = create_app() |
@ -0,0 +1,11 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .chat import chat_router |
||||
from .v1 import v1_router |
||||
|
||||
api_router = APIRouter() |
||||
api_router.include_router(v1_router, prefix="/v1") |
||||
api_router.include_router(chat_router, prefix="") |
||||
|
||||
|
||||
__all__ = ["api_router"] |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from app.core.dependency import DependPermission |
||||
from .ai import ai_api_router |
||||
|
||||
chat_router = APIRouter() |
||||
chat_router.include_router(ai_api_router, prefix="/model") |
||||
|
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .chat_router import router |
||||
|
||||
ai_api_router = APIRouter() |
||||
ai_api_router.include_router(router, tags=["ai对话接口模块"]) |
||||
|
||||
__all__ = ["ai_api_router"] |
@ -0,0 +1,104 @@ |
||||
from fastapi import APIRouter, HTTPException |
||||
|
||||
from starlette.requests import Request |
||||
from fastapi.responses import StreamingResponse |
||||
from typing import AsyncGenerator |
||||
from .chat_service import classify, extract_spot, query_flow, gen_markdown_stream, ai_chat_stream |
||||
from app.models.ChatIn import ChatIn |
||||
import hmac |
||||
import hashlib |
||||
import time |
||||
import json |
||||
from pydantic import BaseModel |
||||
from app.settings.config import settings |
||||
|
||||
router = APIRouter() |
||||
|
||||
SECRET_KEY = settings.SIGN_KEY # 约定的密钥 |
||||
TIMESTAMP_TOLERANCE = 60 # 时间戳容忍度,单位:秒 |
||||
CONVERSATION_EXPIRE_TIME = 600 # 对话历史过期时间,单位:秒 |
||||
|
||||
@router.post("/chat", summary="ai对话") |
||||
async def h5_chat_stream(request: Request, inp: ChatIn): |
||||
if inp.sign is None: |
||||
raise HTTPException(status_code=401, detail="缺少签名参数") |
||||
|
||||
if not verify_signature(inp.dict(), inp.sign): |
||||
raise HTTPException(status_code=401, detail="无效的签名") |
||||
|
||||
if not verify_timestamp(inp.timestamp): |
||||
raise HTTPException(status_code=401, detail="时间戳无效") |
||||
|
||||
if inp.language is None: |
||||
inp.language = "zh_cn" |
||||
|
||||
# 获取用户 ID |
||||
user_id = inp.user_id |
||||
if not user_id: |
||||
raise HTTPException(status_code=400, detail="缺少用户 ID") |
||||
|
||||
# 从 Redis 中获取用户的对话历史 |
||||
redis_client = request.app.state.redis_client |
||||
conversation_history_key = f"conversation:{user_id}" |
||||
conversation_history_str = await redis_client.get(conversation_history_key) |
||||
conversation_history = json.loads(conversation_history_str) if conversation_history_str else [] |
||||
|
||||
# 分类阶段(保留同步调用) |
||||
cat = await classify(inp.message) |
||||
|
||||
async def content_stream() -> AsyncGenerator[str, None]: |
||||
nonlocal conversation_history |
||||
try: |
||||
if cat == "游玩判断": |
||||
spot = await extract_spot(inp.message) |
||||
data = await query_flow(request, spot) |
||||
async for chunk in gen_markdown_stream(inp.message, data, inp.language, conversation_history): |
||||
yield chunk |
||||
else: |
||||
async for chunk in ai_chat_stream(inp, conversation_history): |
||||
yield chunk |
||||
|
||||
# 将更新后的对话历史存回 Redis,并设置过期时间 |
||||
await redis_client.setex(conversation_history_key, CONVERSATION_EXPIRE_TIME, json.dumps(conversation_history)) |
||||
except Exception as e: |
||||
print(f"Error in content_stream: {e}") |
||||
raise |
||||
|
||||
try: |
||||
return StreamingResponse( |
||||
content_stream(), |
||||
media_type="text/event-stream", |
||||
headers={ |
||||
"Cache-Control": "no-cache", |
||||
"Connection": "keep-alive", |
||||
"X-Accel-Buffering": "no" |
||||
} |
||||
) |
||||
except Exception as e: |
||||
print(f"Error in StreamingResponse: {e}") |
||||
raise HTTPException(status_code=500, detail="Internal Server Error") |
||||
|
||||
class ClearConversationRequest(BaseModel): |
||||
user_id: int |
||||
|
||||
@router.post("/clear_conversation", summary="清除对话记录") |
||||
async def clear_conversation(request: Request, body: ClearConversationRequest): |
||||
user_id = body.user_id |
||||
if not user_id: |
||||
raise HTTPException(status_code=400, detail="缺少用户 ID") |
||||
|
||||
redis_client = request.app.state.redis_client |
||||
conversation_history_key = f"conversation:{user_id}" |
||||
await redis_client.delete(conversation_history_key) |
||||
return {"message": "对话历史已清除"} |
||||
|
||||
def verify_signature(data: dict, sign: str) -> bool: |
||||
sorted_keys = sorted(data.keys()) |
||||
sign_str = '&'.join([f'{key}={data[key]}' for key in sorted_keys if key != 'sign']) |
||||
sign_str += f'&secret={SECRET_KEY}' |
||||
calculated_sign = hmac.new(SECRET_KEY.encode(), sign_str.encode(), hashlib.sha256).hexdigest() |
||||
return calculated_sign == sign |
||||
|
||||
def verify_timestamp(timestamp: int) -> bool: |
||||
current_timestamp = int(time.time() * 1000) |
||||
return abs(current_timestamp - timestamp) <= TIMESTAMP_TOLERANCE * 1000 |
@ -0,0 +1,161 @@ |
||||
import os, openai, aiomysql |
||||
from openai import OpenAI |
||||
from dotenv import load_dotenv |
||||
import redis.asyncio as redis |
||||
from typing import AsyncGenerator |
||||
|
||||
from app.models.ChatIn import ChatIn |
||||
from fastapi import Request |
||||
from app.settings.config import settings |
||||
|
||||
load_dotenv() |
||||
|
||||
client = OpenAI(api_key=settings.DEEPSEEK_API_KEY, base_url=settings.DEEPSEEK_API_URL) |
||||
|
||||
CATEGORY_PROMPT = """你是一个分类助手,请根据用户的问题判断属于哪一类: |
||||
1. 如果用户是问“某个保定市景区现在适不适合去”,或者“某个保定市景区现在人多么”此类涉及某个景区人数或者客流量的,注意只有保定的景区,请返回:游玩判断。 |
||||
2. 其他均返回保定文旅。 |
||||
只能返回以上两个分类词之一,不能多说话,不回复其他多余内容。""" |
||||
EXTRACT_PROMPT = """你是一个中文旅游助手,请从用户的问题中提取出景区名称,仅返回景区名,不要多余文字;如果没有提到具体景区,返回空字符串。""" |
||||
|
||||
async def classify(msg: str) -> str: |
||||
print(f"Starting classification for message: {msg}") |
||||
try: |
||||
response = client.chat.completions.create( |
||||
model="deepseek-chat", |
||||
messages=[{"role": "system", "content": CATEGORY_PROMPT}, {"role": "user", "content": msg}] |
||||
) |
||||
result = response.choices[0].message.content |
||||
print(f"Classification result: {result}") |
||||
return result |
||||
except Exception as e: |
||||
print(f"Error in classification: {e}") |
||||
raise |
||||
|
||||
async def ai_chat_stream(inp: ChatIn, conversation_history: list) -> AsyncGenerator[str, None]: |
||||
chat_prompt = ( |
||||
f"你是一个河北省保定市的旅游助手,为游客提供保定市文旅相关问答服务,包括但不限于行程规划、" |
||||
f"景点推荐等,不提供其他任何非保定文旅相关信息。输出语言为:{inp.language}" |
||||
) |
||||
messages = [{"role": "system", "content": chat_prompt}] + conversation_history |
||||
messages.append({"role": "user", "content": inp.message}) |
||||
|
||||
print(f"Starting AI chat stream with input: {inp.message}") |
||||
try: |
||||
response = client.chat.completions.create( |
||||
model="deepseek-chat", |
||||
messages=messages, |
||||
stream=True |
||||
) |
||||
full_response = "" |
||||
for chunk in response: |
||||
delta = chunk.choices[0].delta |
||||
if hasattr(delta, "content") and delta.content: |
||||
full_response += delta.content |
||||
# 直接输出纯文本内容,无SSE格式 |
||||
yield delta.content |
||||
# 保留缓冲区刷新 |
||||
import sys |
||||
sys.stdout.flush() |
||||
# 可选:添加结束标记(如需要) |
||||
# yield "[STREAM_END]" |
||||
except Exception as e: |
||||
print(f"Error in AI chat stream: {e}") |
||||
raise |
||||
|
||||
# 将 AI 的回复添加到对话历史中 |
||||
conversation_history.append({"role": "assistant", "content": full_response}) |
||||
print("AI chat stream finished.") |
||||
|
||||
async def extract_spot(msg: str) -> str: |
||||
print(f"Starting spot extraction for message: {msg}") |
||||
try: |
||||
response = client.chat.completions.create( |
||||
model="deepseek-chat", |
||||
messages=[{"role": "system", "content": EXTRACT_PROMPT}, {"role": "user", "content": msg}] |
||||
) |
||||
result = response.choices[0].message.content |
||||
print(f"Extracted spot: {result}") |
||||
return result |
||||
except Exception as e: |
||||
print(f"Error in spot extraction: {e}") |
||||
raise |
||||
|
||||
async def query_flow(request: Request, spot: str) -> str: |
||||
if not spot: |
||||
print("No spot found, returning default message.") |
||||
return "**未找到景区信息,请检查名称是否正确。\n\n(内容由AI生成,仅供参考)" |
||||
|
||||
cache_key = f"flow:{spot}" |
||||
|
||||
# Step 1: Redis 缓存查询 |
||||
print(f"Querying Redis cache for key: {cache_key}") |
||||
try: |
||||
redis_client = request.app.state.redis_client |
||||
cached = await redis_client.get(cache_key) |
||||
if cached: |
||||
print(f"Found cached data for key: {cache_key}") |
||||
return cached |
||||
except Exception as e: |
||||
print(f"[Redis] 查询缓存失败: {e}") |
||||
|
||||
# Step 2: MySQL 查询(使用连接池) |
||||
print(f"Querying MySQL for spot: {spot}") |
||||
try: |
||||
pool = request.app.state.mysql_pool |
||||
async with pool.acquire() as conn: |
||||
async with conn.cursor() as cur: |
||||
query = "SELECT SUM(t1.init_num) AS init_num, SUM(t1.out_num) AS out_num FROM equipment_passenger_flow.flow_current_video t1 LEFT JOIN cyjcpt_bd.zhly_video_manage t2 ON t1.mac_address = t2.mac_address LEFT JOIN cyjcpt_bd.zhly_scenic_basic t3 ON t2.video_scenic_id = t3.id WHERE t3.`name` LIKE %s" |
||||
search_spot = f"%{spot}%" |
||||
await cur.execute(query, (search_spot,)) |
||||
row = await cur.fetchone() |
||||
except Exception as e: |
||||
print(f"[MySQL] 查询失败: {e}") |
||||
return f"**未找到景区【{spot}】的信息,请检查名称是否正确。\n\n(内容由AI生成,仅供参考)" |
||||
print("数据库查询结果", row) |
||||
if not row: |
||||
print(f"No data found for spot: {spot}") |
||||
return f"**未找到景区【{spot}】的信息,请检查名称是否正确。\n\n(内容由AI生成,仅供参考)" |
||||
|
||||
# 修改结果拼接部分,使用新的列名 |
||||
result = f"**{spot} 客流**\n\n进入人数: {row[0]}\n离开人数: {row[1]}\n\n(内容由AI生成,仅供参考)" |
||||
|
||||
# Step 3: 写入 Redis 缓存 |
||||
print(f"Writing data to Redis cache for key: {cache_key}") |
||||
try: |
||||
await redis_client.setex(cache_key, 120, result) |
||||
except Exception as e: |
||||
print(f"[Redis] 写缓存失败: {e}") |
||||
|
||||
return result |
||||
|
||||
async def gen_markdown_stream(msg: str, data: str, language: str, conversation_history: list) -> AsyncGenerator[str, None]: |
||||
prompt = f"用户问题:{msg}\n查询到的内容为:{data}\n请结合用户问题和查询到的内容,自行优化回复内容,如果内容格式允许的话加上markdown语法。输出语言为:{language}" |
||||
messages = conversation_history + [{"role": "user", "content": prompt}] |
||||
|
||||
print(f"Starting markdown stream with message: {msg} and data: {data}") |
||||
try: |
||||
response = client.chat.completions.create( |
||||
model="deepseek-chat", |
||||
messages=messages, |
||||
stream=True |
||||
) |
||||
full_response = "" |
||||
for chunk in response: |
||||
delta = chunk.choices[0].delta |
||||
if hasattr(delta, "content") and delta.content: |
||||
full_response += delta.content |
||||
# 删除SSE格式封装,改为纯文本输出 |
||||
yield delta.content |
||||
# 添加缓冲区刷新 |
||||
import sys |
||||
sys.stdout.flush() |
||||
# 删除SSE结束标记 |
||||
# yield "data: [DONE]\n\n" |
||||
except Exception as e: |
||||
print(f"Error in markdown stream: {e}") |
||||
raise |
||||
|
||||
# 将 AI 的回复添加到对话历史中 |
||||
conversation_history.append({"role": "assistant", "content": full_response}) |
||||
print("Markdown stream finished.") |
@ -0,0 +1,22 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from app.core.dependency import DependPermission |
||||
|
||||
from .apis import apis_router |
||||
from .auditlog import auditlog_router |
||||
from .base import base_router |
||||
from .depts import depts_router |
||||
from .menus import menus_router |
||||
from .roles import roles_router |
||||
from .users import users_router |
||||
|
||||
v1_router = APIRouter() |
||||
|
||||
v1_router.include_router(base_router, prefix="/base") |
||||
v1_router.include_router(users_router, prefix="/user", dependencies=[DependPermission]) |
||||
v1_router.include_router(roles_router, prefix="/role", dependencies=[DependPermission]) |
||||
v1_router.include_router(menus_router, prefix="/menu", dependencies=[DependPermission]) |
||||
v1_router.include_router(apis_router, prefix="/api", dependencies=[DependPermission]) |
||||
v1_router.include_router(depts_router, prefix="/dept", dependencies=[DependPermission]) |
||||
v1_router.include_router(auditlog_router, prefix="/auditlog", dependencies=[DependPermission]) |
||||
|
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .apis import router |
||||
|
||||
apis_router = APIRouter() |
||||
apis_router.include_router(router, tags=["API模块"]) |
||||
|
||||
__all__ = ["apis_router"] |
@ -0,0 +1,67 @@ |
||||
from fastapi import APIRouter, Query |
||||
from tortoise.expressions import Q |
||||
|
||||
from app.controllers.api import api_controller |
||||
from app.schemas import Success, SuccessExtra |
||||
from app.schemas.apis import * |
||||
|
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.get("/list", summary="查看API列表") |
||||
async def list_api( |
||||
page: int = Query(1, description="页码"), |
||||
page_size: int = Query(10, description="每页数量"), |
||||
path: str = Query(None, description="API路径"), |
||||
summary: str = Query(None, description="API简介"), |
||||
tags: str = Query(None, description="API模块"), |
||||
): |
||||
q = Q() |
||||
if path: |
||||
q &= Q(path__contains=path) |
||||
if summary: |
||||
q &= Q(summary__contains=summary) |
||||
if tags: |
||||
q &= Q(tags__contains=tags) |
||||
total, api_objs = await api_controller.list(page=page, page_size=page_size, search=q, order=["tags", "id"]) |
||||
data = [await obj.to_dict() for obj in api_objs] |
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size) |
||||
|
||||
|
||||
@router.get("/get", summary="查看Api") |
||||
async def get_api( |
||||
id: int = Query(..., description="Api"), |
||||
): |
||||
api_obj = await api_controller.get(id=id) |
||||
data = await api_obj.to_dict() |
||||
return Success(data=data) |
||||
|
||||
|
||||
@router.post("/create", summary="创建Api") |
||||
async def create_api( |
||||
api_in: ApiCreate, |
||||
): |
||||
await api_controller.create(obj_in=api_in) |
||||
return Success(msg="Created Successfully") |
||||
|
||||
|
||||
@router.post("/update", summary="更新Api") |
||||
async def update_api( |
||||
api_in: ApiUpdate, |
||||
): |
||||
await api_controller.update(id=api_in.id, obj_in=api_in) |
||||
return Success(msg="Update Successfully") |
||||
|
||||
|
||||
@router.delete("/delete", summary="删除Api") |
||||
async def delete_api( |
||||
api_id: int = Query(..., description="ApiID"), |
||||
): |
||||
await api_controller.remove(id=api_id) |
||||
return Success(msg="Deleted Success") |
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新API列表") |
||||
async def refresh_api(): |
||||
await api_controller.refresh_api() |
||||
return Success(msg="OK") |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .auditlog import router |
||||
|
||||
auditlog_router = APIRouter() |
||||
auditlog_router.include_router(router, tags=["审计日志模块"]) |
||||
|
||||
__all__ = ["auditlog_router"] |
@ -0,0 +1,48 @@ |
||||
from datetime import datetime |
||||
from fastapi import APIRouter, Query |
||||
from tortoise.expressions import Q |
||||
|
||||
from app.models.admin import AuditLog |
||||
from app.schemas import SuccessExtra |
||||
from app.schemas.apis import * |
||||
|
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.get("/list", summary="查看操作日志") |
||||
async def get_audit_log_list( |
||||
page: int = Query(1, description="页码"), |
||||
page_size: int = Query(10, description="每页数量"), |
||||
username: str = Query("", description="操作人名称"), |
||||
module: str = Query("", description="功能模块"), |
||||
method: str = Query("", description="请求方法"), |
||||
summary: str = Query("", description="接口描述"), |
||||
path: str = Query("", description="请求路径"), |
||||
status: int = Query(None, description="状态码"), |
||||
start_time: datetime = Query("", description="开始时间"), |
||||
end_time: datetime = Query("", description="结束时间"), |
||||
): |
||||
q = Q() |
||||
if username: |
||||
q &= Q(username__icontains=username) |
||||
if module: |
||||
q &= Q(module__icontains=module) |
||||
if method: |
||||
q &= Q(method__icontains=method) |
||||
if summary: |
||||
q &= Q(summary__icontains=summary) |
||||
if path: |
||||
q &= Q(path__icontains=path) |
||||
if status: |
||||
q &= Q(status=status) |
||||
if start_time and end_time: |
||||
q &= Q(created_at__range=[start_time, end_time]) |
||||
elif start_time: |
||||
q &= Q(created_at__gte=start_time) |
||||
elif end_time: |
||||
q &= Q(created_at__lte=end_time) |
||||
|
||||
audit_log_objs = await AuditLog.filter(q).offset((page - 1) * page_size).limit(page_size).order_by("-created_at") |
||||
total = await AuditLog.filter(q).count() |
||||
data = [await audit_log.to_dict() for audit_log in audit_log_objs] |
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size) |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .base import router |
||||
|
||||
base_router = APIRouter() |
||||
base_router.include_router(router, tags=["基础模块"]) |
||||
|
||||
__all__ = ["base_router"] |
@ -0,0 +1,103 @@ |
||||
from datetime import datetime, timedelta, timezone |
||||
|
||||
from fastapi import APIRouter |
||||
|
||||
from app.controllers.user import user_controller |
||||
from app.core.ctx import CTX_USER_ID |
||||
from app.core.dependency import DependAuth |
||||
from app.models.admin import Api, Menu, Role, User |
||||
from app.schemas.base import Fail, Success |
||||
from app.schemas.login import * |
||||
from app.schemas.users import UpdatePassword |
||||
from app.settings import settings |
||||
from app.utils.jwt_utils import create_access_token |
||||
from app.utils.password import get_password_hash, verify_password |
||||
|
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.post("/access_token", summary="获取token") |
||||
async def login_access_token(credentials: CredentialsSchema): |
||||
user: User = await user_controller.authenticate(credentials) |
||||
await user_controller.update_last_login(user.id) |
||||
access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) |
||||
expire = datetime.now(timezone.utc) + access_token_expires |
||||
|
||||
data = JWTOut( |
||||
access_token=create_access_token( |
||||
data=JWTPayload( |
||||
user_id=user.id, |
||||
username=user.username, |
||||
is_superuser=user.is_superuser, |
||||
exp=expire, |
||||
) |
||||
), |
||||
username=user.username, |
||||
) |
||||
return Success(data=data.model_dump()) |
||||
|
||||
|
||||
@router.get("/userinfo", summary="查看用户信息", dependencies=[DependAuth]) |
||||
async def get_userinfo(): |
||||
user_id = CTX_USER_ID.get() |
||||
user_obj = await user_controller.get(id=user_id) |
||||
data = await user_obj.to_dict(exclude_fields=["password"]) |
||||
data["avatar"] = "https://avatars.githubusercontent.com/u/54677442?v=4" |
||||
return Success(data=data) |
||||
|
||||
|
||||
@router.get("/usermenu", summary="查看用户菜单", dependencies=[DependAuth]) |
||||
async def get_user_menu(): |
||||
user_id = CTX_USER_ID.get() |
||||
user_obj = await User.filter(id=user_id).first() |
||||
menus: list[Menu] = [] |
||||
if user_obj.is_superuser: |
||||
menus = await Menu.all() |
||||
else: |
||||
role_objs: list[Role] = await user_obj.roles |
||||
for role_obj in role_objs: |
||||
menu = await role_obj.menus |
||||
menus.extend(menu) |
||||
menus = list(set(menus)) |
||||
parent_menus: list[Menu] = [] |
||||
for menu in menus: |
||||
if menu.parent_id == 0: |
||||
parent_menus.append(menu) |
||||
res = [] |
||||
for parent_menu in parent_menus: |
||||
parent_menu_dict = await parent_menu.to_dict() |
||||
parent_menu_dict["children"] = [] |
||||
for menu in menus: |
||||
if menu.parent_id == parent_menu.id: |
||||
parent_menu_dict["children"].append(await menu.to_dict()) |
||||
res.append(parent_menu_dict) |
||||
return Success(data=res) |
||||
|
||||
|
||||
@router.get("/userapi", summary="查看用户API", dependencies=[DependAuth]) |
||||
async def get_user_api(): |
||||
user_id = CTX_USER_ID.get() |
||||
user_obj = await User.filter(id=user_id).first() |
||||
if user_obj.is_superuser: |
||||
api_objs: list[Api] = await Api.all() |
||||
apis = [api.method.lower() + api.path for api in api_objs] |
||||
return Success(data=apis) |
||||
role_objs: list[Role] = await user_obj.roles |
||||
apis = [] |
||||
for role_obj in role_objs: |
||||
api_objs: list[Api] = await role_obj.apis |
||||
apis.extend([api.method.lower() + api.path for api in api_objs]) |
||||
apis = list(set(apis)) |
||||
return Success(data=apis) |
||||
|
||||
|
||||
@router.post("/update_password", summary="修改密码", dependencies=[DependAuth]) |
||||
async def update_user_password(req_in: UpdatePassword): |
||||
user_id = CTX_USER_ID.get() |
||||
user = await user_controller.get(user_id) |
||||
verified = verify_password(req_in.old_password, user.password) |
||||
if not verified: |
||||
return Fail(msg="旧密码验证错误!") |
||||
user.password = get_password_hash(req_in.new_password) |
||||
await user.save() |
||||
return Success(msg="修改成功") |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .depts import router |
||||
|
||||
depts_router = APIRouter() |
||||
depts_router.include_router(router, tags=["部门模块"]) |
||||
|
||||
__all__ = ["depts_router"] |
@ -0,0 +1,48 @@ |
||||
from fastapi import APIRouter, Query |
||||
|
||||
from app.controllers.dept import dept_controller |
||||
from app.schemas import Success |
||||
from app.schemas.depts import * |
||||
|
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.get("/list", summary="查看部门列表") |
||||
async def list_dept( |
||||
name: str = Query(None, description="部门名称"), |
||||
): |
||||
dept_tree = await dept_controller.get_dept_tree(name) |
||||
return Success(data=dept_tree) |
||||
|
||||
|
||||
@router.get("/get", summary="查看部门") |
||||
async def get_dept( |
||||
id: int = Query(..., description="部门ID"), |
||||
): |
||||
dept_obj = await dept_controller.get(id=id) |
||||
data = await dept_obj.to_dict() |
||||
return Success(data=data) |
||||
|
||||
|
||||
@router.post("/create", summary="创建部门") |
||||
async def create_dept( |
||||
dept_in: DeptCreate, |
||||
): |
||||
await dept_controller.create_dept(obj_in=dept_in) |
||||
return Success(msg="Created Successfully") |
||||
|
||||
|
||||
@router.post("/update", summary="更新部门") |
||||
async def update_dept( |
||||
dept_in: DeptUpdate, |
||||
): |
||||
await dept_controller.update_dept(obj_in=dept_in) |
||||
return Success(msg="Update Successfully") |
||||
|
||||
|
||||
@router.delete("/delete", summary="删除部门") |
||||
async def delete_dept( |
||||
dept_id: int = Query(..., description="部门ID"), |
||||
): |
||||
await dept_controller.delete_dept(dept_id=dept_id) |
||||
return Success(msg="Deleted Success") |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .menus import router |
||||
|
||||
menus_router = APIRouter() |
||||
menus_router.include_router(router, tags=["菜单模块"]) |
||||
|
||||
__all__ = ["menus_router"] |
@ -0,0 +1,63 @@ |
||||
import logging |
||||
|
||||
from fastapi import APIRouter, Query |
||||
|
||||
from app.controllers.menu import menu_controller |
||||
from app.schemas.base import Fail, Success, SuccessExtra |
||||
from app.schemas.menus import * |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.get("/list", summary="查看菜单列表") |
||||
async def list_menu( |
||||
page: int = Query(1, description="页码"), |
||||
page_size: int = Query(10, description="每页数量"), |
||||
): |
||||
async def get_menu_with_children(menu_id: int): |
||||
menu = await menu_controller.model.get(id=menu_id) |
||||
menu_dict = await menu.to_dict() |
||||
child_menus = await menu_controller.model.filter(parent_id=menu_id).order_by("order") |
||||
menu_dict["children"] = [await get_menu_with_children(child.id) for child in child_menus] |
||||
return menu_dict |
||||
|
||||
parent_menus = await menu_controller.model.filter(parent_id=0).order_by("order") |
||||
res_menu = [await get_menu_with_children(menu.id) for menu in parent_menus] |
||||
return SuccessExtra(data=res_menu, total=len(res_menu), page=page, page_size=page_size) |
||||
|
||||
|
||||
@router.get("/get", summary="查看菜单") |
||||
async def get_menu( |
||||
menu_id: int = Query(..., description="菜单id"), |
||||
): |
||||
result = await menu_controller.get(id=menu_id) |
||||
return Success(data=result) |
||||
|
||||
|
||||
@router.post("/create", summary="创建菜单") |
||||
async def create_menu( |
||||
menu_in: MenuCreate, |
||||
): |
||||
await menu_controller.create(obj_in=menu_in) |
||||
return Success(msg="Created Success") |
||||
|
||||
|
||||
@router.post("/update", summary="更新菜单") |
||||
async def update_menu( |
||||
menu_in: MenuUpdate, |
||||
): |
||||
await menu_controller.update(id=menu_in.id, obj_in=menu_in) |
||||
return Success(msg="Updated Success") |
||||
|
||||
|
||||
@router.delete("/delete", summary="删除菜单") |
||||
async def delete_menu( |
||||
id: int = Query(..., description="菜单id"), |
||||
): |
||||
child_menu_count = await menu_controller.model.filter(parent_id=id).count() |
||||
if child_menu_count > 0: |
||||
return Fail(msg="Cannot delete a menu with child menus") |
||||
await menu_controller.remove(id=id) |
||||
return Success(msg="Deleted Success") |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .roles import router |
||||
|
||||
roles_router = APIRouter() |
||||
roles_router.include_router(router, tags=["角色模块"]) |
||||
|
||||
__all__ = ["roles_router"] |
@ -0,0 +1,73 @@ |
||||
import logging |
||||
|
||||
from fastapi import APIRouter, Query |
||||
from fastapi.exceptions import HTTPException |
||||
from tortoise.expressions import Q |
||||
|
||||
from app.controllers import role_controller |
||||
from app.schemas.base import Success, SuccessExtra |
||||
from app.schemas.roles import * |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.get("/list", summary="查看角色列表") |
||||
async def list_role( |
||||
page: int = Query(1, description="页码"), |
||||
page_size: int = Query(10, description="每页数量"), |
||||
role_name: str = Query("", description="角色名称,用于查询"), |
||||
): |
||||
q = Q() |
||||
if role_name: |
||||
q = Q(name__contains=role_name) |
||||
total, role_objs = await role_controller.list(page=page, page_size=page_size, search=q) |
||||
data = [await obj.to_dict() for obj in role_objs] |
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size) |
||||
|
||||
|
||||
@router.get("/get", summary="查看角色") |
||||
async def get_role( |
||||
role_id: int = Query(..., description="角色ID"), |
||||
): |
||||
role_obj = await role_controller.get(id=role_id) |
||||
return Success(data=await role_obj.to_dict()) |
||||
|
||||
|
||||
@router.post("/create", summary="创建角色") |
||||
async def create_role(role_in: RoleCreate): |
||||
if await role_controller.is_exist(name=role_in.name): |
||||
raise HTTPException( |
||||
status_code=400, |
||||
detail="The role with this rolename already exists in the system.", |
||||
) |
||||
await role_controller.create(obj_in=role_in) |
||||
return Success(msg="Created Successfully") |
||||
|
||||
|
||||
@router.post("/update", summary="更新角色") |
||||
async def update_role(role_in: RoleUpdate): |
||||
await role_controller.update(id=role_in.id, obj_in=role_in) |
||||
return Success(msg="Updated Successfully") |
||||
|
||||
|
||||
@router.delete("/delete", summary="删除角色") |
||||
async def delete_role( |
||||
role_id: int = Query(..., description="角色ID"), |
||||
): |
||||
await role_controller.remove(id=role_id) |
||||
return Success(msg="Deleted Success") |
||||
|
||||
|
||||
@router.get("/authorized", summary="查看角色权限") |
||||
async def get_role_authorized(id: int = Query(..., description="角色ID")): |
||||
role_obj = await role_controller.get(id=id) |
||||
data = await role_obj.to_dict(m2m=True) |
||||
return Success(data=data) |
||||
|
||||
|
||||
@router.post("/authorized", summary="更新角色权限") |
||||
async def update_role_authorized(role_in: RoleUpdateMenusApis): |
||||
role_obj = await role_controller.get(id=role_in.id) |
||||
await role_controller.update_roles(role=role_obj, menu_ids=role_in.menu_ids, api_infos=role_in.api_infos) |
||||
return Success(msg="Updated Successfully") |
@ -0,0 +1,8 @@ |
||||
from fastapi import APIRouter |
||||
|
||||
from .users import router |
||||
|
||||
users_router = APIRouter() |
||||
users_router.include_router(router, tags=["用户模块"]) |
||||
|
||||
__all__ = ["users_router"] |
@ -0,0 +1,81 @@ |
||||
import logging |
||||
|
||||
from fastapi import APIRouter, Body, Query |
||||
from tortoise.expressions import Q |
||||
|
||||
from app.controllers.dept import dept_controller |
||||
from app.controllers.user import user_controller |
||||
from app.schemas.base import Fail, Success, SuccessExtra |
||||
from app.schemas.users import * |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
router = APIRouter() |
||||
|
||||
|
||||
@router.get("/list", summary="查看用户列表") |
||||
async def list_user( |
||||
page: int = Query(1, description="页码"), |
||||
page_size: int = Query(10, description="每页数量"), |
||||
username: str = Query("", description="用户名称,用于搜索"), |
||||
email: str = Query("", description="邮箱地址"), |
||||
dept_id: int = Query(None, description="部门ID"), |
||||
): |
||||
q = Q() |
||||
if username: |
||||
q &= Q(username__contains=username) |
||||
if email: |
||||
q &= Q(email__contains=email) |
||||
if dept_id is not None: |
||||
q &= Q(dept_id=dept_id) |
||||
total, user_objs = await user_controller.list(page=page, page_size=page_size, search=q) |
||||
data = [await obj.to_dict(m2m=True, exclude_fields=["password"]) for obj in user_objs] |
||||
for item in data: |
||||
dept_id = item.pop("dept_id", None) |
||||
item["dept"] = await (await dept_controller.get(id=dept_id)).to_dict() if dept_id else {} |
||||
|
||||
return SuccessExtra(data=data, total=total, page=page, page_size=page_size) |
||||
|
||||
|
||||
@router.get("/get", summary="查看用户") |
||||
async def get_user( |
||||
user_id: int = Query(..., description="用户ID"), |
||||
): |
||||
user_obj = await user_controller.get(id=user_id) |
||||
user_dict = await user_obj.to_dict(exclude_fields=["password"]) |
||||
return Success(data=user_dict) |
||||
|
||||
|
||||
@router.post("/create", summary="创建用户") |
||||
async def create_user( |
||||
user_in: UserCreate, |
||||
): |
||||
user = await user_controller.get_by_email(user_in.email) |
||||
if user: |
||||
return Fail(code=400, msg="The user with this email already exists in the system.") |
||||
new_user = await user_controller.create_user(obj_in=user_in) |
||||
await user_controller.update_roles(new_user, user_in.role_ids) |
||||
return Success(msg="Created Successfully") |
||||
|
||||
|
||||
@router.post("/update", summary="更新用户") |
||||
async def update_user( |
||||
user_in: UserUpdate, |
||||
): |
||||
user = await user_controller.update(id=user_in.id, obj_in=user_in) |
||||
await user_controller.update_roles(user, user_in.role_ids) |
||||
return Success(msg="Updated Successfully") |
||||
|
||||
|
||||
@router.delete("/delete", summary="删除用户") |
||||
async def delete_user( |
||||
user_id: int = Query(..., description="用户ID"), |
||||
): |
||||
await user_controller.remove(id=user_id) |
||||
return Success(msg="Deleted Successfully") |
||||
|
||||
|
||||
@router.post("/reset_password", summary="重置密码") |
||||
async def reset_password(user_id: int = Body(..., description="用户ID", embed=True)): |
||||
await user_controller.reset_password(user_id) |
||||
return Success(msg="密码已重置为123456") |
@ -0,0 +1,2 @@ |
||||
from .role import role_controller as role_controller |
||||
from .user import user_controller as user_controller |
@ -0,0 +1,45 @@ |
||||
from fastapi.routing import APIRoute |
||||
|
||||
from app.core.crud import CRUDBase |
||||
from app.log import logger |
||||
from app.models.admin import Api |
||||
from app.schemas.apis import ApiCreate, ApiUpdate |
||||
|
||||
|
||||
class ApiController(CRUDBase[Api, ApiCreate, ApiUpdate]): |
||||
def __init__(self): |
||||
super().__init__(model=Api) |
||||
|
||||
async def refresh_api(self): |
||||
from app import app |
||||
|
||||
# 删除废弃API数据 |
||||
all_api_list = [] |
||||
for route in app.routes: |
||||
# 只更新有鉴权的API |
||||
if isinstance(route, APIRoute) and len(route.dependencies) > 0: |
||||
all_api_list.append((list(route.methods)[0], route.path_format)) |
||||
delete_api = [] |
||||
for api in await Api.all(): |
||||
if (api.method, api.path) not in all_api_list: |
||||
delete_api.append((api.method, api.path)) |
||||
for item in delete_api: |
||||
method, path = item |
||||
logger.debug(f"API Deleted {method} {path}") |
||||
await Api.filter(method=method, path=path).delete() |
||||
|
||||
for route in app.routes: |
||||
if isinstance(route, APIRoute) and len(route.dependencies) > 0: |
||||
method = list(route.methods)[0] |
||||
path = route.path_format |
||||
summary = route.summary |
||||
tags = list(route.tags)[0] |
||||
api_obj = await Api.filter(method=method, path=path).first() |
||||
if api_obj: |
||||
await api_obj.update_from_dict(dict(method=method, path=path, summary=summary, tags=tags)).save() |
||||
else: |
||||
logger.debug(f"API Created {method} {path}") |
||||
await Api.create(**dict(method=method, path=path, summary=summary, tags=tags)) |
||||
|
||||
|
||||
api_controller = ApiController() |
@ -0,0 +1,86 @@ |
||||
from tortoise.expressions import Q |
||||
from tortoise.transactions import atomic |
||||
|
||||
from app.core.crud import CRUDBase |
||||
from app.models.admin import Dept, DeptClosure |
||||
from app.schemas.depts import DeptCreate, DeptUpdate |
||||
|
||||
|
||||
class DeptController(CRUDBase[Dept, DeptCreate, DeptUpdate]): |
||||
def __init__(self): |
||||
super().__init__(model=Dept) |
||||
|
||||
async def get_dept_tree(self, name): |
||||
q = Q() |
||||
# 获取所有未被软删除的部门 |
||||
q &= Q(is_deleted=False) |
||||
if name: |
||||
q &= Q(name__contains=name) |
||||
all_depts = await self.model.filter(q).order_by("order") |
||||
|
||||
# 辅助函数,用于递归构建部门树 |
||||
def build_tree(parent_id): |
||||
return [ |
||||
{ |
||||
"id": dept.id, |
||||
"name": dept.name, |
||||
"desc": dept.desc, |
||||
"order": dept.order, |
||||
"parent_id": dept.parent_id, |
||||
"children": build_tree(dept.id), # 递归构建子部门 |
||||
} |
||||
for dept in all_depts |
||||
if dept.parent_id == parent_id |
||||
] |
||||
|
||||
# 从顶级部门(parent_id=0)开始构建部门树 |
||||
dept_tree = build_tree(0) |
||||
return dept_tree |
||||
|
||||
async def get_dept_info(self): |
||||
pass |
||||
|
||||
async def update_dept_closure(self, obj: Dept): |
||||
parent_depts = await DeptClosure.filter(descendant=obj.parent_id) |
||||
for i in parent_depts: |
||||
print(i.ancestor, i.descendant) |
||||
dept_closure_objs: list[DeptClosure] = [] |
||||
# 插入父级关系 |
||||
for item in parent_depts: |
||||
dept_closure_objs.append(DeptClosure(ancestor=item.ancestor, descendant=obj.id, level=item.level + 1)) |
||||
# 插入自身x |
||||
dept_closure_objs.append(DeptClosure(ancestor=obj.id, descendant=obj.id, level=0)) |
||||
# 创建关系 |
||||
await DeptClosure.bulk_create(dept_closure_objs) |
||||
|
||||
@atomic() |
||||
async def create_dept(self, obj_in: DeptCreate): |
||||
# 创建 |
||||
if obj_in.parent_id != 0: |
||||
await self.get(id=obj_in.parent_id) |
||||
new_obj = await self.create(obj_in=obj_in) |
||||
await self.update_dept_closure(new_obj) |
||||
|
||||
@atomic() |
||||
async def update_dept(self, obj_in: DeptUpdate): |
||||
dept_obj = await self.get(id=obj_in.id) |
||||
# 更新部门关系 |
||||
if dept_obj.parent_id != obj_in.parent_id: |
||||
await DeptClosure.filter(ancestor=dept_obj.id).delete() |
||||
await DeptClosure.filter(descendant=dept_obj.id).delete() |
||||
await self.update_dept_closure(dept_obj) |
||||
# 更新部门信息 |
||||
dept_obj.update_from_dict(obj_in.model_dump(exclude_unset=True)) |
||||
await dept_obj.save() |
||||
|
||||
@atomic() |
||||
async def delete_dept(self, dept_id: int): |
||||
# 删除部门 |
||||
obj = await self.get(id=dept_id) |
||||
obj.is_deleted = True |
||||
await obj.save() |
||||
# 删除关系 |
||||
await DeptClosure.filter(descendant=dept_id).delete() |
||||
|
||||
|
||||
dept_controller = DeptController() |
@ -0,0 +1,16 @@ |
||||
from typing import Optional |
||||
|
||||
from app.core.crud import CRUDBase |
||||
from app.models.admin import Menu |
||||
from app.schemas.menus import MenuCreate, MenuUpdate |
||||
|
||||
|
||||
class MenuController(CRUDBase[Menu, MenuCreate, MenuUpdate]): |
||||
def __init__(self): |
||||
super().__init__(model=Menu) |
||||
|
||||
async def get_by_menu_path(self, path: str) -> Optional["Menu"]: |
||||
return await self.model.filter(path=path).first() |
||||
|
||||
|
||||
menu_controller = MenuController() |
@ -0,0 +1,27 @@ |
||||
from typing import List |
||||
|
||||
from app.core.crud import CRUDBase |
||||
from app.models.admin import Api, Menu, Role |
||||
from app.schemas.roles import RoleCreate, RoleUpdate |
||||
|
||||
|
||||
class RoleController(CRUDBase[Role, RoleCreate, RoleUpdate]): |
||||
def __init__(self): |
||||
super().__init__(model=Role) |
||||
|
||||
async def is_exist(self, name: str) -> bool: |
||||
return await self.model.filter(name=name).exists() |
||||
|
||||
async def update_roles(self, role: Role, menu_ids: List[int], api_infos: List[dict]) -> None: |
||||
await role.menus.clear() |
||||
for menu_id in menu_ids: |
||||
menu_obj = await Menu.filter(id=menu_id).first() |
||||
await role.menus.add(menu_obj) |
||||
|
||||
await role.apis.clear() |
||||
for item in api_infos: |
||||
api_obj = await Api.filter(path=item.get("path"), method=item.get("method")).first() |
||||
await role.apis.add(api_obj) |
||||
|
||||
|
||||
role_controller = RoleController() |
@ -0,0 +1,60 @@ |
||||
from datetime import datetime |
||||
from typing import List, Optional |
||||
|
||||
from fastapi.exceptions import HTTPException |
||||
|
||||
from app.core.crud import CRUDBase |
||||
from app.models.admin import User |
||||
from app.schemas.login import CredentialsSchema |
||||
from app.schemas.users import UserCreate, UserUpdate |
||||
from app.utils.password import get_password_hash, verify_password |
||||
|
||||
from .role import role_controller |
||||
|
||||
|
||||
class UserController(CRUDBase[User, UserCreate, UserUpdate]): |
||||
def __init__(self): |
||||
super().__init__(model=User) |
||||
|
||||
async def get_by_email(self, email: str) -> Optional[User]: |
||||
return await self.model.filter(email=email).first() |
||||
|
||||
async def get_by_username(self, username: str) -> Optional[User]: |
||||
return await self.model.filter(username=username).first() |
||||
|
||||
async def create_user(self, obj_in: UserCreate) -> User: |
||||
obj_in.password = get_password_hash(password=obj_in.password) |
||||
obj = await self.create(obj_in) |
||||
return obj |
||||
|
||||
async def update_last_login(self, id: int) -> None: |
||||
user = await self.model.get(id=id) |
||||
user.last_login = datetime.now() |
||||
await user.save() |
||||
|
||||
async def authenticate(self, credentials: CredentialsSchema) -> Optional["User"]: |
||||
user = await self.model.filter(username=credentials.username).first() |
||||
if not user: |
||||
raise HTTPException(status_code=400, detail="无效的用户名") |
||||
verified = verify_password(credentials.password, user.password) |
||||
if not verified: |
||||
raise HTTPException(status_code=400, detail="密码错误!") |
||||
if not user.is_active: |
||||
raise HTTPException(status_code=400, detail="用户已被禁用") |
||||
return user |
||||
|
||||
async def update_roles(self, user: User, role_ids: List[int]) -> None: |
||||
await user.roles.clear() |
||||
for role_id in role_ids: |
||||
role_obj = await role_controller.get(id=role_id) |
||||
await user.roles.add(role_obj) |
||||
|
||||
async def reset_password(self, user_id: int): |
||||
user_obj = await self.get(id=user_id) |
||||
if user_obj.is_superuser: |
||||
raise HTTPException(status_code=403, detail="不允许重置超级管理员密码") |
||||
user_obj.password = get_password_hash(password="123456") |
||||
await user_obj.save() |
||||
|
||||
|
||||
user_controller = UserController() |
@ -0,0 +1,31 @@ |
||||
from starlette.background import BackgroundTasks |
||||
|
||||
from .ctx import CTX_BG_TASKS |
||||
|
||||
|
||||
class BgTasks: |
||||
"""后台任务统一管理""" |
||||
|
||||
@classmethod |
||||
async def init_bg_tasks_obj(cls): |
||||
"""实例化后台任务,并设置到上下文""" |
||||
bg_tasks = BackgroundTasks() |
||||
CTX_BG_TASKS.set(bg_tasks) |
||||
|
||||
@classmethod |
||||
async def get_bg_tasks_obj(cls): |
||||
"""从上下文中获取后台任务实例""" |
||||
return CTX_BG_TASKS.get() |
||||
|
||||
@classmethod |
||||
async def add_task(cls, func, *args, **kwargs): |
||||
"""添加后台任务""" |
||||
bg_tasks = await cls.get_bg_tasks_obj() |
||||
bg_tasks.add_task(func, *args, **kwargs) |
||||
|
||||
@classmethod |
||||
async def execute_tasks(cls): |
||||
"""执行后台任务,一般是请求结果返回之后执行""" |
||||
bg_tasks = await cls.get_bg_tasks_obj() |
||||
if bg_tasks.tasks: |
||||
await bg_tasks() |
@ -0,0 +1,45 @@ |
||||
from typing import Any, Dict, Generic, List, NewType, Tuple, Type, TypeVar, Union |
||||
|
||||
from pydantic import BaseModel |
||||
from tortoise.expressions import Q |
||||
from tortoise.models import Model |
||||
|
||||
Total = NewType("Total", int) |
||||
ModelType = TypeVar("ModelType", bound=Model) |
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) |
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) |
||||
|
||||
|
||||
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): |
||||
def __init__(self, model: Type[ModelType]): |
||||
self.model = model |
||||
|
||||
async def get(self, id: int) -> ModelType: |
||||
return await self.model.get(id=id) |
||||
|
||||
async def list(self, page: int, page_size: int, search: Q = Q(), order: list = []) -> Tuple[Total, List[ModelType]]: |
||||
query = self.model.filter(search) |
||||
return await query.count(), await query.offset((page - 1) * page_size).limit(page_size).order_by(*order) |
||||
|
||||
async def create(self, obj_in: CreateSchemaType) -> ModelType: |
||||
if isinstance(obj_in, Dict): |
||||
obj_dict = obj_in |
||||
else: |
||||
obj_dict = obj_in.model_dump() |
||||
obj = self.model(**obj_dict) |
||||
await obj.save() |
||||
return obj |
||||
|
||||
async def update(self, id: int, obj_in: Union[UpdateSchemaType, Dict[str, Any]]) -> ModelType: |
||||
if isinstance(obj_in, Dict): |
||||
obj_dict = obj_in |
||||
else: |
||||
obj_dict = obj_in.model_dump(exclude_unset=True, exclude={"id"}) |
||||
obj = await self.get(id=id) |
||||
obj = obj.update_from_dict(obj_dict) |
||||
await obj.save() |
||||
return obj |
||||
|
||||
async def remove(self, id: int) -> None: |
||||
obj = await self.get(id=id) |
||||
await obj.delete() |
@ -0,0 +1,6 @@ |
||||
import contextvars |
||||
|
||||
from starlette.background import BackgroundTasks |
||||
|
||||
CTX_USER_ID: contextvars.ContextVar[int] = contextvars.ContextVar("user_id", default=0) |
||||
CTX_BG_TASKS: contextvars.ContextVar[BackgroundTasks] = contextvars.ContextVar("bg_task", default=None) |
@ -0,0 +1,53 @@ |
||||
from typing import Optional |
||||
|
||||
import jwt |
||||
from fastapi import Depends, Header, HTTPException, Request |
||||
|
||||
from app.core.ctx import CTX_USER_ID |
||||
from app.models import Role, User |
||||
from app.settings import settings |
||||
|
||||
|
||||
class AuthControl: |
||||
@classmethod |
||||
async def is_authed(cls, token: str = Header(..., description="token验证")) -> Optional["User"]: |
||||
try: |
||||
if token == "dev": |
||||
user = await User.filter().first() |
||||
user_id = user.id |
||||
else: |
||||
decode_data = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.JWT_ALGORITHM) |
||||
user_id = decode_data.get("user_id") |
||||
user = await User.filter(id=user_id).first() |
||||
if not user: |
||||
raise HTTPException(status_code=401, detail="Authentication failed") |
||||
CTX_USER_ID.set(int(user_id)) |
||||
return user |
||||
except jwt.DecodeError: |
||||
raise HTTPException(status_code=401, detail="无效的Token") |
||||
except jwt.ExpiredSignatureError: |
||||
raise HTTPException(status_code=401, detail="登录已过期") |
||||
except Exception as e: |
||||
raise HTTPException(status_code=500, detail=f"{repr(e)}") |
||||
|
||||
|
||||
class PermissionControl: |
||||
@classmethod |
||||
async def has_permission(cls, request: Request, current_user: User = Depends(AuthControl.is_authed)) -> None: |
||||
if current_user.is_superuser: |
||||
return |
||||
method = request.method |
||||
path = request.url.path |
||||
roles: list[Role] = await current_user.roles |
||||
if not roles: |
||||
raise HTTPException(status_code=403, detail="The user is not bound to a role") |
||||
apis = [await role.apis for role in roles] |
||||
permission_apis = list(set((api.method, api.path) for api in sum(apis, []))) |
||||
# path = "/api/v1/auth/userinfo" |
||||
# method = "GET" |
||||
if (method, path) not in permission_apis: |
||||
raise HTTPException(status_code=403, detail=f"Permission denied method:{method} path:{path}") |
||||
|
||||
|
||||
DependAuth = Depends(AuthControl.is_authed) |
||||
DependPermission = Depends(PermissionControl.has_permission) |
@ -0,0 +1,43 @@ |
||||
from fastapi.exceptions import ( |
||||
HTTPException, |
||||
RequestValidationError, |
||||
ResponseValidationError, |
||||
) |
||||
from fastapi.requests import Request |
||||
from fastapi.responses import JSONResponse |
||||
from tortoise.exceptions import DoesNotExist, IntegrityError |
||||
|
||||
|
||||
class SettingNotFound(Exception): |
||||
pass |
||||
|
||||
|
||||
async def DoesNotExistHandle(req: Request, exc: DoesNotExist) -> JSONResponse: |
||||
content = dict( |
||||
code=404, |
||||
msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}", |
||||
) |
||||
return JSONResponse(content=content, status_code=404) |
||||
|
||||
|
||||
async def IntegrityHandle(_: Request, exc: IntegrityError) -> JSONResponse: |
||||
content = dict( |
||||
code=500, |
||||
msg=f"IntegrityError,{exc}", |
||||
) |
||||
return JSONResponse(content=content, status_code=500) |
||||
|
||||
|
||||
async def HttpExcHandle(_: Request, exc: HTTPException) -> JSONResponse: |
||||
content = dict(code=exc.status_code, msg=exc.detail, data=None) |
||||
return JSONResponse(content=content, status_code=exc.status_code) |
||||
|
||||
|
||||
async def RequestValidationHandle(_: Request, exc: RequestValidationError) -> JSONResponse: |
||||
content = dict(code=422, msg=f"RequestValidationError, {exc}") |
||||
return JSONResponse(content=content, status_code=422) |
||||
|
||||
|
||||
async def ResponseValidationHandle(_: Request, exc: ResponseValidationError) -> JSONResponse: |
||||
content = dict(code=500, msg=f"ResponseValidationError, {exc}") |
||||
return JSONResponse(content=content, status_code=500) |
@ -0,0 +1,234 @@ |
||||
import shutil |
||||
|
||||
from aerich import Command |
||||
from fastapi import FastAPI |
||||
from fastapi.middleware import Middleware |
||||
from fastapi.middleware.cors import CORSMiddleware |
||||
from tortoise.expressions import Q |
||||
|
||||
from app.api import api_router |
||||
from app.controllers.api import api_controller |
||||
from app.controllers.user import UserCreate, user_controller |
||||
from app.core.exceptions import ( |
||||
DoesNotExist, |
||||
DoesNotExistHandle, |
||||
HTTPException, |
||||
HttpExcHandle, |
||||
IntegrityError, |
||||
IntegrityHandle, |
||||
RequestValidationError, |
||||
RequestValidationHandle, |
||||
ResponseValidationError, |
||||
ResponseValidationHandle, |
||||
) |
||||
from app.log import logger |
||||
from app.models.admin import Api, Menu, Role |
||||
from app.schemas.menus import MenuType |
||||
from app.settings.config import settings |
||||
|
||||
from .middlewares import BackGroundTaskMiddleware, HttpAuditLogMiddleware |
||||
|
||||
|
||||
def make_middlewares(): |
||||
middleware = [ |
||||
Middleware( |
||||
CORSMiddleware, |
||||
allow_origins=settings.CORS_ORIGINS, |
||||
allow_credentials=settings.CORS_ALLOW_CREDENTIALS, |
||||
allow_methods=settings.CORS_ALLOW_METHODS, |
||||
allow_headers=settings.CORS_ALLOW_HEADERS, |
||||
), |
||||
Middleware(BackGroundTaskMiddleware), |
||||
Middleware( |
||||
HttpAuditLogMiddleware, |
||||
methods=["GET", "POST", "PUT", "DELETE"], |
||||
exclude_paths=[ |
||||
"/api/v1/base/access_token", |
||||
"/docs", |
||||
"/openapi.json", |
||||
"/api/model/" |
||||
], |
||||
), |
||||
] |
||||
return middleware |
||||
|
||||
|
||||
def register_exceptions(app: FastAPI): |
||||
app.add_exception_handler(DoesNotExist, DoesNotExistHandle) |
||||
app.add_exception_handler(HTTPException, HttpExcHandle) |
||||
app.add_exception_handler(IntegrityError, IntegrityHandle) |
||||
app.add_exception_handler(RequestValidationError, RequestValidationHandle) |
||||
app.add_exception_handler(ResponseValidationError, ResponseValidationHandle) |
||||
|
||||
|
||||
def register_routers(app: FastAPI, prefix: str = "/api"): |
||||
app.include_router(api_router, prefix=prefix) |
||||
|
||||
|
||||
async def init_superuser(): |
||||
user = await user_controller.model.exists() |
||||
if not user: |
||||
await user_controller.create_user( |
||||
UserCreate( |
||||
username="admin", |
||||
email="admin@admin.com", |
||||
password="123456", |
||||
is_active=True, |
||||
is_superuser=True, |
||||
) |
||||
) |
||||
|
||||
|
||||
async def init_menus(): |
||||
menus = await Menu.exists() |
||||
if not menus: |
||||
parent_menu = await Menu.create( |
||||
menu_type=MenuType.CATALOG, |
||||
name="系统管理", |
||||
path="/system", |
||||
order=1, |
||||
parent_id=0, |
||||
icon="carbon:gui-management", |
||||
is_hidden=False, |
||||
component="Layout", |
||||
keepalive=False, |
||||
redirect="/system/user", |
||||
) |
||||
children_menu = [ |
||||
Menu( |
||||
menu_type=MenuType.MENU, |
||||
name="用户管理", |
||||
path="user", |
||||
order=1, |
||||
parent_id=parent_menu.id, |
||||
icon="material-symbols:person-outline-rounded", |
||||
is_hidden=False, |
||||
component="/system/user", |
||||
keepalive=False, |
||||
), |
||||
Menu( |
||||
menu_type=MenuType.MENU, |
||||
name="角色管理", |
||||
path="role", |
||||
order=2, |
||||
parent_id=parent_menu.id, |
||||
icon="carbon:user-role", |
||||
is_hidden=False, |
||||
component="/system/role", |
||||
keepalive=False, |
||||
), |
||||
Menu( |
||||
menu_type=MenuType.MENU, |
||||
name="菜单管理", |
||||
path="menu", |
||||
order=3, |
||||
parent_id=parent_menu.id, |
||||
icon="material-symbols:list-alt-outline", |
||||
is_hidden=False, |
||||
component="/system/menu", |
||||
keepalive=False, |
||||
), |
||||
Menu( |
||||
menu_type=MenuType.MENU, |
||||
name="API管理", |
||||
path="api", |
||||
order=4, |
||||
parent_id=parent_menu.id, |
||||
icon="ant-design:api-outlined", |
||||
is_hidden=False, |
||||
component="/system/api", |
||||
keepalive=False, |
||||
), |
||||
Menu( |
||||
menu_type=MenuType.MENU, |
||||
name="部门管理", |
||||
path="dept", |
||||
order=5, |
||||
parent_id=parent_menu.id, |
||||
icon="mingcute:department-line", |
||||
is_hidden=False, |
||||
component="/system/dept", |
||||
keepalive=False, |
||||
), |
||||
Menu( |
||||
menu_type=MenuType.MENU, |
||||
name="审计日志", |
||||
path="auditlog", |
||||
order=6, |
||||
parent_id=parent_menu.id, |
||||
icon="ph:clipboard-text-bold", |
||||
is_hidden=False, |
||||
component="/system/auditlog", |
||||
keepalive=False, |
||||
), |
||||
] |
||||
await Menu.bulk_create(children_menu) |
||||
await Menu.create( |
||||
menu_type=MenuType.MENU, |
||||
name="一级菜单", |
||||
path="/top-menu", |
||||
order=2, |
||||
parent_id=0, |
||||
icon="material-symbols:featured-play-list-outline", |
||||
is_hidden=False, |
||||
component="/top-menu", |
||||
keepalive=False, |
||||
redirect="", |
||||
) |
||||
|
||||
|
||||
async def init_apis(): |
||||
apis = await api_controller.model.exists() |
||||
if not apis: |
||||
await api_controller.refresh_api() |
||||
|
||||
|
||||
async def init_db(): |
||||
command = Command(tortoise_config=settings.TORTOISE_ORM) |
||||
try: |
||||
await command.init_db(safe=True) |
||||
except FileExistsError: |
||||
pass |
||||
|
||||
await command.init() |
||||
try: |
||||
await command.migrate() |
||||
except AttributeError: |
||||
logger.warning("unable to retrieve model history from database, model history will be created from scratch") |
||||
shutil.rmtree("migrations") |
||||
await command.init_db(safe=True) |
||||
|
||||
await command.upgrade(run_in_transaction=True) |
||||
|
||||
|
||||
async def init_roles(): |
||||
roles = await Role.exists() |
||||
if not roles: |
||||
admin_role = await Role.create( |
||||
name="管理员", |
||||
desc="管理员角色", |
||||
) |
||||
user_role = await Role.create( |
||||
name="普通用户", |
||||
desc="普通用户角色", |
||||
) |
||||
|
||||
# 分配所有API给管理员角色 |
||||
all_apis = await Api.all() |
||||
await admin_role.apis.add(*all_apis) |
||||
# 分配所有菜单给管理员和普通用户 |
||||
all_menus = await Menu.all() |
||||
await admin_role.menus.add(*all_menus) |
||||
await user_role.menus.add(*all_menus) |
||||
|
||||
# 为普通用户分配基本API |
||||
basic_apis = await Api.filter(Q(method__in=["GET"]) | Q(tags="基础模块")) |
||||
await user_role.apis.add(*basic_apis) |
||||
|
||||
|
||||
async def init_data(): |
||||
await init_db() |
||||
await init_superuser() |
||||
await init_menus() |
||||
await init_apis() |
||||
await init_roles() |
@ -0,0 +1,182 @@ |
||||
import json |
||||
import re |
||||
from datetime import datetime |
||||
from typing import Any, AsyncGenerator |
||||
|
||||
from fastapi import FastAPI |
||||
from fastapi.responses import Response |
||||
from fastapi.routing import APIRoute |
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint |
||||
from starlette.requests import Request |
||||
from starlette.types import ASGIApp, Receive, Scope, Send |
||||
|
||||
from app.core.dependency import AuthControl |
||||
from app.models.admin import AuditLog, User |
||||
|
||||
from .bgtask import BgTasks |
||||
|
||||
|
||||
class SimpleBaseMiddleware: |
||||
def __init__(self, app: ASGIApp) -> None: |
||||
self.app = app |
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: |
||||
if scope["type"] != "http": |
||||
await self.app(scope, receive, send) |
||||
return |
||||
|
||||
request = Request(scope, receive=receive) |
||||
|
||||
response = await self.before_request(request) or self.app |
||||
await response(request.scope, request.receive, send) |
||||
await self.after_request(request) |
||||
|
||||
async def before_request(self, request: Request): |
||||
return self.app |
||||
|
||||
async def after_request(self, request: Request): |
||||
return None |
||||
|
||||
|
||||
class BackGroundTaskMiddleware(SimpleBaseMiddleware): |
||||
async def before_request(self, request): |
||||
await BgTasks.init_bg_tasks_obj() |
||||
|
||||
async def after_request(self, request): |
||||
await BgTasks.execute_tasks() |
||||
|
||||
|
||||
class HttpAuditLogMiddleware(BaseHTTPMiddleware): |
||||
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) |
||||
for k, v in body.items(): |
||||
if hasattr(v, "filename"): # 文件上传行为 |
||||
args[k] = v.filename |
||||
elif isinstance(v, list) and v and hasattr(v[0], "filename"): |
||||
args[k] = [file.filename for file in v] |
||||
else: |
||||
args[k] = v |
||||
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: |
||||
""" |
||||
根据request和response对象获取对应的日志记录数据 |
||||
""" |
||||
data: dict = {"path": request.url.path, "status": response.status_code, "method": request.method} |
||||
# 路由信息 |
||||
app: FastAPI = request.app |
||||
for route in app.routes: |
||||
if ( |
||||
isinstance(route, APIRoute) |
||||
and route.path_regex.match(request.url.path) |
||||
and request.method in route.methods |
||||
): |
||||
data["module"] = ",".join(route.tags) |
||||
data["summary"] = route.summary |
||||
# 获取用户信息 |
||||
try: |
||||
token = request.headers.get("token") |
||||
user_obj = None |
||||
if token: |
||||
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: |
||||
data["user_id"] = 0 |
||||
data["username"] = "" |
||||
return data |
||||
|
||||
async def before_request(self, request: Request): |
||||
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: |
||||
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["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) |
||||
response = await call_next(request) |
||||
end_time: datetime = datetime.now() |
||||
process_time = int((end_time.timestamp() - start_time.timestamp()) * 1000) |
||||
await self.after_request(request, response, process_time) |
||||
return response |
@ -0,0 +1 @@ |
||||
from .log import logger as logger |
@ -0,0 +1,25 @@ |
||||
import sys |
||||
|
||||
from loguru import logger as loguru_logger |
||||
|
||||
from app.settings import settings |
||||
|
||||
|
||||
class Loggin: |
||||
def __init__(self) -> None: |
||||
debug = settings.DEBUG |
||||
if debug: |
||||
self.level = "DEBUG" |
||||
else: |
||||
self.level = "INFO" |
||||
|
||||
def setup_logger(self): |
||||
loguru_logger.remove() |
||||
loguru_logger.add(sink=sys.stdout, level=self.level) |
||||
|
||||
# logger.add("my_project.log", level=level, rotation="100 MB") # Output log messages to a file |
||||
return loguru_logger |
||||
|
||||
|
||||
loggin = Loggin() |
||||
logger = loggin.setup_logger() |
@ -0,0 +1,8 @@ |
||||
from pydantic import BaseModel |
||||
|
||||
class ChatIn(BaseModel): |
||||
language: str |
||||
message: str |
||||
sign: str |
||||
timestamp: int |
||||
user_id: int |
@ -0,0 +1,2 @@ |
||||
# 新增model需要在这里导入 |
||||
from .admin import * |
@ -0,0 +1,89 @@ |
||||
from tortoise import fields |
||||
|
||||
from app.schemas.menus import MenuType |
||||
|
||||
from .base import BaseModel, TimestampMixin |
||||
from .enums import MethodType |
||||
|
||||
|
||||
class User(BaseModel, TimestampMixin): |
||||
username = fields.CharField(max_length=20, unique=True, description="用户名称", index=True) |
||||
alias = fields.CharField(max_length=30, null=True, description="姓名", index=True) |
||||
email = fields.CharField(max_length=255, unique=True, description="邮箱", index=True) |
||||
phone = fields.CharField(max_length=20, null=True, description="电话", index=True) |
||||
password = fields.CharField(max_length=128, null=True, description="密码") |
||||
is_active = fields.BooleanField(default=True, description="是否激活", index=True) |
||||
is_superuser = fields.BooleanField(default=False, description="是否为超级管理员", index=True) |
||||
last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True) |
||||
roles = fields.ManyToManyField("models.Role", related_name="user_roles") |
||||
dept_id = fields.IntField(null=True, description="部门ID", index=True) |
||||
|
||||
class Meta: |
||||
table = "user" |
||||
|
||||
|
||||
class Role(BaseModel, TimestampMixin): |
||||
name = fields.CharField(max_length=20, unique=True, description="角色名称", index=True) |
||||
desc = fields.CharField(max_length=500, null=True, description="角色描述") |
||||
menus = fields.ManyToManyField("models.Menu", related_name="role_menus") |
||||
apis = fields.ManyToManyField("models.Api", related_name="role_apis") |
||||
|
||||
class Meta: |
||||
table = "role" |
||||
|
||||
|
||||
class Api(BaseModel, TimestampMixin): |
||||
path = fields.CharField(max_length=100, description="API路径", index=True) |
||||
method = fields.CharEnumField(MethodType, description="请求方法", index=True) |
||||
summary = fields.CharField(max_length=500, description="请求简介", index=True) |
||||
tags = fields.CharField(max_length=100, description="API标签", index=True) |
||||
|
||||
class Meta: |
||||
table = "api" |
||||
|
||||
|
||||
class Menu(BaseModel, TimestampMixin): |
||||
name = fields.CharField(max_length=20, description="菜单名称", index=True) |
||||
remark = fields.JSONField(null=True, description="保留字段") |
||||
menu_type = fields.CharEnumField(MenuType, null=True, description="菜单类型") |
||||
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, description="父菜单ID", index=True) |
||||
is_hidden = fields.BooleanField(default=False, description="是否隐藏") |
||||
component = fields.CharField(max_length=100, description="组件") |
||||
keepalive = fields.BooleanField(default=True, description="存活") |
||||
redirect = fields.CharField(max_length=100, null=True, description="重定向") |
||||
|
||||
class Meta: |
||||
table = "menu" |
||||
|
||||
|
||||
class Dept(BaseModel, TimestampMixin): |
||||
name = fields.CharField(max_length=20, unique=True, description="部门名称", index=True) |
||||
desc = fields.CharField(max_length=500, null=True, description="备注") |
||||
is_deleted = fields.BooleanField(default=False, description="软删除标记", index=True) |
||||
order = fields.IntField(default=0, description="排序", index=True) |
||||
parent_id = fields.IntField(default=0, max_length=10, description="父部门ID", index=True) |
||||
|
||||
class Meta: |
||||
table = "dept" |
||||
|
||||
|
||||
class DeptClosure(BaseModel, TimestampMixin): |
||||
ancestor = fields.IntField(description="父代", index=True) |
||||
descendant = fields.IntField(description="子代", index=True) |
||||
level = fields.IntField(default=0, description="深度", index=True) |
||||
|
||||
|
||||
class AuditLog(BaseModel, TimestampMixin): |
||||
user_id = fields.IntField(description="用户ID", index=True) |
||||
username = fields.CharField(max_length=64, default="", description="用户名称", index=True) |
||||
module = fields.CharField(max_length=64, default="", description="功能模块", index=True) |
||||
summary = fields.CharField(max_length=128, default="", description="请求描述", index=True) |
||||
method = fields.CharField(max_length=10, default="", description="请求方法", index=True) |
||||
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="返回数据") |
@ -0,0 +1,62 @@ |
||||
import asyncio |
||||
from datetime import datetime |
||||
|
||||
from tortoise import fields, models |
||||
|
||||
from app.settings import settings |
||||
|
||||
|
||||
class BaseModel(models.Model): |
||||
id = fields.BigIntField(pk=True, index=True) |
||||
|
||||
async def to_dict(self, m2m: bool = False, exclude_fields: list[str] | None = None): |
||||
if exclude_fields is None: |
||||
exclude_fields = [] |
||||
|
||||
d = {} |
||||
for field in self._meta.db_fields: |
||||
if field not in exclude_fields: |
||||
value = getattr(self, field) |
||||
if isinstance(value, datetime): |
||||
value = value.strftime(settings.DATETIME_FORMAT) |
||||
d[field] = value |
||||
|
||||
if m2m: |
||||
tasks = [ |
||||
self.__fetch_m2m_field(field, exclude_fields) |
||||
for field in self._meta.m2m_fields |
||||
if field not in exclude_fields |
||||
] |
||||
results = await asyncio.gather(*tasks) |
||||
for field, values in results: |
||||
d[field] = values |
||||
|
||||
return d |
||||
|
||||
async def __fetch_m2m_field(self, field, exclude_fields): |
||||
values = await getattr(self, field).all().values() |
||||
formatted_values = [] |
||||
|
||||
for value in values: |
||||
formatted_value = {} |
||||
for k, v in value.items(): |
||||
if k not in exclude_fields: |
||||
if isinstance(v, datetime): |
||||
formatted_value[k] = v.strftime(settings.DATETIME_FORMAT) |
||||
else: |
||||
formatted_value[k] = v |
||||
formatted_values.append(formatted_value) |
||||
|
||||
return field, formatted_values |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
|
||||
|
||||
class UUIDModel: |
||||
uuid = fields.UUIDField(unique=True, pk=False, index=True) |
||||
|
||||
|
||||
class TimestampMixin: |
||||
created_at = fields.DatetimeField(auto_now_add=True, index=True) |
||||
updated_at = fields.DatetimeField(auto_now=True, index=True) |
@ -0,0 +1,19 @@ |
||||
from enum import Enum, StrEnum |
||||
|
||||
|
||||
class EnumBase(Enum): |
||||
@classmethod |
||||
def get_member_values(cls): |
||||
return [item.value for item in cls._member_map_.values()] |
||||
|
||||
@classmethod |
||||
def get_member_names(cls): |
||||
return [name for name in cls._member_names_] |
||||
|
||||
|
||||
class MethodType(StrEnum): |
||||
GET = "GET" |
||||
POST = "POST" |
||||
PUT = "PUT" |
||||
DELETE = "DELETE" |
||||
PATCH = "PATCH" |
@ -0,0 +1 @@ |
||||
from .base import * |
@ -0,0 +1,17 @@ |
||||
from pydantic import BaseModel, Field |
||||
|
||||
from app.models.enums import MethodType |
||||
|
||||
|
||||
class BaseApi(BaseModel): |
||||
path: str = Field(..., description="API路径", example="/api/v1/user/list") |
||||
summary: str = Field("", description="API简介", example="查看用户列表") |
||||
method: MethodType = Field(..., description="API方法", example="GET") |
||||
tags: str = Field(..., description="API标签", example="User") |
||||
|
||||
|
||||
class ApiCreate(BaseApi): ... |
||||
|
||||
|
||||
class ApiUpdate(BaseApi): |
||||
id: int |
@ -0,0 +1,52 @@ |
||||
from typing import Any, Optional |
||||
|
||||
from fastapi.responses import JSONResponse |
||||
|
||||
|
||||
class Success(JSONResponse): |
||||
def __init__( |
||||
self, |
||||
code: int = 200, |
||||
msg: Optional[str] = "OK", |
||||
data: Optional[Any] = None, |
||||
**kwargs, |
||||
): |
||||
content = {"code": code, "msg": msg, "data": data} |
||||
content.update(kwargs) |
||||
super().__init__(content=content, status_code=code) |
||||
|
||||
|
||||
class Fail(JSONResponse): |
||||
def __init__( |
||||
self, |
||||
code: int = 400, |
||||
msg: Optional[str] = None, |
||||
data: Optional[Any] = None, |
||||
**kwargs, |
||||
): |
||||
content = {"code": code, "msg": msg, "data": data} |
||||
content.update(kwargs) |
||||
super().__init__(content=content, status_code=code) |
||||
|
||||
|
||||
class SuccessExtra(JSONResponse): |
||||
def __init__( |
||||
self, |
||||
code: int = 200, |
||||
msg: Optional[str] = None, |
||||
data: Optional[Any] = None, |
||||
total: int = 0, |
||||
page: int = 1, |
||||
page_size: int = 20, |
||||
**kwargs, |
||||
): |
||||
content = { |
||||
"code": code, |
||||
"msg": msg, |
||||
"data": data, |
||||
"total": total, |
||||
"page": page, |
||||
"page_size": page_size, |
||||
} |
||||
content.update(kwargs) |
||||
super().__init__(content=content, status_code=code) |
@ -0,0 +1,18 @@ |
||||
from pydantic import BaseModel, Field |
||||
|
||||
|
||||
class BaseDept(BaseModel): |
||||
name: str = Field(..., description="部门名称", example="研发中心") |
||||
desc: str = Field("", description="备注", example="研发中心") |
||||
order: int = Field(0, description="排序") |
||||
parent_id: int = Field(0, description="父部门ID") |
||||
|
||||
|
||||
class DeptCreate(BaseDept): ... |
||||
|
||||
|
||||
class DeptUpdate(BaseDept): |
||||
id: int |
||||
|
||||
def update_dict(self): |
||||
return self.model_dump(exclude_unset=True, exclude={"id"}) |
@ -0,0 +1,20 @@ |
||||
from datetime import datetime |
||||
|
||||
from pydantic import BaseModel, Field |
||||
|
||||
|
||||
class CredentialsSchema(BaseModel): |
||||
username: str = Field(..., description="用户名称", example="admin") |
||||
password: str = Field(..., description="密码", example="123456") |
||||
|
||||
|
||||
class JWTOut(BaseModel): |
||||
access_token: str |
||||
username: str |
||||
|
||||
|
||||
class JWTPayload(BaseModel): |
||||
user_id: int |
||||
username: str |
||||
is_superuser: bool |
||||
exp: datetime |
@ -0,0 +1,52 @@ |
||||
from enum import StrEnum |
||||
from typing import Optional |
||||
|
||||
from pydantic import BaseModel, Field |
||||
|
||||
|
||||
class MenuType(StrEnum): |
||||
CATALOG = "catalog" # 目录 |
||||
MENU = "menu" # 菜单 |
||||
|
||||
|
||||
class BaseMenu(BaseModel): |
||||
id: int |
||||
name: str |
||||
path: str |
||||
remark: Optional[dict] |
||||
menu_type: Optional[MenuType] |
||||
icon: Optional[str] |
||||
order: int |
||||
parent_id: int |
||||
is_hidden: bool |
||||
component: str |
||||
keepalive: bool |
||||
redirect: Optional[str] |
||||
children: Optional[list["BaseMenu"]] |
||||
|
||||
|
||||
class MenuCreate(BaseModel): |
||||
menu_type: MenuType = Field(default=MenuType.CATALOG.value) |
||||
name: str = Field(example="用户管理") |
||||
icon: Optional[str] = "ph:user-list-bold" |
||||
path: str = Field(example="/system/user") |
||||
order: Optional[int] = Field(example=1) |
||||
parent_id: Optional[int] = Field(example=0, default=0) |
||||
is_hidden: Optional[bool] = False |
||||
component: str = Field(default="Layout", example="/system/user") |
||||
keepalive: Optional[bool] = True |
||||
redirect: Optional[str] = "" |
||||
|
||||
|
||||
class MenuUpdate(BaseModel): |
||||
id: int |
||||
menu_type: Optional[MenuType] = Field(example=MenuType.CATALOG.value) |
||||
name: Optional[str] = Field(example="用户管理") |
||||
icon: Optional[str] = "ph:user-list-bold" |
||||
path: Optional[str] = Field(example="/system/user") |
||||
order: Optional[int] = Field(example=1) |
||||
parent_id: Optional[int] = Field(example=0) |
||||
is_hidden: Optional[bool] = False |
||||
component: str = Field(example="/system/user") |
||||
keepalive: Optional[bool] = False |
||||
redirect: Optional[str] = "" |
@ -0,0 +1,32 @@ |
||||
from datetime import datetime |
||||
from typing import Optional |
||||
|
||||
from pydantic import BaseModel, Field |
||||
|
||||
|
||||
class BaseRole(BaseModel): |
||||
id: int |
||||
name: str |
||||
desc: str = "" |
||||
users: Optional[list] = [] |
||||
menus: Optional[list] = [] |
||||
apis: Optional[list] = [] |
||||
created_at: Optional[datetime] |
||||
updated_at: Optional[datetime] |
||||
|
||||
|
||||
class RoleCreate(BaseModel): |
||||
name: str = Field(example="管理员") |
||||
desc: str = Field("", example="管理员角色") |
||||
|
||||
|
||||
class RoleUpdate(BaseModel): |
||||
id: int = Field(example=1) |
||||
name: str = Field(example="管理员") |
||||
desc: str = Field("", example="管理员角色") |
||||
|
||||
|
||||
class RoleUpdateMenusApis(BaseModel): |
||||
id: int |
||||
menu_ids: list[int] = [] |
||||
api_infos: list[dict] = [] |
@ -0,0 +1,44 @@ |
||||
from datetime import datetime |
||||
from typing import List, Optional |
||||
|
||||
from pydantic import BaseModel, EmailStr, Field |
||||
|
||||
|
||||
class BaseUser(BaseModel): |
||||
id: int |
||||
email: Optional[EmailStr] = None |
||||
username: Optional[str] = None |
||||
is_active: Optional[bool] = True |
||||
is_superuser: Optional[bool] = False |
||||
created_at: Optional[datetime] |
||||
updated_at: Optional[datetime] |
||||
last_login: Optional[datetime] |
||||
roles: Optional[list] = [] |
||||
|
||||
|
||||
class UserCreate(BaseModel): |
||||
email: EmailStr = Field(example="admin@qq.com") |
||||
username: str = Field(example="admin") |
||||
password: str = Field(example="123456") |
||||
is_active: Optional[bool] = True |
||||
is_superuser: Optional[bool] = False |
||||
role_ids: Optional[List[int]] = [] |
||||
dept_id: Optional[int] = Field(0, description="部门ID") |
||||
|
||||
def create_dict(self): |
||||
return self.model_dump(exclude_unset=True, exclude={"role_ids"}) |
||||
|
||||
|
||||
class UserUpdate(BaseModel): |
||||
id: int |
||||
email: EmailStr |
||||
username: str |
||||
is_active: Optional[bool] = True |
||||
is_superuser: Optional[bool] = False |
||||
role_ids: Optional[List[int]] = [] |
||||
dept_id: Optional[int] = 0 |
||||
|
||||
|
||||
class UpdatePassword(BaseModel): |
||||
old_password: str = Field(description="旧密码") |
||||
new_password: str = Field(description="新密码") |
@ -0,0 +1,3 @@ |
||||
from .config import settings as settings |
||||
|
||||
TORTOISE_ORM = settings.TORTOISE_ORM |
@ -0,0 +1,104 @@ |
||||
import os |
||||
import typing |
||||
|
||||
from pydantic_settings import BaseSettings |
||||
|
||||
|
||||
class Settings(BaseSettings): |
||||
VERSION: str = "0.1.0" |
||||
APP_TITLE: str = "Vue FastAPI Admin" |
||||
PROJECT_NAME: str = "Vue FastAPI Admin" |
||||
APP_DESCRIPTION: str = "Description" |
||||
|
||||
CORS_ORIGINS: typing.List = ["*"] |
||||
CORS_ALLOW_CREDENTIALS: bool = True |
||||
CORS_ALLOW_METHODS: typing.List = ["*"] |
||||
CORS_ALLOW_HEADERS: typing.List = ["*"] |
||||
|
||||
DEBUG: bool = True |
||||
|
||||
PROJECT_ROOT: str = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) |
||||
BASE_DIR: str = os.path.abspath(os.path.join(PROJECT_ROOT, os.pardir)) |
||||
LOGS_ROOT: str = os.path.join(BASE_DIR, "app/logs") |
||||
SECRET_KEY: str = "3488a63e1765035d386f05409663f55c83bfae3b3c61a932744b20ad14244dcf" # openssl rand -hex 32 |
||||
JWT_ALGORITHM: str = "HS256" |
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 day |
||||
# DeepSeek |
||||
DEEPSEEK_API_KEY: str = "sk-6147b0356b474070a8df1f2703704ea1" |
||||
DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1" |
||||
# Redis |
||||
REDIS_HOST: str = "39.105.17.128" |
||||
REDIS_PORT: int = 33062 |
||||
REDIS_DB: int = 1 |
||||
REDIS_PASSWORD: str = "cjy@123abc" |
||||
# 验签秘钥 |
||||
SIGN_KEY: str = "qIaGO0fZJNW2f0DM6upT" |
||||
TORTOISE_ORM: dict = { |
||||
"connections": { |
||||
# SQLite configuration |
||||
# "sqlite": { |
||||
# "engine": "tortoise.backends.sqlite", |
||||
# "credentials": {"file_path": f"{BASE_DIR}/db.sqlite3"}, # Path to SQLite database file |
||||
# }, |
||||
# MySQL/MariaDB configuration |
||||
# Install with: tortoise-orm[asyncmy] |
||||
"mysql": { |
||||
"engine": "tortoise.backends.mysql", |
||||
"credentials": { |
||||
"host": "localhost", # Database host address |
||||
"port": 3306, # Database port |
||||
"user": "root", # Database username |
||||
"password": "root", # Database password |
||||
"database": "bd_ai_fastapi", # Database name |
||||
}, |
||||
}, |
||||
# PostgreSQL configuration |
||||
# Install with: tortoise-orm[asyncpg] |
||||
# "postgres": { |
||||
# "engine": "tortoise.backends.asyncpg", |
||||
# "credentials": { |
||||
# "host": "localhost", # Database host address |
||||
# "port": 5432, # Database port |
||||
# "user": "yourusername", # Database username |
||||
# "password": "yourpassword", # Database password |
||||
# "database": "yourdatabase", # Database name |
||||
# }, |
||||
# }, |
||||
# MSSQL/Oracle configuration |
||||
# Install with: tortoise-orm[asyncodbc] |
||||
# "oracle": { |
||||
# "engine": "tortoise.backends.asyncodbc", |
||||
# "credentials": { |
||||
# "host": "localhost", # Database host address |
||||
# "port": 1433, # Database port |
||||
# "user": "yourusername", # Database username |
||||
# "password": "yourpassword", # Database password |
||||
# "database": "yourdatabase", # Database name |
||||
# }, |
||||
# }, |
||||
# SQLServer configuration |
||||
# Install with: tortoise-orm[asyncodbc] |
||||
# "sqlserver": { |
||||
# "engine": "tortoise.backends.asyncodbc", |
||||
# "credentials": { |
||||
# "host": "localhost", # Database host address |
||||
# "port": 1433, # Database port |
||||
# "user": "yourusername", # Database username |
||||
# "password": "yourpassword", # Database password |
||||
# "database": "yourdatabase", # Database name |
||||
# }, |
||||
# }, |
||||
}, |
||||
"apps": { |
||||
"models": { |
||||
"models": ["app.models", "aerich.models"], |
||||
"default_connection": "mysql", |
||||
}, |
||||
}, |
||||
"use_tz": False, # Whether to use timezone-aware datetimes |
||||
"timezone": "Asia/Shanghai", # Timezone setting |
||||
} |
||||
DATETIME_FORMAT: str = "%Y-%m-%d %H:%M:%S" |
||||
|
||||
|
||||
settings = Settings() |
@ -0,0 +1,10 @@ |
||||
import jwt |
||||
|
||||
from app.schemas.login import JWTPayload |
||||
from app.settings.config import settings |
||||
|
||||
|
||||
def create_access_token(*, data: JWTPayload): |
||||
payload = data.model_dump().copy() |
||||
encoded_jwt = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) |
||||
return encoded_jwt |
@ -0,0 +1,16 @@ |
||||
from passlib import pwd |
||||
from passlib.context import CryptContext |
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") |
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool: |
||||
return pwd_context.verify(plain_password, hashed_password) |
||||
|
||||
|
||||
def get_password_hash(password: str) -> str: |
||||
return pwd_context.hash(password) |
||||
|
||||
|
||||
def generate_password() -> str: |
||||
return pwd.genword() |
@ -0,0 +1,5 @@ |
||||
#!/bin/sh |
||||
set -e |
||||
|
||||
nginx |
||||
python run.py |
After Width: | Height: | Size: 159 KiB |
After Width: | Height: | Size: 102 KiB |
After Width: | Height: | Size: 242 KiB |
After Width: | Height: | Size: 432 KiB |
After Width: | Height: | Size: 111 KiB |
After Width: | Height: | Size: 236 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 352 KiB |
After Width: | Height: | Size: 316 KiB |
After Width: | Height: | Size: 289 KiB |
After Width: | Height: | Size: 409 KiB |
@ -0,0 +1,13 @@ |
||||
server { |
||||
listen 80; |
||||
server_name localhost; |
||||
location / { |
||||
root /opt/vue-fastapi-admin/web/dist; |
||||
index index.html index.htm; |
||||
try_files $uri /index.html; |
||||
} |
||||
location ^~ /api/ { |
||||
proxy_pass http://127.0.0.1:9999; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,95 @@ |
||||
[project] |
||||
name = "vue-fastapi-admin" |
||||
version = "0.1.0" |
||||
description = "Vue Fastapi admin" |
||||
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", |
||||
"uvloop==0.21.0 ; sys_platform != 'win32'", |
||||
"openai>=1.97.1", |
||||
"aiomysql>=0.2.0", |
||||
"redis>=6.2.0", |
||||
] |
||||
|
||||
[tool.setuptools] |
||||
packages = ["app"] |
||||
|
||||
[tool.black] |
||||
line-length = 120 |
||||
target-version = ["py310", "py311"] |
||||
|
||||
[tool.ruff] |
||||
line-length = 120 |
||||
lint.extend-select = [] |
||||
lint.ignore = [ |
||||
"F403", |
||||
"F405", |
||||
] |
||||
|
||||
[tool.aerich] |
||||
tortoise_orm = "app.settings.TORTOISE_ORM" |
||||
location = "./migrations" |
||||
src_folder = "./." |
@ -0,0 +1,63 @@ |
||||
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 |
||||
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.7.0 |
||||
email-validator==2.2.0 |
||||
fastapi==0.111.0 |
||||
fastapi-cli==0.0.7 |
||||
h11==0.14.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.5 |
||||
loguru==0.7.3 |
||||
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 |
||||
passlib==1.7.4 |
||||
pathspec==0.12.1 |
||||
platformdirs==4.3.6 |
||||
pycparser==2.22 |
||||
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.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 |
||||
tortoise-orm==0.23.0 |
||||
typer==0.15.1 |
||||
typing-extensions==4.12.2 |
||||
ujson==5.10.0 |
||||
uvicorn==0.34.0 |
||||
uvloop==0.21.0; sys_platform != 'win32' |
||||
watchfiles==1.0.4 |
||||
websockets==14.1 |
||||
openai==1.97.1 |
||||
aiomysql==0.2.0 |
||||
redis==6.2.0 |
@ -0,0 +1,13 @@ |
||||
import uvicorn |
||||
from uvicorn.config import LOGGING_CONFIG |
||||
|
||||
if __name__ == "__main__": |
||||
# 修改默认日志配置 |
||||
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=9999, reload=True, log_config=LOGGING_CONFIG) |
@ -0,0 +1,69 @@ |
||||
Metadata-Version: 2.4 |
||||
Name: vue-fastapi-admin |
||||
Version: 0.1.0 |
||||
Summary: Vue Fastapi admin |
||||
Author-email: mizhexiaoxiao <mizhexiaoxiao@gmail.com> |
||||
Requires-Python: >=3.11 |
||||
License-File: LICENSE |
||||
Requires-Dist: fastapi==0.111.0 |
||||
Requires-Dist: tortoise-orm==0.23.0 |
||||
Requires-Dist: pydantic==2.10.5 |
||||
Requires-Dist: email-validator==2.2.0 |
||||
Requires-Dist: passlib==1.7.4 |
||||
Requires-Dist: pyjwt==2.10.1 |
||||
Requires-Dist: black==24.10.0 |
||||
Requires-Dist: isort==5.13.2 |
||||
Requires-Dist: ruff==0.9.1 |
||||
Requires-Dist: loguru==0.7.3 |
||||
Requires-Dist: pydantic-settings==2.7.1 |
||||
Requires-Dist: argon2-cffi==23.1.0 |
||||
Requires-Dist: pydantic-core==2.27.2 |
||||
Requires-Dist: annotated-types==0.7.0 |
||||
Requires-Dist: setuptools==75.8.0 |
||||
Requires-Dist: uvicorn==0.34.0 |
||||
Requires-Dist: h11==0.14.0 |
||||
Requires-Dist: aerich==0.8.1 |
||||
Requires-Dist: aiosqlite==0.20.0 |
||||
Requires-Dist: anyio==4.8.0 |
||||
Requires-Dist: argon2-cffi-bindings==21.2.0 |
||||
Requires-Dist: asyncclick==8.1.8 |
||||
Requires-Dist: certifi==2024.12.14 |
||||
Requires-Dist: cffi==1.17.1 |
||||
Requires-Dist: click==8.1.8 |
||||
Requires-Dist: dictdiffer==0.9.0 |
||||
Requires-Dist: dnspython==2.7.0 |
||||
Requires-Dist: fastapi-cli==0.0.7 |
||||
Requires-Dist: httpcore==1.0.7 |
||||
Requires-Dist: httptools==0.6.4 |
||||
Requires-Dist: httpx==0.28.1 |
||||
Requires-Dist: idna==3.10 |
||||
Requires-Dist: iso8601==2.1.0 |
||||
Requires-Dist: jinja2==3.1.5 |
||||
Requires-Dist: markdown-it-py==3.0.0 |
||||
Requires-Dist: markupsafe==3.0.2 |
||||
Requires-Dist: mdurl==0.1.2 |
||||
Requires-Dist: mypy-extensions==1.0.0 |
||||
Requires-Dist: orjson==3.10.14 |
||||
Requires-Dist: packaging==24.2 |
||||
Requires-Dist: pathspec==0.12.1 |
||||
Requires-Dist: platformdirs==4.3.6 |
||||
Requires-Dist: pycparser==2.22 |
||||
Requires-Dist: pygments==2.19.1 |
||||
Requires-Dist: pypika-tortoise==0.3.2 |
||||
Requires-Dist: python-dotenv==1.0.1 |
||||
Requires-Dist: python-multipart==0.0.20 |
||||
Requires-Dist: pytz==2024.2 |
||||
Requires-Dist: pyyaml==6.0.2 |
||||
Requires-Dist: rich==13.9.4 |
||||
Requires-Dist: rich-toolkit==0.13.2 |
||||
Requires-Dist: shellingham==1.5.4 |
||||
Requires-Dist: sniffio==1.3.1 |
||||
Requires-Dist: starlette==0.37.2 |
||||
Requires-Dist: typer==0.15.1 |
||||
Requires-Dist: typing-extensions==4.12.2 |
||||
Requires-Dist: ujson==5.10.0 |
||||
Requires-Dist: watchfiles==1.0.4 |
||||
Requires-Dist: websockets==14.1 |
||||
Requires-Dist: pyproject-toml>=0.1.0 |
||||
Requires-Dist: uvloop==0.21.0; sys_platform != "win32" |
||||
Dynamic: license-file |
@ -0,0 +1,9 @@ |
||||
LICENSE |
||||
README.md |
||||
pyproject.toml |
||||
app/__init__.py |
||||
vue_fastapi_admin.egg-info/PKG-INFO |
||||
vue_fastapi_admin.egg-info/SOURCES.txt |
||||
vue_fastapi_admin.egg-info/dependency_links.txt |
||||
vue_fastapi_admin.egg-info/requires.txt |
||||
vue_fastapi_admin.egg-info/top_level.txt |
@ -0,0 +1 @@ |
||||
|
@ -0,0 +1,63 @@ |
||||
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 |
||||
watchfiles==1.0.4 |
||||
websockets==14.1 |
||||
pyproject-toml>=0.1.0 |
||||
|
||||
[:sys_platform != "win32"] |
||||
uvloop==0.21.0 |
@ -0,0 +1 @@ |
||||
app |
@ -0,0 +1,8 @@ |
||||
# 资源公共路径,需要以 /开头和结尾 |
||||
VITE_PUBLIC_PATH = '/' |
||||
|
||||
# 是否启用代理 |
||||
VITE_USE_PROXY = true |
||||
|
||||
# base api |
||||
VITE_BASE_API = 'http://localhost:9999/api/v1' |
@ -0,0 +1,11 @@ |
||||
# 资源公共路径,需要以 /开头和结尾 |
||||
VITE_PUBLIC_PATH = '/' |
||||
|
||||
# base api |
||||
VITE_BASE_API = '/api/v1' |
||||
|
||||
# 是否启用压缩 |
||||
VITE_USE_COMPRESS = true |
||||
|
||||
# 压缩类型 |
||||
VITE_COMPRESS_TYPE = gzip |
@ -0,0 +1,62 @@ |
||||
{ |
||||
"globals": { |
||||
"$loadingBar": true, |
||||
"$message": true, |
||||
"defineOptions": true, |
||||
"$dialog": true, |
||||
"$notification": true, |
||||
"EffectScope": true, |
||||
"computed": true, |
||||
"createApp": true, |
||||
"customRef": true, |
||||
"defineAsyncComponent": true, |
||||
"defineComponent": true, |
||||
"effectScope": true, |
||||
"getCurrentInstance": true, |
||||
"getCurrentScope": true, |
||||
"h": true, |
||||
"inject": true, |
||||
"isProxy": true, |
||||
"isReactive": true, |
||||
"isReadonly": true, |
||||
"isRef": true, |
||||
"markRaw": true, |
||||
"nextTick": true, |
||||
"onActivated": true, |
||||
"onBeforeMount": true, |
||||
"onBeforeUnmount": true, |
||||
"onBeforeUpdate": true, |
||||
"onDeactivated": true, |
||||
"onErrorCaptured": true, |
||||
"onMounted": true, |
||||
"onRenderTracked": true, |
||||
"onRenderTriggered": true, |
||||
"onScopeDispose": true, |
||||
"onServerPrefetch": true, |
||||
"onUnmounted": true, |
||||
"onUpdated": true, |
||||
"provide": true, |
||||
"reactive": true, |
||||
"readonly": true, |
||||
"ref": true, |
||||
"resolveComponent": true, |
||||
"shallowReactive": true, |
||||
"shallowReadonly": true, |
||||
"shallowRef": true, |
||||
"toRaw": true, |
||||
"toRef": true, |
||||
"toRefs": true, |
||||
"triggerRef": true, |
||||
"unref": true, |
||||
"useAttrs": true, |
||||
"useCssModule": true, |
||||
"useCssVars": true, |
||||
"useRoute": true, |
||||
"useRouter": true, |
||||
"useSlots": true, |
||||
"watch": true, |
||||
"watchEffect": true, |
||||
"watchPostEffect": true, |
||||
"watchSyncEffect": true |
||||
} |
||||
} |
@ -0,0 +1,4 @@ |
||||
node_modules |
||||
dist |
||||
public |
||||
package.json |
@ -0,0 +1,26 @@ |
||||
# Logs |
||||
logs |
||||
*.log |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
lerna-debug.log* |
||||
|
||||
node_modules |
||||
.DS_Store |
||||
dist |
||||
dist-ssr |
||||
coverage |
||||
*.local |
||||
stats.html |
||||
|
||||
# Editor directories and files |
||||
.vscode/* |
||||
!.vscode/extensions.json |
||||
.idea |
||||
*.suo |
||||
*.ntvs* |
||||
*.njsproj |
||||
*.sln |
||||
*.sw? |
@ -0,0 +1,3 @@ |
||||
/node_modules/** |
||||
/dist/* |
||||
/public/* |
@ -0,0 +1,6 @@ |
||||
{ |
||||
"printWidth": 100, |
||||
"singleQuote": true, |
||||
"semi": false, |
||||
"endOfLine": "lf" |
||||
} |
@ -0,0 +1,20 @@ |
||||
## 快速开始 |
||||
|
||||
进入前端目录 |
||||
|
||||
```sh |
||||
cd web |
||||
``` |
||||
|
||||
安装依赖(建议使用pnpm: https://pnpm.io/zh/installation) |
||||
|
||||
```sh |
||||
npm i -g pnpm # 已安装可忽略 |
||||
pnpm i # 或者 npm i |
||||
``` |
||||
|
||||
启动 |
||||
|
||||
```sh |
||||
pnpm dev |
||||
``` |
@ -0,0 +1,13 @@ |
||||
import dayjs from 'dayjs' |
||||
|
||||
/** |
||||
* * 此处定义的是全局常量,启动或打包后将添加到window中 |
||||
* https://vitejs.cn/config/#define
|
||||
*/ |
||||
|
||||
// 项目构建时间
|
||||
const _BUILD_TIME_ = JSON.stringify(dayjs().format('YYYY-MM-DD HH:mm:ss')) |
||||
|
||||
export const viteDefine = { |
||||
_BUILD_TIME_, |
||||
} |
@ -0,0 +1 @@ |
||||
export * from './define' |
@ -0,0 +1,23 @@ |
||||
export const OUTPUT_DIR = 'dist' |
||||
|
||||
export const PROXY_CONFIG = { |
||||
// /**
|
||||
// * @desc 替换匹配值
|
||||
// * @请求路径 http://localhost:3100/api/user
|
||||
// * @转发路径 http://localhost:9999/api/v1 +/user
|
||||
// */
|
||||
// '/api': {
|
||||
// target: 'http://localhost:9999/api/v1',
|
||||
// changeOrigin: true,
|
||||
// rewrite: (path) => path.replace(new RegExp('^/api'), ''),
|
||||
// },
|
||||
/** |
||||
* @desc 不替换匹配值 |
||||
* @请求路径 http://localhost:3100/api/v1/user
|
||||
* @转发路径 http://localhost:9999/api/v1/user
|
||||
*/ |
||||
'/api/v1': { |
||||
target: 'http://127.0.0.1:9999', |
||||
changeOrigin: true, |
||||
}, |
||||
} |
@ -0,0 +1,15 @@ |
||||
import { createHtmlPlugin } from 'vite-plugin-html' |
||||
|
||||
export function configHtmlPlugin(viteEnv, isBuild) { |
||||
const { VITE_TITLE } = viteEnv |
||||
|
||||
const htmlPlugin = createHtmlPlugin({ |
||||
minify: isBuild, |
||||
inject: { |
||||
data: { |
||||
title: VITE_TITLE, |
||||
}, |
||||
}, |
||||
}) |
||||
return htmlPlugin |
||||
} |
@ -0,0 +1,35 @@ |
||||
import vue from '@vitejs/plugin-vue' |
||||
|
||||
/** |
||||
* * unocss插件,原子css |
||||
* https://github.com/antfu/unocss
|
||||
*/ |
||||
import Unocss from 'unocss/vite' |
||||
|
||||
// rollup打包分析插件
|
||||
import visualizer from 'rollup-plugin-visualizer' |
||||
// 压缩
|
||||
import viteCompression from 'vite-plugin-compression' |
||||
|
||||
import { configHtmlPlugin } from './html' |
||||
import unplugin from './unplugin' |
||||
|
||||
export function createVitePlugins(viteEnv, isBuild) { |
||||
const plugins = [vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()] |
||||
|
||||
if (viteEnv.VITE_USE_COMPRESS) { |
||||
plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' })) |
||||
} |
||||
|
||||
if (isBuild) { |
||||
plugins.push( |
||||
visualizer({ |
||||
open: true, |
||||
gzipSize: true, |
||||
brotliSize: true, |
||||
}), |
||||
) |
||||
} |
||||
|
||||
return plugins |
||||
} |
@ -0,0 +1,46 @@ |
||||
import { resolve } from 'path' |
||||
import AutoImport from 'unplugin-auto-import/vite' |
||||
import Components from 'unplugin-vue-components/vite' |
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' |
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders' |
||||
import IconsResolver from 'unplugin-icons/resolver' |
||||
|
||||
/** |
||||
* * unplugin-icons插件,自动引入iconify图标 |
||||
* usage: https://github.com/antfu/unplugin-icons
|
||||
* 图标库: https://icones.js.org/
|
||||
*/ |
||||
import Icons from 'unplugin-icons/vite' |
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' |
||||
|
||||
import { getSrcPath } from '../utils' |
||||
|
||||
const customIconPath = resolve(getSrcPath(), 'assets/svg') |
||||
|
||||
export default [ |
||||
AutoImport({ |
||||
imports: ['vue', 'vue-router'], |
||||
dts: false, |
||||
}), |
||||
Icons({ |
||||
compiler: 'vue3', |
||||
customCollections: { |
||||
custom: FileSystemIconLoader(customIconPath), |
||||
}, |
||||
scale: 1, |
||||
defaultClass: 'inline-block', |
||||
}), |
||||
Components({ |
||||
resolvers: [ |
||||
NaiveUiResolver(), |
||||
IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' }), |
||||
], |
||||
dts: false, |
||||
}), |
||||
createSvgIconsPlugin({ |
||||
iconDirs: [customIconPath], |
||||
symbolId: 'icon-custom-[dir]-[name]', |
||||
inject: 'body-last', |
||||
customDomId: '__CUSTOM_SVG_ICON__', |
||||
}), |
||||
] |
@ -0,0 +1,15 @@ |
||||
import { resolve } from 'path' |
||||
import chalk from 'chalk' |
||||
import { writeFileSync } from 'fs-extra' |
||||
import { OUTPUT_DIR } from '../constant' |
||||
import { getEnvConfig, getRootPath } from '../utils' |
||||
|
||||
export function runBuildCNAME() { |
||||
const { VITE_CNAME } = getEnvConfig() |
||||
if (!VITE_CNAME) return |
||||
try { |
||||
writeFileSync(resolve(getRootPath(), `${OUTPUT_DIR}/CNAME`), VITE_CNAME) |
||||
} catch (error) { |
||||
console.log(chalk.red('CNAME file failed to package:\n' + error)) |
||||
} |
||||
} |
@ -0,0 +1,14 @@ |
||||
import chalk from 'chalk' |
||||
import { runBuildCNAME } from './build-cname' |
||||
|
||||
export const runBuild = async () => { |
||||
try { |
||||
runBuildCNAME() |
||||
console.log(`✨ ${chalk.cyan('build successfully!')}`) |
||||
} catch (error) { |
||||
console.log(chalk.red('vite build error:\n' + error)) |
||||
process.exit(1) |
||||
} |
||||
} |
||||
|
||||
runBuild() |