modify: 功能tongbu

This commit is contained in:
2026-04-29 11:24:19 +08:00
parent b1968f5ddc
commit 2dfa836acb
69 changed files with 4817 additions and 518 deletions
+9 -1
View File
@@ -10,4 +10,12 @@ wheels/
.venv
logs/
resources/
output.md
output.md
*.db
*.svfs
# resources
uploads/
# docker
*.tar
@@ -0,0 +1,57 @@
FROM debian:bookworm-slim AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY build/{{ cookiecutter.project_name }} /tmp/{{ cookiecutter.project_name }}
RUN mkdir -p /tmp/rootfs/app && \
# === 临时目录 ===
mkdir -p /tmp/rootfs/tmp && \
chmod 1777 /tmp/rootfs/tmp && \
# === 数据目录 ===
mkdir -p /tmp/rootfs/app/data && \
chmod 777 /tmp/rootfs/app/data && \
# === 解释器目录 ===
mkdir -p /tmp/rootfs/lib64 && \
mkdir -p /tmp/rootfs/lib/x86_64-linux-gnu && \
cp /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
ln -s /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /tmp/rootfs/lib64/ld-linux-x86-64.so.2 && \
# === 依赖库 ===
cp /lib/x86_64-linux-gnu/libdl.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/libz.so.1 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/libpthread.so.0 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/libc.so.6 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/librt.so.1 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/libm.so.6 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/libutil.so.1 /tmp/rootfs/lib/x86_64-linux-gnu/ && \
cp /lib/x86_64-linux-gnu/libnss_dns.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ 2>/dev/null || true && \
cp /lib/x86_64-linux-gnu/libnss_files.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ 2>/dev/null || true && \
cp /lib/x86_64-linux-gnu/libresolv.so.2 /tmp/rootfs/lib/x86_64-linux-gnu/ 2>/dev/null || true && \
# === 时区数据 ===
mkdir -p /tmp/rootfs/usr/share/zoneinfo && \
cp -r /usr/share/zoneinfo/Asia /tmp/rootfs/usr/share/zoneinfo/ && \
cp /usr/share/zoneinfo/UTC /tmp/rootfs/usr/share/zoneinfo/ && \
# === 证书和 DNS ===
mkdir -p /tmp/rootfs/etc/ssl/certs && \
cp /etc/ssl/certs/ca-certificates.crt /tmp/rootfs/etc/ssl/certs/ && \
echo "hosts: files dns" > /tmp/rootfs/etc/nsswitch.conf && \
# === /proc /sys /dev ===
mkdir -p /tmp/rootfs/proc /tmp/rootfs/sys /tmp/rootfs/dev
FROM scratch
COPY --from=builder /tmp/rootfs/ /
COPY build/{{ cookiecutter.project_name }} /app/{{ cookiecutter.project_name }}
COPY build/config.toml /app/config.toml
COPY build/resources/ /app/resources/
COPY build/static.svfs /app/static.svfs
COPY build/data /app/data
WORKDIR /app
EXPOSE 8000
CMD ["./{{ cookiecutter.project_name }}"]
+29 -7
View File
@@ -42,13 +42,12 @@ build
```text
build
├── resources
│   ├── {{ cookiecutter.project_name }}.ico
│   ├── plugins.mbank
│   └── app.mbank
├── config.toml
├── db.sqlite3
└── {{ cookiecutter.project_name }} # windows {{ cookiecutter.project_name }}.exe
build/
├── {{ cookiecutter.project_name }} # 单文件可执行程序 (Linux) 或 {{ cookiecutter.project_name }}.exe (Windows)
├── config.toml # 生产配置(密钥已注入)
├── resources/ # 加密的模块银行 (.mbank) 和图标
├── static.svfs # Vue 前端虚拟文件系统
└── data/ # 初始 SQLite 数据库
```
运行打包后的项目
@@ -56,3 +55,26 @@ build
cd build
{{ cookiecutter.project_name }} # windows {{ cookiecutter.project_name }}.exe
```
## Docker 部署
Docker 镜像采用多角度构建 + `scratch` 基础镜像,真正做到最小化。
### 构建镜像
```bash
docker build -t {{ cookiecutter.project_name }}:latest .
```
### 执行部署脚本
```bash
sudo chmod +x deploy.sh
./deploy.sh
```
部署脚本会:
- 在 `$HOME/{{ cookiecutter.project_name }}-data` 下创建 `data/`, `uploads/`, `logs/` 目录
- 若首次运行,从镜像提取初始数据库
- 启动容器并挂载上述目录,端口 `8000` 映射
- 自动设置 `--restart unless-stopped`
+40 -30
View File
@@ -1,80 +1,90 @@
[default]
app.debug = false
app.description = "基于 FastAPI 的 {{ cookiecutter.project_name }} 打包脚手架"
app.name = "{{ cookiecutter.project_name }}"
app.version = "{{ cookiecutter.project_name }}"
app.description = "基于 FastAPI 和 Tortoise-ORM 的 chongming 打包脚手架"
app.debug = false
env = "development"
prefix = "/api/v1"
upload_path = "uploads"
[database]
database_path = "./data/database.db"
type = "sqlite"
url = "sqlite+aiosqlite:///./database.db"
generate_schemas = true # 生产环境使用迁移工具
url = "sqlite+aiosqlite:///./data/database.db"
[database.sqlite]
echo = true
pool_size = 20
max_overflow = 10
pool_timeout = 30
pool_recycle = 300
pool_pre_ping = true
connect_args.timeout = 15
connect_args.check_same_thread = false
connect_args.timeout = 15
echo = true
echo_pool = true
max_overflow = 10
pool_pre_ping = true
pool_recycle = 300
pool_size = 20
pool_timeout = 30
[scheduler]
job_store_path = "scheduler_jobs.db"
[cache]
cache_store_path = "./"
[security]
secret_key = "your-secret-key-change-in-production"
algorithm = "HS256"
access_token_expire_minutes = 30
refresh_token_expire_days = 7
jwt_cache_db_path = "jwt_cache"
algorithm = "HS256"
max_sessions_per_user = 5
refresh_token_expire_days = 7
secret_key = "your-secret-key-change-in-production"
[development]
env = "development"
debug = true
env = "development"
[development.server]
access_log = false
host = "127.0.0.1"
port = 8000
reload = true
[development.logging]
level = "DEBUG"
file = "logs/dev.log"
file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level = "DEBUG"
[development.cors]
allow_origins = ["*"]
allow_credentials = true
allow_methods = ["*"]
allow_headers = ["*"]
allow_methods = ["*"]
allow_origins = ["*"]
[production]
env = "production"
debug = false
env = "production"
[production.server]
access_log = false
host = "0.0.0.0"
port = 8000
reload = false
workers = 4
[production.logging]
level = "INFO"
file = "logs/prod.log"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
max_size = 10485760 # 10MB
backup_count = 5 # 最多保存 10 个日志文件
backup_count = 5 # 最多保存 10 个日志文件
file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level = "INFO"
max_size = 10485760 # 10MB
[production.cors]
allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
allow_credentials = true
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["Authorization", "Content-Type"]
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
[production.module_system]
type = "module-bank"
path = ["resources/app.mbank", "resources/plugins.mbank"]
type = "module-bank"
[production.file_system]
path = "static.svfs"
type = "svfs"
path = "resources/application.vfs"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# deploy.sh - {{ cookiecutter.project_name }} 快速部署脚本
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 配置
IMAGE="{{ cookiecutter.project_name }}:latest"
CONTAINER="{{ cookiecutter.project_name }}"
PORT=8000
DATA_DIR="${HOME}/{{ cookiecutter.project_name }}-data"
# 检查 docker
if ! command -v docker &> /dev/null; then
log_error "Docker 未安装,请先安装 Docker"
exit 1
fi
# 检查镜像
if ! docker image inspect "$IMAGE" &> /dev/null; then
log_error "镜像 $IMAGE 不存在,请先构建: docker build -t $IMAGE ."
exit 1
fi
# 1. 停止并删除旧容器
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
log_warn "发现旧容器,正在停止并删除..."
docker stop "$CONTAINER" 2>/dev/null || true
docker rm "$CONTAINER" 2>/dev/null || true
fi
# 2. 创建目录
log_info "创建数据目录..."
mkdir -p "$DATA_DIR"/{data,uploads,logs}
chmod 755 "$DATA_DIR"/{data,uploads,logs}
# 3. 复制初始数据(仅在目录为空时)
if [ -z "$(ls -A $DATA_DIR/data 2>/dev/null)" ]; then
log_info "首次部署,从镜像中提取初始数据库..."
TMP_CONTAINER="{{ cookiecutter.project_name }}-tmp-$$"
docker create --name "$TMP_CONTAINER" "$IMAGE" &> /dev/null
docker cp "$TMP_CONTAINER:/app/data/." "$DATA_DIR/data/" &> /dev/null
docker rm "$TMP_CONTAINER" &> /dev/null
log_info "初始数据库已就绪"
else
log_info "数据库已存在,跳过初始化"
fi
# 检查端口是否被占用
if ss -tlnp | grep -q ":${PORT}"; then
log_error "端口 ${PORT} 已被占用,请修改 PORT 变量或释放端口"
exit 1
fi
# 4. 启动容器
log_info "启动容器..."
docker run -d \
-p "$PORT:8000" \
-v "$DATA_DIR/data:/app/data" \
-v "$DATA_DIR/uploads:/app/uploads" \
-v "$DATA_DIR/logs:/app/logs" \
--restart unless-stopped \
--name "$CONTAINER" \
"$IMAGE"
# 5. 等待启动
log_info "等待应用启动..."
sleep 3
# 6. 检查状态
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
log_info "=========================================="
log_info "✅ 部署成功!"
log_info "=========================================="
log_info "应用地址: http://localhost:$PORT"
log_info "API 文档: http://localhost:$PORT/docs"
log_info "数据目录: $DATA_DIR"
log_info ""
log_info "查看日志: docker logs -f $CONTAINER"
log_info "停止服务: docker stop $CONTAINER"
log_info "重启服务: docker restart $CONTAINER"
log_info "=========================================="
else
log_error "容器启动失败,查看日志:"
docker logs "$CONTAINER" 2>/dev/null || true
exit 1
fi
@@ -1,78 +1,91 @@
[default]
app.debug = false
app.description = "基于 FastAPI 的 {{ cookiecutter.project_name }} 打包脚手架"
app.name = "{{ cookiecutter.project_name }}"
app.version = "0.1.0"
app.description = "基于 FastAPI 的 chongming 打包脚手架"
app.debug = false
env = "production"
prefix = "/api/v1"
upload_path = "uploads"
[database]
type = "sqlite"
url = "sqlite+aiosqlite:///./database.db"
generate_schemas = true # 生产环境使用迁移工具
generate_schemas = true # 生产环境使用迁移工具
type = "sqlite"
url = "sqlite+aiosqlite:///./data/database.db"
[database.sqlite]
echo = true
pool_size = 20
max_overflow = 10
pool_timeout = 30
pool_recycle = 300
pool_pre_ping = true
connect_args.timeout = 15
connect_args.check_same_thread = false
connect_args.timeout = 15
echo = false
echo_pool = false
max_overflow = 10
pool_pre_ping = true
pool_recycle = 300
pool_size = 20
pool_timeout = 30
[scheduler]
job_store_path = "scheduler_jobs.db"
[cache]
cache_store_path = "./"
[security]
secret_key = "your-secret-key-change-in-production"
algorithm = "HS256"
access_token_expire_minutes = 30
refresh_token_expire_days = 7
jwt_cache_db_path = "jwt_cache"
algorithm = "HS256"
max_sessions_per_user = 5
refresh_token_expire_days = 7
secret_key = "your-secret-key-change-in-production"
[development]
env = "development"
debug = true
env = "development"
[development.server]
access_log = false
host = "127.0.0.1"
port = 8001
reload = true
[development.logging]
level = "DEBUG"
file = "logs/dev.log"
file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level = "DEBUG"
[development.cors]
allow_origins = ["*"]
allow_credentials = true
allow_methods = ["*"]
allow_headers = ["*"]
allow_methods = ["*"]
allow_origins = ["*"]
[production]
env = "production"
debug = false
env = "production"
[production.server]
access_log = false
host = "0.0.0.0"
port = 8000
reload = false
timeout_keep_alive = 65
workers = 4
[production.logging]
level = "INFO"
file = "logs/prod.log"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
backup_count = 5 # 最多保存 10 个日志文件
file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level = "INFO"
max_size = 10485760 # 10MB
[production.cors]
allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
allow_credentials = true
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["Authorization", "Content-Type"]
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_origins = ["https://yourdomain.com", "http://localhost:8000", "http://127.0.0.1:8000"]
[production.module_system]
type = "module-bank"
path = ["resources/app.mbank", "resources/plugins.mbank"]
type = "module-bank"
[production.file_system]
path = "static.svfs"
type = "svfs"
path = "resources/application.vfs"
+48 -17
View File
@@ -1,33 +1,39 @@
# main.py - 修复 reload 问题
#!/usr/bin/env python3
"""
FastAPI + Tortoise-ORM + {{ cookiecutter.project_name }} 示例
主入口文件,适配 {{ cookiecutter.project_name }} 打包系统
FastAPI chongming 示例
主入口文件,适配 chongming 打包系统
"""
import sys
import platform
from utils.launch import launch
from multiprocessing import freeze_support
import server
use_shell = platform.system() == "Windows"
def app_run(app_config: dict, default_config: dict):
"""
应用运行函数 - 适配模块银行环境
Args:
app_config: 应用配置
default_config: 默认配置
"""
import uvicorn
from app.core.logger import setup_logging, get_logger # type: ignore
app_config: 应用配置字典,包含服务器配置、环境设置等
default_config: 默认配置字典,包含应用名称、版本等基础信息
Returns:
None: 此函数不返回值,直接启动服务器并阻塞运行
"""
from app.core.logger import get_logger # type: ignore
setup_logging(app_config["logging"])
logger = get_logger("app")
# 获取服务器配置
server_config = app_config.get("server", {})
host = server_config.get("host", "0.0.0.0")
port = server_config.get("port", 8000)
timeout_keep_alive = server_config.get("timeout_keep_alive", 5)
# 打包环境强制禁用重载
is_frozen = getattr(sys, "frozen", False)
@@ -47,18 +53,43 @@ def app_run(app_config: dict, default_config: dict):
logger.info(f"👥 Workers: {workers}")
logger.info("=" * 50)
uvicorn.run(
"server:app",
host=host,
port=port,
reload=reload,
workers=workers,
log_level="debug" if app_config.get("debug", False) else "info",
access_log=True,
)
# 根据操作系统选择不同的服务器启动方式
if use_shell:
import uvicorn
uvicorn.run(
"server:app",
host=host,
port=port,
reload=reload,
workers=workers,
log_level="debug" if app_config.get("debug", False) else "info",
access_log=False,
timeout_keep_alive=timeout_keep_alive,
)
else:
from gunicorn.app.wsgiapp import run
sys.argv = [
"gunicorn",
"server:app",
"-k",
"uvicorn.workers.UvicornWorker",
"-w",
str(workers),
"-b",
f"{host}:{port}",
]
run()
def main():
"""
主函数入口 - 初始化多进程支持并启动应用
Returns:
None: 此函数不返回值,调用launch函数启动应用
"""
freeze_support()
launch(app_run, "production")
@@ -3,17 +3,19 @@ import sys
from utils.launch import init_module_bank
from utils.config import ConfigLoader
# 加载配置文件并初始化模块银行
config = ConfigLoader("config.toml")
init_module_bank(config)
# 从数据库导入真正的 app 模块
# 从数据库动态导入 app 模块
# 该模块存储在 SQLite 数据库中,通过自定义导入机制加载
try:
import app # type: ignore # 此时会从 SQLite 数据库加载
import app # type: ignore
app_instance = app.app # 获取 FastAPI 实例
app_instance = app.app
except ImportError as e:
print(f"无法从数据库导入 app 模块: {e}")
sys.exit(1)
# 暴露 ASGI 应用变量,供 uvicorn 使用
# 暴露 ASGI 应用实例,供 uvicorn 服务器使用
app = app_instance
@@ -6,6 +6,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, Optional
# 根据Python版本选择合适的TOML解析库
if sys.version_info >= (3, 11):
import tomllib
else:
@@ -13,7 +14,15 @@ else:
class ConfigLoader:
"""TOML配置文件加载器"""
"""TOML配置文件加载器
提供配置文件的加载、访问和管理功能。支持自动查找配置文件、
点号分隔的嵌套键访问、配置节获取以及配置重载等功能。
Attributes:
config_path: 配置文件的路径
_config: 存储解析后的配置数据
"""
def __init__(self, config_path: Optional[str] = None):
"""
@@ -21,6 +30,9 @@ class ConfigLoader:
Args:
config_path: 配置文件路径,如果为None则自动查找
Raises:
FileNotFoundError: 当无法找到配置文件时抛出异常
"""
if config_path is None:
# 自动查找配置文件
@@ -31,7 +43,21 @@ class ConfigLoader:
self.load()
def _find_config_file(self) -> str:
"""自动查找配置文件"""
"""
自动查找配置文件
按照预定义的优先级顺序搜索配置文件:
1. 当前目录下的config.toml
2. config子目录下的config.toml
3. 用户配置目录~/.config/myapp/config.toml
4. 系统配置目录/etc/myapp/config.toml
Returns:
str: 找到的第一个配置文件路径
Raises:
FileNotFoundError: 当所有可能的位置都未找到配置文件时抛出异常
"""
possible_paths = [
"config.toml",
"config/config.toml",
@@ -47,7 +73,15 @@ class ConfigLoader:
raise FileNotFoundError("未找到配置文件")
def load(self) -> None:
"""加载配置文件"""
"""
加载配置文件
读取并解析TOML格式的配置文件,将配置数据存储到内部字典中。
Raises:
FileNotFoundError: 当配置文件不存在时抛出异常
tomllib.TOMLDecodeError: 当配置文件格式不正确时抛出异常
"""
if not self.config_path.exists():
raise FileNotFoundError(f"配置文件不存在: {self.config_path}")
@@ -55,7 +89,20 @@ class ConfigLoader:
self._config = tomllib.loads(f.read())
def get(self, key: str, default: Any = None) -> Any:
"""获取配置值,支持点号分隔的路径"""
"""
获取配置值,支持点号分隔的路径
通过点号分隔的键路径访问嵌套的配置值。例如:
- "database.host" 访问 {"database": {"host": "localhost"}}
- "server.port" 访问 {"server": {"port": 8080}}
Args:
key: 配置键,支持点号分隔的嵌套路径
default: 当键不存在时返回的默认值,默认为None
Returns:
Any: 配置值,如果键不存在则返回默认值
"""
keys = key.split(".")
value = self._config
@@ -67,22 +114,71 @@ class ConfigLoader:
return default
def get_section(self, section: str) -> Dict[str, Any]:
"""获取整个配置节"""
"""
获取整个配置节
返回指定配置节的完整字典内容,适用于需要访问某个配置节下
所有配置项的场景。
Args:
section: 配置节名称
Returns:
Dict[str, Any]: 配置节的内容字典,如果节不存在则返回空字典
"""
return self._config.get(section, {})
def reload(self) -> None:
"""重新加载配置文件"""
"""
重新加载配置文件
从磁盘重新读取配置文件并更新内部配置数据。
适用于配置文件在运行时被修改的场景。
Raises:
FileNotFoundError: 当配置文件不存在时抛出异常
"""
self.load()
def __getitem__(self, key: str) -> Any:
"""支持字典式访问"""
"""
支持字典式访问
允许使用方括号语法访问配置值,如:config["database.host"]
Args:
key: 配置键
Returns:
Any: 配置值
Raises:
KeyError: 当键不存在且无默认值时抛出异常(通过get方法处理)
"""
return self.get(key)
def __contains__(self, key: str) -> bool:
"""检查配置是否存在"""
"""
检查配置是否存在
支持使用in操作符检查配置键是否存在,如:"database.host" in config
Args:
key: 要检查的配置键
Returns:
bool: 如果配置键存在且值不为None则返回True,否则返回False
"""
return self.get(key) is not None
@property
def all(self) -> Dict[str, Any]:
"""获取所有配置"""
"""
获取所有配置
返回完整的配置字典,适用于需要遍历或导出所有配置的场景。
Returns:
Dict[str, Any]: 包含所有配置的字典
"""
return self._config
@@ -7,14 +7,29 @@ key = None
def init_module_bank(config):
"""
初始化模块银行系统,将指定路径下的Python模块打包并安装导入器。
该函数会验证所有模块路径的有效性,创建PythonToSQLite打包器实例,
列出所有可用模块,并为每个打包器安装导入器以支持模块的动态加载。
Args:
config (dict): 配置字典,必须包含 'production' -> 'module_system' -> 'path' 键,
其值为模块路径列表
Raises:
ValueError: 当配置的模块路径中有任何一个不存在时抛出异常
"""
MODULE_BANK_PATH_LIST = config["production"]["module_system"]["path"]
for module_path in MODULE_BANK_PATH_LIST:
if os.path.exists(module_path) == False:
if not os.path.exists(module_path):
raise ValueError(f"模块路径不存在: {module_path}")
# 为每个模块路径创建PythonToSQLite打包器实例
packer_list = [
PythonToSQLite(module_path, key=key) for module_path in MODULE_BANK_PATH_LIST
]
# 遍历所有打包器,列出模块信息并安装导入器
for packer in packer_list:
modules = packer.list_modules()
for module in modules:
@@ -23,6 +38,21 @@ def init_module_bank(config):
def launch(run_fun: Callable[[dict, dict], None], run_mode: str):
"""
启动应用程序,加载配置并执行指定的运行函数。
该函数从config.toml文件中加载配置,根据运行模式获取对应的应用配置和默认配置,
然后调用传入的运行函数执行应用程序逻辑。
Args:
run_fun (Callable[[dict, dict], None]): 运行函数,接收两个参数:
- app_config (dict): 当前运行模式的配置
- default_config (dict): 默认配置
run_mode (str): 运行模式,用于从配置文件中获取对应模式的配置项
Returns:
None
"""
config = ConfigLoader("config.toml")
# 获取对应运行模式的配置
@@ -9,14 +9,17 @@ description = "基于 modules-bank 的 fastapi 打包脚手架"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"aiofiles",
"aiosqlite",
"bcrypt==4.0.1",
"apscheduler",
"bcrypt",
"diskcache",
"fastapi[uvicorn]",
"gunicorn",
"module-bank",
"passlib[bcrypt]",
"pyjwt",
"python-multipart",
"sqlite-vfs",
"sqlitedict",
"sqlmodel[aiosqlite]",
"tomli",
"typing-extensions",
@@ -35,6 +38,7 @@ url = "https://mirrors.ustc.edu.cn/pypi/web/simple/"
dev = [
"pyarmor",
"pyinstaller",
"ruff>=0.15.10",
]
[tool.hatch.build.targets.wheel]
@@ -42,4 +46,5 @@ packages = ["src/{{ cookiecutter.project_name }}"]
[project.scripts]
build = "{{ cookiecutter.project_name }}.scripts.build:build"
serve = "{{ cookiecutter.project_name }}:serve"
serve = "{{ cookiecutter.project_name }}:serve"
gunicorn = "{{ cookiecutter.project_name }}:gunicorn_serve"
@@ -4,6 +4,6 @@
"pythonVersion": "{{ cookiecutter.python_version }}",
"extraPaths": [
"./",
"./.venv/lib/python3.12/site-packages"
"./.venv/lib/python{{ cookiecutter.python_version }}/site-packages"
]
}
@@ -1,16 +1,15 @@
aerich
aiofiles
aiosqlite
apscheduler
bcrypt
diskcache
fastapi[uvicorn]
module-bank==0.1.1
passlib
pydantic-settings
pydantic[email]
sqlite-vfs
toml
tortoise-orm
uvicorn[standard]
sqlitedict
gunicorn
module-bank
pyjwt
bcrypt==4.0.1
pyinstaller
aerich
requests
python-multipart
sqlite-vfs
sqlmodel[aiosqlite]
tomli
typing-extensions
uvicorn
@@ -1,17 +1,12 @@
import jwt
import json
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, TypedDict
from sqlitedict import SqliteDict
from typing import Optional, Dict, Any, List
from pydantic import BaseModel
from dataclasses import dataclass, asdict
# from pydantic import BaseModel
import hashlib
import threading
from pathlib import Path
from .config import get_config
import jwt
@dataclass
@@ -31,6 +26,7 @@ class TokenCacheData:
token_type: str = "access" # access 或 refresh
def __post_init__(self):
"""初始化后处理,设置默认时间戳"""
if self.created_at is None:
self.created_at = datetime.utcnow().isoformat()
if self.last_used is None:
@@ -38,54 +34,37 @@ class TokenCacheData:
class JWTCache:
"""JWT Token 缓存管理器"""
"""JWT Token 缓存管理器
def __init__(self, db_path: Optional[str] = None):
self.config = get_config()
负责 JWT Token 的创建、验证、失效和会话管理。
使用 Redis 缓存存储 Token 哈希和用户会话索引,支持多设备登录和会话限制。
"""
if db_path is None:
db_path = self.config["security"]["jwt_cache_db_path"]
def __init__(self, config, cache):
"""初始化 JWT 缓存管理器
db_file_path = Path(db_path)
db_file_path.mkdir(parents=True, exist_ok=True)
Args:
config: 配置字典,包含 security 相关配置项
cache: 缓存实例,提供 get/set/delete/exists/keys 等方法
"""
self.config = config
self.lock = threading.RLock()
# 主 Token 缓存
self.token_db = SqliteDict(
rf"{db_path}/tokens.cache",
tablename="tokens",
autocommit=True,
encode=self._json_encode,
decode=self._json_decode,
journal_mode="WAL",
)
# 黑名单缓存
self.blacklist_db = SqliteDict(
rf"{db_path}/blacklist.cache",
tablename="blacklist",
autocommit=True,
encode=json.dumps,
decode=json.loads,
journal_mode="WAL",
)
# 用户会话索引
self.user_sessions_db = SqliteDict(
rf"{db_path}/user_sessions.cache",
tablename="user_sessions",
autocommit=True,
encode=json.dumps,
decode=json.loads,
journal_mode="WAL",
)
self.cache = cache
self.secret_key = self.config["security"]["secret_key"]
self.algorithm = self.config["security"]["algorithm"]
def _json_encode(self, obj):
"""自定义 JSON 编码器,处理 datetime 对象"""
"""自定义 JSON 编码器,处理 datetime 对象
Args:
obj: 需要序列化的对象
Returns:
JSON 字符串
"""
def convert_datetime(o):
if isinstance(o, datetime):
@@ -97,11 +76,25 @@ class JWTCache:
return json.dumps(obj, default=convert_datetime)
def _json_decode(self, s):
"""自定义 JSON 解码器"""
"""自定义 JSON 解码器
Args:
s: JSON 字符串
Returns:
解码后的 Python 对象
"""
return json.loads(s)
def _hash_token(self, token: str) -> str:
"""Token 哈希化(避免存储明文 Token)"""
"""Token 哈希化(避免存储明文 Token)
Args:
token: 原始 JWT Token 字符串
Returns:
SHA256 哈希值(十六进制字符串)
"""
return hashlib.sha256(token.encode()).hexdigest()
def create_token(
@@ -113,16 +106,18 @@ class JWTCache:
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> str:
"""
创建并缓存 JWT Token
"""创建并缓存 JWT Token
生成新的 JWT Token,将其哈希值和元数据存储到缓存中,
并更新用户会话索引。如果超出会话限制,会自动清理最早的会话。
Args:
user_id: 用户ID
user_data: 用户数据
user_data: 用户数据,将嵌入到 Token payload 中
token_type: Token 类型(access 或 refresh)
device_id: 设备ID
user_agent: 用户代理
ip_address: IP地址
device_id: 设备ID,用于标识登录设备
user_agent: 用户代理字符串,记录客户端信息
ip_address: IP地址,记录登录来源
Returns:
JWT Token 字符串
@@ -168,7 +163,7 @@ class JWTCache:
)
# 存储到缓存
self.token_db[token_hash] = asdict(cache_data)
self.cache.set(f"token:{token_hash}", asdict(cache_data))
# 更新用户会话索引
self._update_user_session_index(user_id, token_hash, token_type)
@@ -179,7 +174,11 @@ class JWTCache:
return token
def _generate_jti(self) -> str:
"""生成唯一的 JWT ID"""
"""生成唯一的 JWT ID
Returns:
UUID v4 字符串,用作 Token 的唯一标识符
"""
import uuid
return str(uuid.uuid4())
@@ -187,10 +186,19 @@ class JWTCache:
def _update_user_session_index(
self, user_id: str, token_hash: str, token_type: str
):
"""更新用户会话索引"""
"""更新用户会话索引
维护每个用户每种 Token 类型的会话列表,限制最大数量为 100。
当超过限制时,保留最新的 100 个会话。
Args:
user_id: 用户ID
token_hash: Token 的 SHA256 哈希值
token_type: Token 类型(access 或 refresh)
"""
with self.lock:
key = f"{user_id}:{token_type}"
user_tokens = self.user_sessions_db.get(key, [])
key = f"user:{user_id}:{token_type}"
user_tokens = self.cache.get(key, [])
# 添加新 token
user_tokens.append(token_hash)
@@ -200,49 +208,63 @@ class JWTCache:
if len(user_tokens) > max_tokens:
user_tokens = user_tokens[-max_tokens:]
self.user_sessions_db[key] = user_tokens
self.cache.set(key, user_tokens)
def _enforce_session_limit(self, user_id: str):
"""强制用户会话限制"""
"""强制用户会话限制
检查用户的 access 和 refresh token 数量,如果超过配置的最大会话数,
则删除最早的会话以维持限制。
Args:
user_id: 用户ID
"""
max_sessions = self.config["security"]["max_sessions_per_user"]
access_key = f"{user_id}:access"
refresh_key = f"{user_id}:refresh"
access_key = f"user:{user_id}:access"
refresh_key = f"user:{user_id}:refresh"
# 检查 access token 数量
access_tokens = self.user_sessions_db.get(access_key, [])
access_tokens = self.cache.get(access_key, [])
if len(access_tokens) > max_sessions:
# 删除最早的 token
tokens_to_remove = access_tokens[:-max_sessions]
for token_hash in tokens_to_remove:
if token_hash in self.token_db:
del self.token_db[token_hash]
if self.cache.exists(f"token:{token_hash}"):
self.cache.delete(f"token:{token_hash}")
# 更新索引
self.user_sessions_db[access_key] = access_tokens[-max_sessions:]
self.cache.set(access_key, access_tokens[-max_sessions:])
# 检查 refresh token 数量
refresh_tokens = self.user_sessions_db.get(refresh_key, [])
refresh_tokens = self.cache.get(refresh_key, [])
if len(refresh_tokens) > max_sessions:
tokens_to_remove = refresh_tokens[:-max_sessions]
for token_hash in tokens_to_remove:
if token_hash in self.token_db:
del self.token_db[token_hash]
if self.cache.exists(f"token:{token_hash}"):
self.cache.delete(f"token:{token_hash}")
self.user_sessions_db[refresh_key] = refresh_tokens[-max_sessions:]
self.cache.set(refresh_key, refresh_tokens[-max_sessions:])
def validate_token(
self, token: str, token_type: str = "access"
) -> Optional[Dict[str, Any]]:
"""
验证 JWT Token
"""验证 JWT Token
执行多层验证:JWT 签名验证、类型检查、缓存存在性检查、
活跃状态检查和过期时间检查。验证成功后更新最后使用时间。
Args:
token: JWT Token
token_type: 期望的 Token 类型
token: JWT Token 字符串
token_type: 期望的 Token 类型(access 或 refresh)
Returns:
验证成功返回用户数据,失败返回 None
验证成功返回包含用户信息的字典,包含以下字段:
- user_id: 用户ID
- device_id: 设备ID
- payload: JWT payload 数据
- cache_data: 缓存中的完整会话数据
验证失败返回 None
"""
try:
# 解码验证 Token
@@ -259,14 +281,11 @@ class JWTCache:
if not user_id or not jti:
return None
# 检查黑名单
token_hash = self._hash_token(token)
if self._is_blacklisted(token_hash):
return None
# 检查缓存
with self.lock:
cache_data = self.token_db.get(token_hash)
cache_data = self.cache.get(f"token:{token_hash}")
if not cache_data:
return None
@@ -279,12 +298,12 @@ class JWTCache:
if datetime.utcnow() > expires_at:
# 标记为不活跃
cache_data["is_active"] = False
self.token_db[token_hash] = cache_data
self.cache.set(f"token:{token_hash}", cache_data)
return None
# 更新最后使用时间
cache_data["last_used"] = datetime.utcnow().isoformat()
self.token_db[token_hash] = cache_data
self.cache.set(f"token:{token_hash}", cache_data)
return {
"user_id": user_id,
@@ -300,81 +319,84 @@ class JWTCache:
except Exception:
return None
def add_to_blacklist(self, token: str, reason: str = "logout"):
"""添加 Token 到黑名单"""
token_hash = self._hash_token(token)
expire_time = datetime.utcnow() + timedelta(days=7) # 黑名单保留7天
self.blacklist_db[token_hash] = {
"reason": reason,
"blacklisted_at": datetime.utcnow().isoformat(),
"expires_at": expire_time.isoformat(),
}
def _is_blacklisted(self, token_hash: str) -> bool:
"""检查 Token 是否在黑名单中"""
with self.lock:
if token_hash not in self.blacklist_db:
return False
blacklist_data = self.blacklist_db[token_hash]
expires_at = datetime.fromisoformat(blacklist_data["expires_at"])
# 如果黑名单条目已过期,删除它
if datetime.utcnow() > expires_at:
del self.blacklist_db[token_hash]
return False
return True
def invalidate_token(self, token: str):
"""使单个 Token 失效"""
"""使单个 Token 失效
通过标记缓存中的 Token 为非活跃状态来实现失效,
而不是直接删除,以便保留审计信息。
Args:
token: 需要失效的 JWT Token 字符串
"""
with self.lock:
token_hash = self._hash_token(token)
# 添加到黑名单
self.add_to_blacklist(token)
# 标记为不活跃
if token_hash in self.token_db:
cache_data = self.token_db[token_hash]
if self.cache.exists(f"token:{token_hash}"):
cache_data = self.cache.get(f"token:{token_hash}")
cache_data["is_active"] = False
self.token_db[token_hash] = cache_data
self.cache.set(f"token:{token_hash}", cache_data)
def invalidate_user_tokens(self, user_id: str, token_type: Optional[str] = None):
"""使用户的所有 Token 失效"""
"""使用户的所有 Token 失效
批量失效指定用户的所有 Token,可用于用户登出、密码修改等场景。
可以指定失效特定类型的 Token,或者同时失效 access 和 refresh token。
Args:
user_id: 用户ID
token_type: 可选,指定要失效的 Token 类型(access 或 refresh)。
如果不指定,则失效该用户的所有类型 Token
"""
with self.lock:
if token_type:
keys = [f"{user_id}:{token_type}"]
keys = [f"user:{user_id}:{token_type}"]
else:
keys = [f"{user_id}:access", f"{user_id}:refresh"]
keys = [f"user:{user_id}:access", f"user:{user_id}:refresh"]
for key in keys:
token_hashes = self.user_sessions_db.get(key, [])
token_hashes = self.cache.get(key, [])
for token_hash in token_hashes:
# 添加到黑名单
# 注意:这里我们只有 token_hash,没有原始 token
# 所以只能标记为不活跃
if token_hash in self.token_db:
cache_data = self.token_db[token_hash]
if self.cache.exists(f"token:{token_hash}"):
cache_data = self.cache.get(f"token:{token_hash}")
cache_data["is_active"] = False
self.token_db[token_hash] = cache_data
self.cache.set(f"token:{token_hash}", cache_data)
# 清空用户会话索引
self.user_sessions_db[key] = []
self.cache.set(key, [])
def get_user_sessions(self, user_id: str) -> List[Dict[str, Any]]:
"""获取用户的所有活跃会话"""
"""获取用户的所有活跃会话
遍历用户的所有 Token 会话,过滤出当前活跃且未过期的会话,
返回包含设备信息、时间戳等详细信息的会话列表。
Args:
user_id: 用户ID
Returns:
活跃会话列表,每个会话包含以下字段:
- token_type: Token 类型
- device_id: 设备ID
- user_agent: 用户代理
- ip_address: IP地址
- created_at: 创建时间
- last_used: 最后使用时间
- expires_at: 过期时间
"""
with self.lock:
sessions = []
for token_type in ["access", "refresh"]:
key = f"{user_id}:{token_type}"
token_hashes = self.user_sessions_db.get(key, [])
key = f"user:{user_id}:{token_type}"
token_hashes = self.cache.get(key, [])
for token_hash in token_hashes:
if token_hash in self.token_db:
cache_data = self.token_db[token_hash]
if self.cache.exists(f"token:{token_hash}"):
cache_data = self.cache.get(f"token:{token_hash}")
# 检查是否活跃且未过期
if cache_data.get("is_active", True):
@@ -397,50 +419,62 @@ class JWTCache:
return sessions
def cleanup_expired(self):
"""清理过期数据"""
"""清理过期数据
扫描所有 Token 缓存,删除已过期的 Token 记录。
建议定期调用此方法以释放缓存空间。
Returns:
清理的过期 Token 数量
"""
with self.lock:
cleaned_count = 0
current_time = datetime.utcnow()
# 清理过期 Token
for token_hash, data in list(self.token_db.items()):
expires_at = datetime.fromisoformat(data["expires_at"])
for token_hash in self.cache.keys("token:*"):
expires_at = datetime.fromisoformat(self.cache.get(token_hash))
if current_time > expires_at:
del self.token_db[token_hash]
cleaned_count += 1
# 清理过期黑名单
for token_hash, data in list(self.blacklist_db.items()):
expires_at = datetime.fromisoformat(data["expires_at"])
if current_time > expires_at:
del self.blacklist_db[token_hash]
self.cache.delete(token_hash)
cleaned_count += 1
return cleaned_count
def close(self):
with self.lock:
"""关闭所有数据库连接"""
self.token_db.close()
self.blacklist_db.close()
self.user_sessions_db.close()
# 全局缓存实例
_jwt_cache = None
def get_jwt_cache() -> JWTCache:
"""获取 JWT 缓存实例(单例模式)"""
def get_jwt_cache(config=None, cache=None) -> JWTCache:
"""获取 JWT 缓存实例(单例模式)
确保整个应用中只有一个 JWTCache 实例,避免重复初始化和资源浪费。
首次调用时必须提供 config 和 cache 参数。
Args:
config: 配置字典,仅在首次初始化时需要
cache: 缓存实例,仅在首次初始化时需要
Returns:
JWTCache 单例实例
Raises:
ValueError: 首次调用时如果 config 或 cache 为空则抛出异常
"""
global _jwt_cache
if _jwt_cache is None:
_jwt_cache = JWTCache()
if config is None or cache is None:
raise ValueError("参数 config 和 cache 不能为空")
_jwt_cache = JWTCache(config, cache)
return _jwt_cache
# Token 响应模型
class TokenResponse(BaseModel):
"""Token 响应模型"""
"""Token 响应模型
用于登录接口返回 access token 和 refresh token
"""
access_token: str
refresh_token: str
@@ -449,9 +483,22 @@ class TokenResponse(BaseModel):
refresh_expires_in: int
class RefreshTokenResponse(BaseModel):
"""刷新 Token 响应模型
用于刷新 token 接口,仅返回新的 access token
"""
access_token: str
token_type: str = "bearer"
# Token 数据模型
class TokenData(BaseModel):
"""Token 数据模型"""
"""Token 数据模型
用于从 Token 中提取的用户身份信息
"""
user_id: str
username: Optional[str] = None
@@ -0,0 +1,560 @@
"""
APScheduler 集成模块
提供持久化任务调度服务
"""
import os
import sys
import tempfile
from contextlib import suppress
from typing import Dict, List, Optional, Callable, AsyncGenerator
from datetime import datetime
from fastapi import Request
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.job import Job
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
def acquire_scheduler_lock():
"""
获取调度器分布式锁,确保只有一个进程实例运行调度器
通过在临时目录创建锁文件并使用文件系统锁机制,防止多个进程同时启动调度器。
支持 Windows 和 Unix/Linux 系统的不同锁实现。
Returns:
tuple: 包含两个元素的元组
- lock_file (file object or None): 锁文件对象,如果获取锁失败则为 None
- bool: 是否成功获取锁,成功为 True,失败为 False
"""
lock_path = os.path.join(tempfile.gettempdir(), "chongming_scheduler.lock")
lock_file = open(lock_path, "w")
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_NB | fcntl.LOCK_EX)
return lock_file, True
except (IOError, OSError):
lock_file.close()
return None, False
class TaskService:
"""
任务调度服务类
基于 APScheduler 实现的异步任务调度服务,支持多种触发器类型(间隔、Cron、一次性)。
使用 SQLite 数据库持久化存储任务信息,确保服务重启后任务不丢失。
通过文件锁机制保证多进程环境下只有一个实例执行调度任务。
"""
def __init__(self, config, logger, async_session_maker=None):
"""
初始化任务调度服务
Args:
config (dict): 配置字典,包含调度器相关配置项
- scheduler.job_store_path (str): 任务存储数据库路径,默认为 "scheduler_jobs.db"
logger (logging.Logger): 日志记录器实例,用于记录调度器运行日志
async_session_maker (callable, optional): 异步会话工厂函数,用于数据库操作,默认为 None
Attributes:
async_session_maker: 异步会话工厂函数
job_store_db_path: 任务存储数据库文件路径
scheduler: AsyncIOScheduler 调度器实例
_started (bool): 调度器是否已启动的标志
lock_file: 锁文件对象
is_scheduler_worker (bool): 当前进程是否为调度器工作进程
logger: 日志记录器实例
"""
self.async_session_maker = async_session_maker
# 使用独立的 SQLite 数据库存储任务(与业务数据库分开)
self.job_store_db_path = config.get("scheduler", {}).get(
"job_store_path", "scheduler_jobs.db"
)
job_store_url = f"sqlite:///{self.job_store_db_path}"
# 配置 job stores 和 executors
jobstores = {"default": SQLAlchemyJobStore(url=job_store_url)}
executors = {"default": AsyncIOExecutor()}
job_defaults = {
"coalesce": True, # 合并错过的任务
"max_instances": 3, # 最大并发实例数
"misfire_grace_time": 30, # 错过任务的宽限时间(秒)
}
self.scheduler = AsyncIOScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults,
timezone="Asia/Shanghai",
)
self._started = False
self.lock_file, self.is_scheduler_worker = acquire_scheduler_lock()
self.logger = logger
async def start(self, init_tasks_callback: Optional[Callable] = None):
"""
启动任务调度器
仅在当前进程成功获取调度器锁时启动调度器,避免多进程重复启动。
启动后可选择执行初始化任务回调函数。
Args:
init_tasks_callback (callable, optional): 初始化任务回调函数,接收 TaskService 实例作为参数。
用于在调度器启动后注册初始任务。默认为 None。
Note:
- 如果调度器已经启动,不会重复启动
- 只有成功获取锁的进程才会启动调度器
"""
if self.is_scheduler_worker:
if not self._started:
self.scheduler.start()
self._started = True
self.logger.info("任务调度器已启动")
if init_tasks_callback:
await init_tasks_callback(self)
async def shutdown(self, wait: bool = True):
"""
关闭任务调度器
优雅地关闭调度器,包括暂停所有运行中的任务、关闭调度器、释放文件锁。
Args:
wait (bool): 是否等待正在执行的任务完成后再关闭。
True 表示等待任务完成,False 表示立即关闭。默认为 True。
Note:
- 先暂停所有任务,再关闭调度器
- 释放文件锁以允许其他进程获取锁
- 使用 suppress 捕获所有异常,确保关闭过程不会抛出异常
"""
if self.lock_file:
with suppress(Exception):
for job in await self.get_jobs():
await self.pause_job(job.get("id", ""))
if self._started:
self.scheduler.shutdown(wait=wait)
self._started = False
self.logger.info("任务调度器已关闭")
if sys.platform == "win32":
import msvcrt
msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
self.lock_file.close()
async def add_interval_job(
self,
func: Callable,
seconds: int = 0,
minutes: int = 0,
hours: int = 0,
days: int = 0,
job_id: Optional[str] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
replace_existing: bool = True,
) -> Job:
"""
添加间隔执行任务
创建按照固定时间间隔重复执行的任务。
Args:
func (callable): 要执行的任务函数
seconds (int): 间隔秒数,默认为 0
minutes (int): 间隔分钟数,默认为 0
hours (int): 间隔小时数,默认为 0
days (int): 间隔天数,默认为 0
job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
Returns:
Job: 创建的任务对象
Example:
>>> await service.add_interval_job(my_func, minutes=5, job_id="cleanup_task")
"""
trigger = IntervalTrigger(
seconds=seconds, minutes=minutes, hours=hours, days=days
)
job = self.scheduler.add_job(
func,
trigger=trigger,
id=job_id,
args=args or [],
kwargs=kwargs or {},
replace_existing=replace_existing,
)
self.logger.info(f"添加间隔任务: {job.id}")
return job
async def add_cron_job(
self,
func: Callable,
job_id: Optional[str] = None,
year: Optional[int] = None,
month: Optional[int] = None,
day: Optional[int] = None,
week: Optional[int] = None,
day_of_week: Optional[int] = None,
hour: Optional[int] = None,
minute: Optional[int] = None,
second: Optional[int] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
replace_existing: bool = True,
) -> Job:
"""
添加 Cron 表达式任务
创建按照 Cron 表达式规则执行的任务,支持精确的时间调度。
Args:
func (callable): 要执行的任务函数
job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
year (int, optional): 年份,支持通配符和范围
month (int, optional): 月份 (1-12)
day (int, optional): 日期 (1-31)
week (int, optional): 周数 (1-53)
day_of_week (int, optional): 星期几 (0-6, 0 表示周一)
hour (int, optional): 小时 (0-23)
minute (int, optional): 分钟 (0-59)
second (int, optional): 秒 (0-59)
args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
Returns:
Job: 创建的任务对象
Example:
>>> # 每天凌晨 2 点执行
>>> await service.add_cron_job(my_func, hour=2, minute=0, job_id="daily_task")
"""
trigger = CronTrigger(
year=year,
month=month,
day=day,
week=week,
day_of_week=day_of_week,
hour=hour,
minute=minute,
second=second,
)
job = self.scheduler.add_job(
func,
trigger=trigger,
id=job_id,
args=args or [],
kwargs=kwargs or {},
replace_existing=replace_existing,
)
self.logger.info(f"添加 cron 任务: {job.id}")
return job
async def add_date_job(
self,
func: Callable,
run_date: Optional[datetime] = None,
job_id: Optional[str] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
replace_existing: bool = True,
) -> Job:
"""
添加一次性任务
创建在指定时间点执行一次的任务,执行后自动移除。
Args:
func (callable): 要执行的任务函数
run_date (datetime, optional): 任务执行的具体时间,如果未提供则立即执行
job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
Returns:
Job: 创建的任务对象
Example:
>>> from datetime import datetime
>>> run_time = datetime(2024, 12, 25, 10, 0, 0)
>>> await service.add_date_job(my_func, run_date=run_time, job_id="christmas_task")
"""
trigger = DateTrigger(run_date=run_date)
job = self.scheduler.add_job(
func,
trigger=trigger,
id=job_id,
args=args or [],
kwargs=kwargs or {},
replace_existing=replace_existing,
)
self.logger.info(f"添加一次性任务: {job.id}")
return job
async def remove_job(self, job_id: str) -> bool:
"""
删除任务
从调度器中移除指定任务,任务将不再执行。
Args:
job_id (str): 要删除的任务的唯一标识符
Returns:
bool: 删除是否成功,成功返回 True,失败返回 False
Note:
- 如果任务不存在或删除过程中发生异常,返回 False
- 会记录删除操作的日志
"""
try:
self.scheduler.remove_job(job_id)
self.logger.info(f"删除任务: {job_id}")
return True
except Exception as e:
self.logger.error(f"删除任务失败 {job_id}: {e}")
return False
async def pause_job(self, job_id: str) -> bool:
"""
暂停任务
暂停指定任务的执行,任务保留在调度器中但不会触发。
Args:
job_id (str): 要暂停的任务的唯一标识符
Returns:
bool: 暂停是否成功,成功返回 True,失败返回 False
Note:
- 暂停的任务可以通过 resume_job 恢复
- 如果任务不存在或暂停过程中发生异常,返回 False
"""
try:
self.scheduler.pause_job(job_id)
self.logger.info(f"暂停任务: {job_id}")
return True
except Exception as e:
self.logger.error(f"暂停任务失败 {job_id}: {e}")
return False
async def resume_job(self, job_id: str) -> bool:
"""
恢复任务
恢复之前被暂停的任务,使其继续按照原定计划执行。
Args:
job_id (str): 要恢复的任务的唯一标识符
Returns:
bool: 恢复是否成功,成功返回 True,失败返回 False
Note:
- 只能恢复已被暂停的任务
- 如果任务不存在或恢复过程中发生异常,返回 False
"""
try:
self.scheduler.resume_job(job_id)
self.logger.info(f"恢复任务: {job_id}")
return True
except Exception as e:
self.logger.error(f"恢复任务失败 {job_id}: {e}")
return False
async def get_job(self, job_id: str) -> Optional[Dict]:
"""
获取单个任务信息
查询指定任务的详细信息,包括下次执行时间、触发器等。
Args:
job_id (str): 要查询的任务的唯一标识符
Returns:
dict or None: 任务信息字典,如果任务不存在则返回 None。
字典包含以下字段:
- id (str): 任务 ID
- name (str): 任务名称
- next_run_time (str or None): 下次执行时间的 ISO 格式字符串
- trigger (str): 触发器描述
- pending (bool): 任务是否处于待处理状态
- args (list): 任务函数的位置参数
- kwargs (dict): 任务函数的关键字参数
"""
job = self.scheduler.get_job(job_id)
if job:
return self._job_to_dict(job)
return None
async def get_jobs(self) -> List[Dict]:
"""
获取所有任务信息
查询调度器中所有任务的详细信息列表。
Returns:
list[dict]: 任务信息字典列表,每个字典的结构与 get_job 返回的相同。
如果没有任务,返回空列表。
"""
jobs = self.scheduler.get_jobs()
return [self._job_to_dict(job) for job in jobs]
async def reschedule_job(
self, job_id: str, trigger_type: str, **trigger_args
) -> Optional[Job]:
"""
重新调度任务(修改触发时间)
修改现有任务的触发器配置,改变任务的执行时间规则。
Args:
job_id (str): 要重新调度的任务的唯一标识符
trigger_type (str): 触发器类型,可选值:
- "interval": 间隔触发器
- "cron": Cron 表达式触发器
- "date": 一次性触发器
**trigger_args: 触发器参数,根据 trigger_type 不同而不同:
- interval: seconds, minutes, hours, days
- cron: year, month, day, week, day_of_week, hour, minute, second
- date: run_date
Returns:
Job or None: 重新调度后的任务对象,如果任务不存在或重新调度失败则返回 None
Raises:
ValueError: 当传入不支持的 trigger_type 时抛出
Example:
>>> # 将任务改为每 10 分钟执行一次
>>> await service.reschedule_job("task_id", "interval", minutes=10)
"""
try:
job = self.scheduler.get_job(job_id)
if not job:
return None
if trigger_type == "interval":
trigger = IntervalTrigger(**trigger_args)
elif trigger_type == "cron":
trigger = CronTrigger(**trigger_args)
elif trigger_type == "date":
trigger = DateTrigger(**trigger_args)
else:
raise ValueError(f"不支持的 trigger 类型: {trigger_type}")
job.reschedule(trigger=trigger)
self.logger.info(f"重新调度任务: {job_id}")
return job
except Exception as e:
self.logger.error(f"重新调度任务失败 {job_id}: {e}")
return None
def _job_to_dict(self, job: Job) -> Dict:
"""
将 Job 对象转换为字典
内部辅助方法,将 APScheduler 的 Job 对象序列化为字典格式,
便于 API 返回和数据传输。
Args:
job (Job): APScheduler 的 Job 对象
Returns:
dict: 包含任务信息的字典,字段包括:
- id (str): 任务 ID
- name (str): 任务名称
- next_run_time (str or None): 下次执行时间的 ISO 8601 格式字符串
- trigger (str): 触发器的字符串描述
- pending (bool): 任务是否处于待处理状态
- args (list): 任务函数的位置参数列表
- kwargs (dict): 任务函数的关键字参数字典
"""
return {
"id": job.id,
"name": job.name,
"next_run_time": (
job.next_run_time.isoformat() if job.next_run_time else None
),
"trigger": str(job.trigger),
"pending": job.pending,
"args": job.args,
"kwargs": job.kwargs,
}
_task_service: Optional[TaskService] = None
def get_task_service_instance(config=None, logger=None, async_session_maker=None):
"""
获取任务调度服务单例实例
使用全局变量实现单例模式,确保整个应用中只有一个 TaskService 实例。
首次调用时创建实例,后续调用直接返回已创建的实例。
Args:
config (dict, optional): 配置字典,仅在首次创建实例时需要
logger (logging.Logger, optional): 日志记录器,仅在首次创建实例时需要
async_session_maker (callable, optional): 异步会话工厂函数,仅在首次创建实例时需要
Returns:
TaskService: 任务调度服务单例实例
Raises:
ValueError: 如果首次调用时未提供 config 或 logger 参数
Note:
- 这是一个单例模式实现,确保全局只有一个调度器实例
- 首次调用必须提供 config 和 logger 参数
- 后续调用可以不提供参数,直接返回已创建的实例
"""
global _task_service
if _task_service is None:
if config is None or logger is None:
raise ValueError("请传入 config 和 logger")
_task_service = TaskService(config, logger, async_session_maker)
return _task_service
async def get_task_service(request: Request) -> AsyncGenerator:
"""
FastAPI 依赖注入函数,获取任务调度服务实例
用于 FastAPI 路由函数的依赖注入,从应用状态中获取已初始化的任务调度服务。
Args:
request (Request): FastAPI 请求对象,从中获取应用实例
Returns:
TaskService: 从 request.app.state.task_service 获取的任务调度服务实例
Note:
- 此函数应在应用启动时通过 startup 事件初始化 task_service
- 适用于 FastAPI 的 Depends 依赖注入机制
"""
return request.app.state.task_service
@@ -0,0 +1,39 @@
# 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
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,38 @@
{
"name": "{{ cookiecutter.project_name }}-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write --experimental-cli src/",
"openapi": "npx @hey-api/openapi-ts --input http://127.0.0.1:8000/openapi.json --output ./src/api/generated/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.13.7",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.96.1",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"npm-run-all2": "^8.0.4",
"prettier": "3.8.1",
"typescript": "~6.0.0",
"vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,16 @@
<!-- src/App.vue -->
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style>
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 204 KiB

@@ -0,0 +1,31 @@
// import './assets/main.css'
import 'element-plus/dist/index.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
/**
* 打包时取消该注释
*/
// import { client } from "./api/generated/client.gen"
// client.setConfig({
// baseUrl: "/"
// })
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
const authStore = useAuthStore()
authStore.initAuth()
app.mount('#app')
@@ -0,0 +1,44 @@
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/todos' },
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { requiresGuest: true },
},
{
path: '/register',
name: 'register',
component: () => import('@/views/RegisterView.vue'),
meta: { requiresGuest: true },
},
{
path: '/todos',
name: 'todos',
component: () => import('@/views/TodoView.vue'),
meta: { requiresAuth: true },
},
],
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const isAuthenticated = !!authStore.accessToken
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else if (to.meta.requiresGuest && isAuthenticated) {
next('/todos')
} else {
next()
}
})
export default router
@@ -0,0 +1,77 @@
import { defineStore } from "pinia"
import { ElMessage } from 'element-plus'
import { client } from "@/api/generated/client.gen"
import { loginApiV1AuthLoginPost, registerApiV1AuthRegisterPost } from "@/api/generated"
interface AuthState {
accessToken: string | null
refreshToken: string | null
}
export const useAuthStore = defineStore("auth", {
state: (): AuthState => ({
accessToken: localStorage.getItem("access_token"),
refreshToken: localStorage.getItem("refresh_token"),
}),
actions: {
setTokens(access: string, refresh: string) {
this.accessToken = access
this.refreshToken = refresh
localStorage.setItem("access_token", access)
localStorage.setItem("refresh_token", refresh)
client.setConfig({
auth: () => this.accessToken || ''
})
},
clearTokens() {
this.accessToken = null
this.refreshToken = null
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
client.setConfig({ auth: undefined })
},
async login(email: string, password: string) {
try {
const { data } = await loginApiV1AuthLoginPost({
body: { email, password },
throwOnError: true,
})
if (data) {
this.setTokens(data.access_token, data.refresh_token)
ElMessage.success("登录成功")
return true
}
return false
} catch (error: any) {
// console.error("登录失败", error)
ElMessage.error(error.message || "登录失败")
return false
}
},
async register(email: string, password: string, username?: string) {
try {
await registerApiV1AuthRegisterPost({
body: { email, password, username },
throwOnError: true,
})
ElMessage.success("注册成功")
return await this.login(email, password)
} catch (error: any) {
ElMessage.error(error.message || "注册失败")
return false
}
},
logout() {
this.clearTokens()
ElMessage.info("已退出登录")
},
initAuth() {
if (this.accessToken) {
client.setConfig({
auth: () => this.accessToken || ''
})
}
}
}
})
@@ -0,0 +1,69 @@
// src/stores/todo.ts
import { defineStore } from 'pinia'
import {
readTodosApiV1TodosGet,
createTodoApiV1TodosPost,
updateTodoApiV1TodosTodoIdPatch,
deleteTodoApiV1TodosTodoIdDelete,
type TodoRead,
type TodoCreate,
type TodoUpdate,
} from '@/api/generated'
interface TodoState {
todos: TodoRead[]
loading: boolean
}
export const useTodoStore = defineStore('todo', {
state: (): TodoState => ({
todos: [],
loading: false,
}),
actions: {
async fetchTodos() {
this.loading = true
try {
const { data } = await readTodosApiV1TodosGet({
query: { offset: 0, limit: 100 },
throwOnError: true,
})
this.todos = data || []
} catch (error) {
console.error('获取 Todo 列表失败', error)
throw error
} finally {
this.loading = false
}
},
async addTodo(todo: TodoCreate) {
const { data } = await createTodoApiV1TodosPost({
body: todo,
throwOnError: true,
})
if (data) {
this.todos.unshift(data)
}
return data
},
async updateTodo(id: number, updates: TodoUpdate) {
const { data } = await updateTodoApiV1TodosTodoIdPatch({
path: { todo_id: id },
body: updates,
throwOnError: true,
})
if (data) {
const index = this.todos.findIndex((t) => t.id === id)
if (index !== -1) this.todos[index] = data
}
return data
},
async deleteTodo(id: number) {
await deleteTodoApiV1TodosTodoIdDelete({
path: { todo_id: id },
throwOnError: true,
})
this.todos = this.todos.filter((t) => t.id !== id)
},
},
})
@@ -0,0 +1,58 @@
<!-- src/views/LoginView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElCard, ElForm, ElFormItem, ElInput, ElButton, ElLink } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({ email: '', password: '' })
const loading = ref(false)
async function handleLogin() {
loading.value = true
const success = await authStore.login(form.value.email, form.value.password)
loading.value = false
if (success) router.push('/todos')
}
</script>
<template>
<div class="page-container">
<el-card class="card">
<template #header>
<h2>登录</h2>
</template>
<el-form :model="form" @submit.prevent="handleLogin">
<el-form-item label="邮箱">
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
登录
</el-button>
</el-form-item>
<el-form-item>
<el-link type="info" @click="$router.push('/register')">还没有账号?立即注册</el-link>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.page-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f7fa;
}
.card {
width: 400px;
}
</style>
@@ -0,0 +1,61 @@
<!-- src/views/RegisterView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElCard, ElForm, ElFormItem, ElInput, ElButton, ElLink } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({ email: '', username: '', password: '' })
const loading = ref(false)
async function handleRegister() {
loading.value = true
const success = await authStore.register(form.value.email, form.value.password, form.value.username)
loading.value = false
if (success) router.push('/todos')
}
</script>
<template>
<div class="page-container">
<el-card class="card">
<template #header>
<h2>注册</h2>
</template>
<el-form :model="form" @submit.prevent="handleRegister">
<el-form-item label="邮箱">
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="可选" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
注册
</el-button>
</el-form-item>
<el-form-item>
<el-link type="info" @click="$router.push('/login')">已有账号?去登录</el-link>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.page-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f7fa;
}
.card {
width: 400px;
}
</style>
@@ -0,0 +1,245 @@
<!-- src/views/TodoView.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTodoStore } from '@/stores/todo'
import { useAuthStore } from '@/stores/auth'
import {
ElContainer,
ElHeader,
ElMain,
ElButton,
ElInput,
ElCheckbox,
ElPopconfirm,
ElMessage,
ElCard,
ElRow,
ElCol,
ElSpace,
ElEmpty,
} from 'element-plus'
import { Plus, Edit, Delete, Check, Close } from '@element-plus/icons-vue'
const todoStore = useTodoStore()
const authStore = useAuthStore()
const router = useRouter()
const newTitle = ref('')
const newDesc = ref('')
const adding = ref(false)
const editingId = ref<number | null>(null)
const editTitle = ref('')
const editDesc = ref('')
const editCompleted = ref(false)
onMounted(() => {
todoStore.fetchTodos()
})
async function addTodo() {
if (!newTitle.value.trim()) {
ElMessage.warning('请输入标题')
return
}
adding.value = true
await todoStore.addTodo({
title: newTitle.value,
description: newDesc.value || null,
})
newTitle.value = ''
newDesc.value = ''
adding.value = false
ElMessage.success('添加成功')
}
function startEdit(todo: any) {
editingId.value = todo.id
editTitle.value = todo.title
editDesc.value = todo.description || ''
editCompleted.value = todo.completed
}
async function saveEdit(id: number) {
await todoStore.updateTodo(id, {
title: editTitle.value,
description: editDesc.value || null,
completed: editCompleted.value,
})
editingId.value = null
ElMessage.success('更新成功')
}
async function toggleComplete(todo: any) {
await todoStore.updateTodo(todo.id, { completed: !todo.completed })
}
async function handleDelete(id: number) {
await todoStore.deleteTodo(id)
ElMessage.success('删除成功')
}
function logout() {
authStore.logout()
router.push('/login')
}
</script>
<template>
<el-container class="layout">
<el-header>
<div class="header">
<h2>✅ 待办清单</h2>
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
</el-header>
<el-main>
<div class="content-wrapper">
<el-card class="add-card">
<template #header>新建待办</template>
<el-row :gutter="16">
<el-col :span="8">
<el-input v-model="newTitle" placeholder="标题" clearable />
</el-col>
<el-col :span="10">
<el-input v-model="newDesc" placeholder="描述(可选)" clearable />
</el-col>
<el-col :span="6">
<el-button type="primary" :icon="Plus" :loading="adding" @click="addTodo">
添加
</el-button>
</el-col>
</el-row>
</el-card>
<el-card v-loading="todoStore.loading" class="list-card">
<template #header>我的待办 ({{ todoStore.todos.length }})</template>
<div v-if="todoStore.todos.length === 0">
<el-empty description="暂无待办,添加一条吧" />
</div>
<div v-else class="todo-list">
<div v-for="todo in todoStore.todos" :key="todo.id" class="todo-item">
<template v-if="editingId !== todo.id">
<div class="todo-content">
<el-checkbox :model-value="todo.completed" @change="() => toggleComplete(todo)" />
<div class="todo-text" :class="{ completed: todo.completed }">
<strong>{{ todo.title }}</strong>
<span v-if="todo.description" class="desc">{{ todo.description }}</span>
</div>
</div>
<div class="todo-actions">
<el-button text type="primary" :icon="Edit" @click="startEdit(todo)" />
<el-popconfirm title="确定删除此项?" @confirm="handleDelete(todo.id)">
<template #reference>
<el-button text type="danger" :icon="Delete" />
</template>
</el-popconfirm>
</div>
</template>
<template v-else>
<div class="edit-form">
<el-input v-model="editTitle" placeholder="标题" size="small" />
<el-input v-model="editDesc" placeholder="描述" size="small" />
<el-checkbox v-model="editCompleted">已完成</el-checkbox>
<el-space>
<el-button type="primary" :icon="Check" size="small" @click="saveEdit(todo.id)">
保存
</el-button>
<el-button :icon="Close" size="small" @click="editingId = null">
取消
</el-button>
</el-space>
</div>
</template>
</div>
</div>
</el-card>
</div>
</el-main>
</el-container>
</template>
<style scoped>
.layout {
height: 100vh;
background: #f0f2f5;
display: flex;
flex-direction: column;
align-items: center;
}
.el-header {
width: 100%;
background: white;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
padding: 0 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.el-main {
width: 100%;
display: flex;
justify-content: center;
padding: 20px;
}
.content-wrapper {
width: 100%;
max-width: 1000px;
}
.add-card {
margin-bottom: 20px;
}
.list-card {
min-height: 400px;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.todo-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.todo-text {
display: flex;
flex-direction: column;
}
.todo-text .desc {
font-size: 12px;
color: #909399;
}
.completed {
text-decoration: line-through;
color: #c0c4cc;
}
.todo-actions {
display: flex;
gap: 4px;
}
.edit-form {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
width: 100%;
}
</style>
@@ -0,0 +1,24 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": [
"./src/*"
]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
@@ -0,0 +1,26 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": [
"node"
],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
@@ -0,0 +1,22 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
/**
* 打包开启此配置项
*/
base: '/static/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
@@ -1,19 +1,60 @@
import uvicorn
from .app.core.config import load_config
from .app.core.logger import setup_logging, get_logger
from .app.core.logger import get_logger
def serve():
"""
使用 uvicorn 启动服务
从配置文件中加载服务器配置和日志配置,然后使用 uvicorn 启动应用服务。
该函数适用于开发环境或单进程部署场景。
Returns:
None: 该函数不返回值,会阻塞运行直到服务停止
"""
config = load_config()
env = config["default"]["env"]
server_config = config[env]["server"]
log_config = config[env]["logging"]
setup_logging(log_config)
logget = get_logger("app")
logget.info(f"启动服务: {server_config}")
logget.info(f"启动日志: {log_config}")
logger = get_logger("app")
logger.info(f"启动服务: {server_config}")
logger.info(f"启动日志: {log_config}")
uvicorn.run("{{ cookiecutter.project_name }}.app:app", **server_config)
def gunicorn_serve():
"""
使用 gunicorn 启动服务
通过 gunicorn 作为进程管理器启动应用,使用 UvicornWorker 作为 worker 类型,
支持多进程部署,适用于生产环境。
从配置文件中读取服务器配置,动态设置 gunicorn 的 worker 数量、绑定地址和端口等参数。
Returns:
None: 该函数不返回值,会阻塞运行直到服务停止
"""
import sys
from gunicorn.app.wsgiapp import run
config = load_config()
env = config["default"]["env"]
server_config = config[env]["server"]
# 构建 gunicorn 命令行参数并启动服务
sys.argv = [
"gunicorn",
"{{ cookiecutter.project_name }}.app:app",
"-k",
"uvicorn.workers.UvicornWorker",
"-w",
f"{server_config.get('workers', 4)}",
"-b",
f"{server_config.get('host', '0.0.0.0')}:{server_config.get('port', 8000)}",
]
run()
if __name__ == "__main__":
serve()
@@ -1,29 +1,61 @@
import time
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlmodel import SQLModel, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.exc import OperationalError
from .core.config import get_config
from .core.jwt_cache import get_jwt_cache
from .core.cache import get_cache
from .core.logger import get_logger
from .core.static_files import SVFSStaticFiles
from .api import api_router
from .task import init_tasks_callback
from plugins.scheduler.scheduler import get_task_service_instance
config = get_config()
cache = get_cache()
logger = get_logger("app")
try:
env = config["default"]["env"]
except KeyError: # pragma: no cover
raise ValueError("配置不存在")
# 生命周期管理
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
应用生命周期管理函数,负责应用的启动和关闭时的资源初始化与清理。
在应用启动时:
- 初始化JWT缓存
- 创建异步数据库引擎和会话工厂
- 在开发环境下创建数据库表
- 初始化定时任务服务
在应用关闭时:
- 关闭定时任务服务
- 释放数据库引擎资源
- 关闭缓存连接
参数:
app: FastAPI应用实例
Yields:
None: 应用运行期间保持上下文激活
"""
from plugins import hello
from plugins.jwt.jwt_cache import get_jwt_cache
get_jwt_cache(config, cache)
hello()
database = config["database"]
@@ -31,18 +63,18 @@ async def lifespan(app: FastAPI):
database_config = database.get(database_type, None)
if database_config is None:
raise ValueError("配置不存在")
database_path = database.get("database_path", None)
if database_path:
Path(database_path).parent.mkdir(parents=True, exist_ok=True)
# 启动时:创建引擎和会话工厂,初始化数据库表
engine = create_async_engine(database["url"], **database_config)
async_session_maker = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
# 将资源存储到 app.state
app.state.engine = engine
app.state.async_session_maker = async_session_maker
# 创建表(异步方式)
if env == "development":
async with engine.begin() as conn:
if database_type == "sqlite":
@@ -58,16 +90,20 @@ async def lifespan(app: FastAPI):
if "already exists" in str(e):
raise
yield # 应用运行期间
task_service = get_task_service_instance(
config, get_logger("scheduler"), async_session_maker
)
app.state.task_service = task_service
await task_service.start(init_tasks_callback)
# 关闭时:释放引擎
yield
await task_service.shutdown(wait=True)
await engine.dispose()
get_jwt_cache().close()
cache.close()
cors = config[env].get("cors", {})
# 创建 FastAPI 应用
app = FastAPI(
title=config["default"]["app"]["name"],
version=config["default"]["app"]["version"],
@@ -77,29 +113,113 @@ app = FastAPI(
openapi_url="/openapi.json" if config[env]["debug"] else None,
lifespan=lifespan,
)
# 添加 CORS 中间件
app.add_middleware(CORSMiddleware, **cors)
app.include_router(api_router, prefix="/api/v1")
app.include_router(api_router, prefix=config["default"]["prefix"])
@app.middleware("http")
async def access_log_middleware(request: Request, call_next):
"""
HTTP访问日志中间件,记录每个请求的详细信息。
记录的信息包括:
- 请求方法和路径
- 响应状态码
- 请求处理时长(毫秒)
- 客户端IP地址
参数:
request: FastAPI请求对象
call_next: 下一个中间件或路由处理函数
返回:
Response: 处理后的响应对象
"""
start = time.perf_counter()
response = await call_next(request)
duration = (time.perf_counter() - start) * 1000
client = request.client.host if request.client else "-"
logger.info(
f"{request.method} {request.url.path} status={response.status_code} "
f"duration={duration:.2f}ms client={client}",
extra={"status_code": response.status_code},
)
return response
# 根路由
@app.get("/", summary="根路径")
async def root():
"""根路径,返回应用信息"""
"""
根路径端点,返回应用的基本信息。
返回:
dict: 包含应用名称、版本、文档地址和健康检查地址的字典
"""
return {
"message": f"欢迎使用 {config["default"]["app"]["name"]}",
"message": f"欢迎使用 {config['default']['app']['name']}",
"version": config["default"]["app"]["version"],
"docs": "/docs" if config[env]["debug"] else None,
"health": "/api/v1/health",
"health": f"{config['default']['prefix']}/health",
}
# 全局异常处理器
# from pydantic import BaseModel
# class TaskRequest(BaseModel):
# task_name: str
# interval: int
# @app.post("/schedule")
# async def add_task(
# request: TaskRequest, task_service: TaskService = Depends(get_task_service)
# ):
# """添加定时任务接口"""
# job = await task_service.add_interval_job(
# execute_background_task,
# seconds=request.interval,
# args=[request.task_name],
# job_id=request.task_name,
# )
# return {"status": "success", "job_id": job.id}
config = get_config()
IMAGE_DIR = Path(f"./{config['default']['upload_path']}/images")
app.mount(
f"{config['default']['prefix']}/images",
StaticFiles(directory=f"./{config['default']['upload_path']}/images"),
name="images",
)
if vfs_db_path := config[env].get("file_system", {}).get("path", None):
logger.info("静态文件已存在,将使用静态文件服务")
static_files_handler = SVFSStaticFiles(directory="/", vfs_db_path=vfs_db_path)
app.mount("/static", static_files_handler, name="static")
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""全局异常处理器"""
async def global_exception_handler(_: Request, exc: Exception):
"""
全局异常处理器,捕获并处理应用中未捕获的异常。
在调试模式下返回详细的异常信息,在生产模式下返回通用错误提示。
参数:
_: FastAPI请求对象(未使用)
exc: 捕获到的异常对象
返回:
JSONResponse: 包含错误信息的JSON响应,状态码为500
"""
logger.error(f"全局异常: {exc}")
return {
"error": "内部服务器错误",
"detail": str(exc) if config[env]["debug"] else "请查看服务器日志",
}
return JSONResponse(
status_code=500,
content={
"error": "内部服务器错误",
"detail": str(exc) if config[env]["debug"] else "请查看服务器日志",
},
)
@@ -4,7 +4,7 @@ API 路由
from fastapi import APIRouter
from .routers import todo, auth
from .routers import todo, auth, upload
# 创建主路由
api_router = APIRouter()
@@ -12,6 +12,7 @@ api_router = APIRouter()
# 注册子路由
api_router.include_router(todo.router, prefix="/todos", tags=["todo"])
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(upload.router, prefix="/upload", tags=["upload"])
# 导出路由
@@ -2,25 +2,35 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..service.auth import get_auth_service
from ..core.jwt_cache import TokenData
from plugins.jwt.jwt_cache import TokenData
security = HTTPBearer()
access_scheme = HTTPBearer(scheme_name="AccessToken")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
credentials: HTTPAuthorizationCredentials = Depends(access_scheme),
) -> TokenData:
"""
获取当前用户依赖项
验证访问令牌并返回用户信息
Args:
credentials: HTTP授权凭证,包含Bearer token
Returns:
TokenData: 验证通过后的用户令牌数据
Raises:
HTTPException: 当访问令牌无效时抛出401未授权异常
"""
auth_service = get_auth_service()
# 提取并验证访问令牌
token = credentials.credentials
token_data = await auth_service.validate_access_token(token)
# 令牌验证失败时抛出未授权异常
if token_data is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1,31 +1,57 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.database import get_session
from ...core.jwt_cache import get_jwt_cache, TokenData
from ...core.security import verify_password
from ...service.user import UserService
from ...service.auth import get_auth_service
from ...model.user import UserCreate, UserRead, UserLogin
from ...model.user import UserCreate, UserRead, UserLogin, Userlogout, UserSession
from ...model.http_error import HTTPError, Detail
from ..deps import get_current_user
from plugins.jwt.jwt_cache import TokenData, TokenResponse, RefreshTokenResponse
router = APIRouter(tags=["authentication"])
security = HTTPBearer()
access_scheme = HTTPBearer(scheme_name="AccessToken")
refresh_scheme = HTTPBearer(scheme_name="RefreshToken")
@router.post("/register", response_model=UserRead)
@router.post(
"/register",
response_model=UserRead,
responses={400: {"model": HTTPError, "description": "邮箱已被注册"}},
)
async def register(
user_data: UserCreate,
session: AsyncSession = Depends(get_session),
):
print(user_data.password)
"""
用户注册接口
创建新用户账户,检查邮箱是否已被注册,如果邮箱未被注册则创建新用户。
Args:
user_data: 用户注册数据,包含邮箱、密码、用户名和全名等信息
session: 数据库会话对象,用于执行数据库操作
Returns:
UserRead: 创建成功的用户信息,包含用户ID、邮箱、用户名等字段
Raises:
HTTPException: 当邮箱已被注册时抛出400错误
"""
existing = await UserService.get_user_by_email(user_data.email, session)
if existing:
raise HTTPException(status_code=400, detail="邮箱已被注册")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=Detail(
message="邮箱已被注册", code=status.HTTP_400_BAD_REQUEST
).model_dump(),
)
user = await UserService.create_user(
session=session,
@@ -37,44 +63,85 @@ async def register(
return user
@router.post("/login")
@router.post(
"/login",
response_model=TokenResponse,
responses={
400: {"model": HTTPError, "description": "邮箱不存在"},
401: {"model": HTTPError, "description": "密码错误"},
402: {"model": HTTPError, "description": "用户被禁用"},
},
)
async def login(
user_data: UserLogin,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""
用户登录接口
验证用户凭据并生成访问令牌和刷新令牌。依次检查邮箱是否存在、密码是否正确、用户是否被禁用。
Args:
user_data: 用户登录数据,包含邮箱和密码
request: FastAPI请求对象,用于获取客户端信息
session: 数据库会话对象,用于查询用户信息
Returns:
TokenResponse: 包含访问令牌(access_token)、刷新令牌(refresh_token)和令牌类型(token_type)的响应对象
Raises:
HTTPException:
- 400错误:当邮箱不存在时
- 401错误:当密码错误时
- 402错误:当用户账户被禁用时
"""
user = await UserService.get_user_by_email(user_data.email, session)
if not user:
raise HTTPException(status_code=400, detail="邮箱或密码错误")
raise HTTPException(
status_code=400, detail=Detail(message="邮箱不存在", code=400).model_dump()
)
if not verify_password(user_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="邮箱或密码错误")
raise HTTPException(
status_code=400, detail=Detail(message="密码错误", code=401).model_dump()
)
if not user.is_active:
raise HTTPException(status_code=400, detail="用户被禁用")
cache = get_jwt_cache()
user_data_dict = {
"sub": str(user.id),
"email": user.email,
"username": user.username,
}
token = cache.create_token(
user_id=str(user.id),
user_data=user_data_dict,
token_type="access",
device_id=None,
user_agent=request.headers.get("user-agent") if request else None,
ip_address=request.client.host if request else None, # type: ignore
)
return {"access_token": token, "token_type": "bearer"}
raise HTTPException(
status_code=400, detail=Detail(message="用户被禁用", code=402).model_dump()
)
auth_service = get_auth_service()
return await auth_service.create_tokens(user, request, device_id=None)
@router.post("/refresh", response_model=dict, summary="刷新访问令牌")
@router.post(
"/refresh",
response_model=RefreshTokenResponse,
responses={
401: {"model": HTTPError, "description": "无效的刷新令牌"},
},
summary="刷新访问令牌",
)
async def refresh_token(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
credentials: HTTPAuthorizationCredentials = Depends(refresh_scheme),
session: AsyncSession = Depends(get_session),
):
"""
刷新访问令牌接口
使用有效的刷新令牌获取新的访问令牌。验证刷新令牌的有效性,如果有效则生成新的访问令牌。
Args:
request: FastAPI请求对象,用于获取客户端信息
credentials: HTTP认证凭证,从请求头中提取的刷新令牌
session: 数据库会话对象,用于验证令牌状态
Returns:
RefreshTokenResponse: 包含新的访问令牌(access_token)和令牌类型(token_type)的响应对象
Raises:
HTTPException: 当刷新令牌无效或已过期时抛出401错误
"""
auth_service = get_auth_service()
refresh_token = credentials.credentials
@@ -84,51 +151,103 @@ async def refresh_token(
if not new_access_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的刷新令牌",
detail=Detail(message="无效的刷新令牌", code=401).model_dump(),
headers={"WWW-Authenticate": "Bearer"},
)
return {"access_token": new_access_token, "token_type": "bearer"}
@router.post("/logout")
@router.post("/logout", response_model=Userlogout)
async def logout(
credentials: HTTPAuthorizationCredentials = Depends(security),
credentials: HTTPAuthorizationCredentials = Depends(access_scheme),
):
"""
用户登出接口
使当前的访问令牌失效,实现单设备登出功能。
Args:
credentials: HTTP认证凭证,从请求头中提取的访问令牌
Returns:
Userlogout: 登出成功响应,包含成功消息和状态码200
"""
auth_service = get_auth_service()
token = credentials.credentials
await auth_service.logout(token)
return {"message": "登出成功"}
return {"message": "登出成功", "code": 200}
@router.post("/logout-all", summary="等出所有设备")
@router.post("/logout-all", summary="登出所有设备", response_model=Userlogout)
async def logout_all(current_user: TokenData = Depends(get_current_user)):
"""
登出所有设备接口
使指定用户的所有活跃会话令牌失效,实现多设备同时登出功能。
Args:
current_user: 当前登录用户的令牌数据,包含用户ID等信息
Returns:
Userlogout: 登出成功响应,包含成功消息和状态码200
"""
auth_service = get_auth_service()
await auth_service.logout_all(current_user.user_id)
return {"message": "所有设备已登出"}
return {"message": "所有设备已登出", "code": 200}
@router.get("/sessions", summary="获取用户会话")
async def get_sessions(current_user: TokenData = Depends(get_current_user)):
"""获取用户的所有活跃会话"""
"""
获取用户会话列表接口
查询并返回指定用户的所有活跃会话信息,包括每个会话的设备信息和创建时间等。
Args:
current_user: 当前登录用户的令牌数据,包含用户ID等信息
Returns:
UserSession: 用户会话信息对象,包含用户ID、会话列表和会话总数
"""
auth_service = get_auth_service()
sessions = await auth_service.get_user_sessions(current_user.user_id)
return {
"user_id": current_user.user_id,
"sessions": sessions,
"count": len(sessions),
}
return UserSession(
user_id=current_user.user_id, sessions=sessions, count=len(sessions)
)
@router.get("/me", response_model=UserRead, summary="获取当前用户信息")
@router.get(
"/me",
response_model=UserRead,
summary="获取当前用户信息",
responses={404: {"model": HTTPError, "description": "用户不存在"}},
)
async def get_current_user_info(
session: AsyncSession = Depends(get_session),
current_user: TokenData = Depends(get_current_user),
):
"""获取当前登录用户的信息"""
user = await UserService.get_user_by_id(int(current_user.user_id), session)
"""
获取当前用户信息接口
根据当前登录用户的令牌信息,从数据库中查询并返回完整的用户资料。
Args:
session: 数据库会话对象,用于查询用户信息
current_user: 当前登录用户的令牌数据,包含用户ID等信息
Returns:
UserRead: 用户详细信息,包含用户ID、邮箱、用户名、全名、激活状态等字段
Raises:
HTTPException: 当用户不存在时抛出404错误
"""
user = await UserService.get_user_by_id(UUID(current_user.user_id), session)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=Detail(message="用户不存在", code=404).model_dump(),
)
return user
@@ -6,53 +6,103 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ...model.todo import TodoCreate, TodoUpdate, TodoRead
from ...core.database import get_session
from ...service.todo import TodoService
from ...model.user import User
from ..deps import get_current_user
router = APIRouter()
router = APIRouter(dependencies=[Depends(get_current_user)])
# -------------------- 路由 --------------------
@router.post("/", response_model=TodoRead, dependencies=[Depends(get_current_user)])
@router.post("/", response_model=TodoRead)
async def create_todo(
todo: TodoCreate,
session: AsyncSession = Depends(get_session),
):
"""
创建新的待办事项
Args:
todo: 待办事项创建请求体,包含待办事项的详细信息
session: 数据库会话对象,用于执行数据库操作
Returns:
TodoRead: 创建成功的待办事项对象,包含完整的待办事项信息
"""
return await TodoService.create_todo(todo, session)
@router.get(
"/", response_model=List[TodoRead], dependencies=[Depends(get_current_user)]
)
@router.get("/", response_model=List[TodoRead])
async def read_todos(
offset: int = 0, limit: int = 100, session: AsyncSession = Depends(get_session)
):
"""
获取待办事项列表
Args:
offset: 分页偏移量,默认为0,用于指定从第几条记录开始查询
limit: 每页返回的最大记录数,默认为100,用于限制返回结果的数量
session: 数据库会话对象,用于执行数据库操作
Returns:
List[TodoRead]: 待办事项列表,包含指定范围内的所有待办事项
"""
return await TodoService.get_todos(offset, limit, session)
@router.get(
"/{todo_id}", response_model=TodoRead, dependencies=[Depends(get_current_user)]
)
@router.get("/{todo_id}", response_model=TodoRead)
async def read_todo(todo_id: int, session: AsyncSession = Depends(get_session)):
"""
根据ID获取单个待办事项
Args:
todo_id: 待办事项的唯一标识符
session: 数据库会话对象,用于执行数据库操作
Returns:
TodoRead: 查询到的待办事项对象
Raises:
HTTPException: 当待办事项不存在时抛出404错误
"""
todo = await TodoService.get_todo_by_id(todo_id, session)
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@router.patch(
"/{todo_id}", response_model=TodoRead, dependencies=[Depends(get_current_user)]
)
@router.patch("/{todo_id}", response_model=TodoRead)
async def update_todo(
todo_id: int, todo_update: TodoUpdate, session: AsyncSession = Depends(get_session)
):
"""
更新待办事项信息
Args:
todo_id: 待办事项的唯一标识符
todo_update: 待办事项更新请求体,包含需要更新的字段信息
session: 数据库会话对象,用于执行数据库操作
Returns:
TodoRead: 更新后的待办事项对象,包含最新的待办事项信息
"""
todo = await TodoService.update_todo(todo_id, todo_update, session)
return todo
@router.delete("/{todo_id}", dependencies=[Depends(get_current_user)])
@router.delete("/{todo_id}")
async def delete_todo(todo_id: int, session: AsyncSession = Depends(get_session)):
"""
删除待办事项
Args:
todo_id: 待办事项的唯一标识符
session: 数据库会话对象,用于执行数据库操作
Returns:
dict: 包含操作结果的字典,{"ok": True} 表示删除成功
Raises:
HTTPException: 当待办事项不存在时抛出404错误
"""
is_deleted = await TodoService.delete_todo(todo_id, session)
if not is_deleted:
raise HTTPException(status_code=404, detail="Todo not found")
@@ -0,0 +1,67 @@
import os
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends
from pydantic import BaseModel
from ..deps import get_current_user
from ...service.uploads import UploadService
from ...core.config import get_config
config = get_config()
router = APIRouter(dependencies=[Depends(get_current_user)])
upload_image_service = UploadService(
config["default"]["upload_path"], 1024 * 1024, "images"
)
class UploadResponse(BaseModel):
"""图片上传响应数据模型"""
url: str
filename: str
file_type: str
@router.post("/image", response_model=UploadResponse)
async def upload_image(file: UploadFile = File(...)):
"""
上传图片文件接口
该接口用于接收用户上传的图片文件,进行格式验证后保存到服务器,
并返回文件的访问URL、文件名和文件类型信息。
Args:
file (UploadFile): 上传的文件对象,必须为图片格式
Returns:
UploadResponse: 包含以下字段的响应对象:
- url (str): 上传文件的访问URL
- filename (str): 保存后的文件名
- file_type (str): 文件扩展名(如 .jpg, .png 等)
Raises:
HTTPException:
- 400: 当文件类型不是图片格式时
- 400: 当文件名为空时
- 400: 当文件扩展名不在允许列表中时
Note:
支持的图片格式包括:.jpg, .jpeg, .png, .gif, .webp
需要用户认证才能访问此接口
"""
# 验证文件内容类型是否为图片
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Invalid file type")
# 验证文件名是否存在
if file.filename is None:
raise HTTPException(status_code=400, detail="Invalid file name")
# 检查文件扩展名是否在允许的范围内
ext = os.path.splitext(file.filename)[1].lower()
if ext not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
raise HTTPException(status_code=400, detail="Invalid file type")
# 执行文件上传操作
url, filename = await upload_image_service.upload(file)
return UploadResponse(url=url, filename=filename, file_type=ext)
@@ -0,0 +1,397 @@
"""
磁盘缓存模块
基于 diskcache 实现的本地磁盘缓存系统,提供类似 Redis 的 API 接口。
支持过期时间设置、TTL 查询、键模式匹配等功能。
"""
import time
from typing import Any, Optional
import diskcache
from ..core.config import get_config
class Cache:
"""
磁盘缓存类
封装 diskcache 提供统一的缓存操作接口,支持:
- 键值对存储与检索
- 过期时间管理(秒/毫秒)
- TTL 查询
- 键模式匹配
- 批量操作
Attributes:
_cache: diskcache.Cache 实例,负责实际的磁盘缓存操作
"""
def __init__(self):
"""
初始化缓存实例
从配置中读取缓存存储路径,创建 diskcache.Cache 实例。
Raises:
KeyError: 当配置中缺少 cache.cache_store_path 时
"""
config = get_config()
cache_dir = config["cache"]["cache_store_path"]
self._cache = diskcache.Cache(cache_dir)
def set(
self,
key: str,
value: Any,
ex: Optional[int] = None,
px: Optional[int] = None,
raw: bool = False,
) -> bool:
"""
设置缓存
将键值对存储到缓存中,可选设置过期时间。内部使用元组 (value, expire_at)
存储,以支持自定义的过期检查逻辑。
Args:
key: 缓存键,字符串类型
value: 缓存值,可以是任意可序列化的 Python 对象
ex: 过期时间(秒),与 px 互斥,优先使用 ex
px: 过期时间(毫秒),当 ex 为 None 时生效
raw: 是否原始存储(当前未使用,保留参数兼容性)
Returns:
bool: 设置成功返回 True,发生异常返回 False
Note:
- ex 和 px 不能同时设置,ex 优先级更高
- 过期时间在存储时计算绝对时间戳,便于后续检查
- 异常会被捕获并打印错误信息,不会抛出异常
"""
try:
expire_at = None
expire_seconds = None
if ex is not None:
expire_seconds = ex
expire_at = time.time() + ex
elif px is not None:
expire_seconds = px / 1000.0
expire_at = time.time() + expire_seconds
stored_value = (value, expire_at)
self._cache.set(key, stored_value, expire=expire_seconds)
return True
except Exception as e:
print(f"[缓存] set 错误: {e}")
return False
def get(self, key: str, default: Any = None) -> Any:
"""
获取缓存值
根据键获取缓存值,自动检查并清理已过期的缓存项。
如果键不存在或已过期,返回默认值。
Args:
key: 缓存键
default: 当键不存在或已过期时的返回值,默认为 None
Returns:
Any: 缓存的值,如果key不存在或已过期返回default
Note:
- 发现过期数据时会主动删除,避免脏数据累积
- 异常情况下返回 default 值,保证调用安全性
"""
try:
stored = self._cache.get(key)
if stored is None:
return default
value, expire_at = stored # type: ignore
if expire_at is not None and time.time() > expire_at: # type: ignore
self._cache.delete(key)
return default
return value
except Exception as e:
print(f"[缓存] get 错误: {e}")
return default
def delete(self, *keys: str) -> int:
"""
删除一个或多个缓存键
批量删除指定的缓存键,返回成功删除的数量。
Args:
*keys: 可变参数,要删除的键列表
Returns:
int: 成功删除的键数量
Note:
- 逐个删除,某个键删除失败不影响其他键
- 只统计实际存在的键的删除操作
"""
deleted = 0
for key in keys:
if self._cache.delete(key):
deleted += 1
return deleted
def exists(self, key: str) -> bool:
"""
检查键是否存在且未过期
通过尝试获取值来判断键的有效性,比直接检查更可靠,
因为会触发过期检查和清理逻辑。
Args:
key: 要检查的缓存键
Returns:
bool: 键存在且未过期返回 True,否则返回 False
"""
return self.get(key, None) is not None
def expire(self, key: str, _time: int) -> bool:
"""
更新键的过期时间
重新设置指定键的过期时间,保持原值不变。
如果键不存在,操作失败。
Args:
key: 缓存键
_time: 新的过期时间(秒),从当前时间开始计算
Returns:
bool: 设置成功返回 True,键不存在或异常返回 False
Note:
- 需要先读取原值,再重新存储以更新过期时间
- 原子性不保证,高并发场景可能有问题
"""
try:
stored = self._cache.get(key)
if stored is None:
return False
value, _ = stored # type: ignore
expire_at = time.time() + _time
new_stored = (value, expire_at)
self._cache.set(key, new_stored, expire=_time)
return True
except Exception as e:
print(f"[缓存] expire 错误: {e}")
return False
def ttl(self, key: str) -> int:
"""
获取键的剩余生存时间(Time To Live)
计算键距离过期的剩余秒数。
Args:
key: 缓存键
Returns:
int: 剩余秒数,返回值含义:
- >= 0: 剩余存活秒数
- -1: 键存在但永不过期
- -2: 键不存在
Note:
- 返回值为整数,向下取整
- 即使剩余时间为负数也返回 0,表示即将过期
"""
try:
stored = self._cache.get(key)
if stored is None:
return -2
_, expire_at = stored # type: ignore
if expire_at is None:
return -1
remaining = expire_at - time.time() # type: ignore
return max(0, int(remaining))
except Exception as e:
print(f"[缓存] ttl 错误: {e}")
return -2
def keys(self, pattern: Optional[str] = None) -> list:
"""
获取所有缓存键,支持通配符模式匹配
遍历所有缓存键,可根据前缀模式过滤。
Args:
pattern: 匹配模式,仅支持前缀通配,如 "user:*" 匹配所有 "user:" 开头的键
None 或空字符串返回所有键
Returns:
list: 匹配的键列表,按迭代顺序排列
Note:
- 仅支持简单的 "*" 后缀通配,不支持复杂模式
- 大量键时性能较差,建议谨慎使用
- pattern 中的 "*" 会被替换为空字符串作为前缀
"""
all_keys = list(self._cache.iterkeys())
if pattern and "*" in pattern:
prefix = pattern.replace("*", "")
all_keys = [k for k in all_keys if k.startswith(prefix)] # type: ignore
return all_keys
def clear(self) -> bool:
"""
清空所有缓存
删除缓存目录中的所有数据,包括未过期的项。
Returns:
bool: 清空成功返回 True,异常返回 False
Warning:
此操作不可逆,会删除所有缓存数据
"""
try:
self._cache.clear()
return True
except Exception as e:
print(f"[缓存] clear 错误: {e}")
return False
def size(self) -> int:
"""
获取当前有效缓存项数量
统计当前未过期的缓存键数量。
Returns:
int: 有效缓存项的数量
Note:
- 不包含已过期但尚未清理的项
- 需要遍历所有键,大数据量时可能有性能开销
"""
# diskcache 的 __len__ 会返回当前有效条目数
return len(list(self._cache.iterkeys()))
def get_all(self) -> dict:
"""
获取所有未过期的缓存数据
遍历所有缓存键,返回有效的键值对字典。
自动过滤过期数据。
Returns:
dict: 所有有效缓存的键值对,格式为 {key: value}
Note:
- 会触发每个键的过期检查
- 大数据量时内存占用较高,谨慎使用
- 返回的是快照,不保证原子性
"""
result = {}
for key in self._cache.iterkeys():
value = self.get(str(key)) # get 内部会检查并清理过期
if value is not None:
result[key] = value
return result
def close(self):
"""
关闭缓存连接
释放 diskcache 占用的资源,包括文件句柄等。
应在应用退出时调用。
Note:
关闭后不应再使用该实例,除非重新初始化
"""
self._cache.close()
_cache: Optional[Cache] = None
def get_cache() -> Cache:
"""
获取全局缓存单例
使用懒加载模式创建缓存实例,确保整个应用中只有一个 Cache 实例。
Returns:
Cache: 全局缓存实例
Note:
- 线程不安全,多线程环境需要额外同步
- 首次调用时初始化,后续调用返回同一实例
"""
global _cache
if _cache is None:
_cache = Cache()
return _cache
# 装饰器:缓存函数结果
def cached(ttl: Optional[int] = None):
"""
函数结果缓存装饰器
自动缓存函数的返回值,相同参数的调用直接从缓存返回,
避免重复计算。适用于纯函数或副作用小的函数。
Args:
ttl: 缓存生存时间(秒),None 表示永不过期
Returns:
callable: 装饰器函数
Example:
>>> @cached(ttl=60)
... def get_user(user_id: int):
... return {"id": user_id, "name": "test"}
>>>
>>> # 第一次调用执行函数并缓存
>>> get_user(1)
>>> # 60秒内再次调用直接返回缓存结果
>>> get_user(1)
Note:
- 缓存键由函数名 + 位置参数 + 关键字参数组成
- 参数必须是可哈希的,否则会导致错误
- 不适合缓存大型对象或频繁变化的数据
- 不同参数组合会生成不同的缓存键
"""
def decorator(func):
def wrapper(*args, **kwargs):
# 生成缓存key
key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
cache = get_cache()
# 尝试从缓存获取
result = cache.get(key)
if result is not None:
return result
# 执行函数
result = func(*args, **kwargs)
# 存入缓存
cache.set(key, result, ex=ttl)
return result
return wrapper
return decorator
@@ -2,7 +2,6 @@ import sys
from pathlib import Path
from typing import TypedDict, Literal, Optional, cast
if sys.version_info >= (3, 11):
import tomllib
from typing import Required, NotRequired
@@ -0,0 +1,46 @@
class PermissionConstant:
"""
权限常量类 - 按业务模块分组管理所有系统权限
该类定义了系统中各个业务模块的权限标识符,采用分层结构组织:
- 每个业务域作为一个内部类(如 Sales, Purchase, Inventory 等)
- 每个权限使用 "资源:操作" 格式定义
- 支持 CRUD 操作及业务特定操作(如 approve, export 等)
使用示例:
>>> Permission.Sales.ORDER_CREATE # 获取销售订单创建权限
'sales_order:create'
>>> Permission.get_all() # 获取所有权限列表
['sales_order:create', 'sales_order:read', ...]
"""
class Sales:
"""
销售域权限常量
包含销售相关的权限定义:
- 销售订单管理(ORDER_*)
- 销售出库管理(OUTBOUND_*)
- 客户管理(CUSTOMER_*)
权限格式: sales_order:create, customer:export 等
"""
ORDER_CREATE = "sales_order:create" # 创建销售订单
ORDER_READ = "sales_order:read" # 读取销售订单
ORDER_UPDATE = "sales_order:update" # 修改销售订单
ORDER_DELETE = "sales_order:delete" # 删除销售订单
OUTBOUND_CREATE = "sales_outbound:create" # 创建销售出库
OUTBOUND_READ = "sales_outbound:read" # 读取销售出库
OUTBOUND_UPDATE = "sales_outbound:update" # 修改销售出库
OUTBOUND_DELETE = "sales_outbound:delete" # 删除销售出库
OUTBOUND_APPROVE = "sales_outbound:approve" # 审批销售出库
CUSTOMER_CREATE = "customer:create" # 创建客户
CUSTOMER_READ = "customer:read" # 读取客户
CUSTOMER_UPDATE = "customer:update" # 修改客户
CUSTOMER_DELETE = "customer:delete" # 删除客户
CUSTOMER_EXPORT = "customer:export" # 导出客户
...
@@ -1,55 +1,170 @@
import re
import logging
import datetime
from pathlib import Path
from .config import Logging
from logging.handlers import RotatingFileHandler
from ..core.config import get_config
config = get_config()
env = config["default"]["env"]
server_config = config[env]["server"]
log_config = config[env]["logging"]
class ColoredFormatter(logging.Formatter):
"""
为 HTTP 状态码添加颜色的日志格式化器
根据 HTTP 状态码的首位数字为日志消息中的状态码添加不同颜色:
- 2xx: 绿色(成功)
- 3xx: 青色(重定向)
- 4xx: 黄色(客户端错误)
- 5xx: 红色(服务器错误)
Attributes:
COLORS: 状态码首位数字到 ANSI 颜色代码的映射字典
RESET: ANSI 重置颜色代码
"""
COLORS = {
"2": "\033[32m", # 绿色 2xx
"3": "\033[36m", # 青色 3xx
"4": "\033[33m", # 黄色 4xx
"5": "\033[31m", # 红色 5xx
}
RESET = "\033[0m"
def format(self, record):
"""
格式化日志记录,为状态码添加颜色
如果日志记录包含 status_code 属性,则查找消息中的 "status=数字" 模式
并为其添加对应的颜色代码。格式化完成后恢复原始消息以避免影响其他 handler。
Args:
record: 日志记录对象,应包含 msg 属性和可选的 status_code 属性
Returns:
str: 格式化后的日志消息字符串,状态码部分可能包含 ANSI 颜色代码
"""
# 保存原始消息
original_msg = record.msg
# 如果日志记录包含 status_code 属性,则为其添加颜色
status_code = getattr(record, "status_code", None)
if status_code and isinstance(status_code, int):
first_digit = str(status_code)[0]
color = self.COLORS.get(first_digit, "")
colored_status = f"{color}{status_code}{self.RESET}"
# 替换消息中的 "status=数字" 为带颜色的版本
record.msg = re.sub(
rf"status={status_code}\b", f"status={colored_status}", original_msg
)
result = super().format(record)
# 恢复原始消息,避免影响其他 handler
record.msg = original_msg
return result
class ExcludeWatchFilesFilter(logging.Filter):
"""
日志过滤器,用于排除第三方库的日志输出
过滤掉来自 watchfiles、aiosqlite、apscheduler、sqlalchemy 等库的日志记录,
避免这些库的详细日志污染应用日志输出。
"""
def __init__(self):
"""
初始化过滤器,设置需要过滤的日志名称前缀列表
"""
super().__init__()
self._filter_starts_with_tulpe = (
"watchfiles",
"aiosqlite",
"apscheduler",
"sqlalchemy",
"sqlitedict",
"sqlalchemy.engine.Engine",
)
def filter(self, record):
# 如果日志记录来自 watchfiles 模块,则不记录(返回 False)
return not record.name.startswith("watchfiles")
"""
判断是否应该保留该日志记录
Args:
record: 日志记录对象,包含 name 属性表示日志来源
Returns:
bool: 如果日志名称不以过滤列表中的任何前缀开头则返回 True(保留),
否则返回 False(过滤掉)
"""
return not record.name.startswith(self._filter_starts_with_tulpe)
def setup_logging(log_config: Logging):
_configured_loggers = set()
def setup_logging(logger: logging.Logger, log_name: str = "app"):
"""
配置日志系统,包括文件和控制台输出
为指定的 logger 配置以下功能:
1. 设置日志级别和格式
2. 创建按日期命名的轮转文件处理器(支持文件大小限制和备份数量控制)
3. 创建带颜色格式化的控制台处理器
4. 应用过滤器排除第三方库日志
该函数具有防重复配置机制,同一个 log_name 只会配置一次。
Args:
logger: 需要配置的 logging.Logger 实例
log_name: 日志名称标识,用于区分不同的日志配置和生成日志文件名,默认为 "app"
Returns:
None
Raises:
无显式抛出异常,但可能因文件系统权限问题导致目录创建或文件写入失败
"""
if log_name in _configured_loggers:
return
_configured_loggers.add(log_name)
log_level = getattr(logging, log_config["level"], logging.INFO)
log_format = log_config["format"]
# 创建文件夹
log_file_path = Path(log_config["file"])
log_file_dir = log_file_path.parent
if log_file_dir != Path("."):
log_file_dir.mkdir(parents=True, exist_ok=True)
# 设置特定日志级别的过滤
logging.getLogger("watchfiles").setLevel(logging.WARNING)
if log_file_path != Path("."):
log_file_path.mkdir(parents=True, exist_ok=True)
# 配置根日志记录器
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
logger.setLevel(log_level)
# 创建格式化器
formatter = logging.Formatter(log_format)
plain_formatter = logging.Formatter(log_format)
color_formatter = ColoredFormatter(log_format)
# 创建轮转文件处理器 - 限制文件大小为 10MB,保留 5 个备份文件
max_bytes = log_config.get("max_size", 10 * 1024 * 1024) # 默认 10MB
backup_count = log_config.get("backup_count", 5) # 默认保留 5 个备份
file_handler = RotatingFileHandler(
log_config["file"],
f"{log_config['file']}/{log_name}-{datetime.date.today()}.log",
encoding="utf-8",
maxBytes=max_bytes,
backupCount=backup_count,
)
file_handler.setFormatter(formatter)
file_handler.setFormatter(plain_formatter)
file_handler.addFilter(ExcludeWatchFilesFilter())
# 创建控制台处理器并添加过滤器
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setFormatter(color_formatter)
console_handler.addFilter(ExcludeWatchFilesFilter())
# 添加处理器到根日志记录器
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
print(
f"日志系统已配置 - 级别: {logging.getLevelName(log_level)}, 文件: {log_config['file']} "
@@ -58,7 +173,18 @@ def setup_logging(log_config: Logging):
def get_logger(name: str) -> logging.Logger:
"""
获取配置好的日志记录器实例
根据名称获取或创建 logger 实例,并自动调用 setup_logging 进行配置。
如果该名称的 logger 已经配置过,则直接返回已配置的实例。
Args:
name: 日志记录器名称,通常使用模块名如 __name__
Returns:
logging.Logger: 已配置完成的日志记录器实例,可直接用于记录日志
"""
logger = logging.getLogger(name)
# 为每个logger添加过滤器
logger.addFilter(ExcludeWatchFilesFilter())
setup_logging(logger, name)
return logger
@@ -1,14 +1,36 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
import bcrypt
def get_password_hash(password: str) -> str:
"""密码哈希"""
return pwd_context.hash(password)
"""
对明文密码进行哈希加密
使用bcrypt算法对密码进行加盐哈希处理,确保密码存储的安全性。
Args:
password: 需要加密的明文密码字符串
Returns:
加密后的密码哈希字符串,采用UTF-8编码
"""
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
验证明文密码与哈希密码是否匹配
将明文密码与已存储的哈希密码进行比对,用于用户登录时的密码验证。
Args:
plain_password: 用户输入的明文密码
hashed_password: 数据库中存储的哈希密码
Returns:
密码匹配返回True,否则返回False
"""
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
@@ -0,0 +1,100 @@
from fastapi import HTTPException
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlite_vfs.core import SQLiteVFS
class SVFSStaticFiles(StaticFiles):
"""
基于 SQLite VFS 的静态文件服务类
该类继承自 FastAPI 的 StaticFiles,使用 SQLite 虚拟文件系统来存储和提供静态文件。
支持单页应用(SPA)的路由回退机制,当请求的文件不存在时,会自动返回 index.html。
"""
def __init__(self, directory, vfs_db_path="static.svfs"):
"""
初始化 SVFSStaticFiles 实例
参数:
directory (str): 静态文件的根目录路径
vfs_db_path (str): SQLite VFS 数据库文件路径,默认为 "static.svfs"
返回值:
无
"""
vfs_db_path = vfs_db_path
self._vfs = SQLiteVFS(vfs_db_path, compress=False)
super().__init__(directory=directory)
async def get_response(self, path: str, scope):
"""
根据请求路径获取对应的静态文件响应
该方法处理静态文件的请求逻辑,包括:
1. 路径标准化处理
2. 文件存在性检查
3. SPA 路由回退支持
4. 目录索引处理
5. MIME 类型自动识别
参数:
path (str): 请求的文件路径
scope: ASGI 作用域对象,包含请求上下文信息
返回值:
Response: FastAPI 响应对象,包含文件内容和正确的 Content-Type
异常:
HTTPException: 当文件不存在且无法回退到 index.html 时,抛出 404 错误
"""
# 统一路径格式:以 / 开头,并将 Windows 反斜杠转为正斜杠
normalized_path = "/" + path.replace("\\", "/")
# 获取文件信息
file_info = self._vfs.get_file_info(normalized_path)
# 处理文件不存在的情况,尝试 SPA 回退机制
if file_info is None:
# SPA 回退:如果不是静态资源扩展名,尝试返回 index.html
if not any(
normalized_path.endswith(ext)
for ext in [
".js",
".css",
".png",
".jpg",
".ico",
".svg",
".webp",
".json",
]
):
index_info = self._vfs.get_file_info("/index.html")
if index_info:
content = self._vfs.read_file("/index.html")
return Response(content=content, media_type="text/html")
raise HTTPException(status_code=404, detail="File not found")
# 处理目录请求,返回目录下的 index.html
if file_info["is_directory"]:
index_info = self._vfs.get_file_info(f"{normalized_path}/index.html")
if index_info:
content = self._vfs.read_file(f"{normalized_path}/index.html")
return Response(content=content, media_type="text/html")
raise HTTPException(status_code=404, detail="Directory index not found")
# 读取文件内容并确定 MIME 类型
content = self._vfs.read_file(normalized_path)
content_type = file_info.get("content_type") or "application/octet-stream"
# 简单根据扩展名补充 MIME 类型(可选)
if normalized_path.endswith(".js"):
content_type = "application/javascript"
elif normalized_path.endswith(".css"):
content_type = "text/css"
elif normalized_path.endswith(".html"):
content_type = "text/html"
elif normalized_path.endswith(".ico"):
content_type = "image/x-icon"
return Response(content=content, media_type=content_type)
@@ -0,0 +1,15 @@
from .user import User
from .role import Role, RoleUpdate
from .permission import Permission
from .relationship_table import UserRole, RolePermission
from .todo import Todo
__all__ = [
"User",
"Role",
"RoleUpdate",
"Permission",
"UserRole",
"RolePermission",
"Todo",
]
@@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional
class Detail(BaseModel):
"""
HTTP错误详情模型
用于封装HTTP错误的详细信息,包括错误消息和错误代码。
Attributes:
message (str): 错误描述信息
code (int): 错误状态码或业务错误码
"""
message: str
code: int
class HTTPError(BaseModel):
"""
HTTP错误响应模型
用于标准化HTTP错误响应的数据结构,符合RESTful API规范。
Attributes:
detail (Optional[Detail]): 错误详情对象,包含具体的错误信息和错误码。
如果为None,表示没有详细的错误信息。
"""
detail: Optional[Detail] = None
@@ -0,0 +1,36 @@
from typing import Optional, List, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from .relationship_table import RolePermission
if TYPE_CHECKING:
from .role import Role
class Permission(SQLModel, table=True):
"""
权限模型类,用于定义系统中的权限项。
权限采用资源:操作的格式进行标识,例如:
- user:create 表示创建用户的权限
- order:delete 表示删除订单的权限
Attributes:
id: 权限的唯一标识符,主键,自增
name: 权限标识符,唯一且建立索引,格式为"资源:操作"
description: 权限的描述信息,可选字段
resource: 资源名称,建立索引,如 user、order、product 等
action: 操作类型,如 create、read、update、delete 等
roles: 关联的角色列表,通过 RolePermission 中间表实现多对多关系
"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True)
description: Optional[str] = None
resource: str = Field(index=True)
action: str
roles: List["Role"] = Relationship(
back_populates="permissions", link_model=RolePermission
)
@@ -0,0 +1,35 @@
from uuid import UUID
from sqlmodel import SQLModel, Field
# ---------- 关联表 ----------
class UserRole(SQLModel, table=True):
"""
用户角色关联表
用于实现用户与角色之间的多对多关系。
每个记录表示一个用户拥有的一个角色。
Attributes:
user_id: 用户ID,外键关联到user表的id字段,作为复合主键的一部分
role_id: 角色ID,外键关联到role表的id字段,作为复合主键的一部分
"""
user_id: UUID = Field(foreign_key="user.id", primary_key=True)
role_id: int = Field(foreign_key="role.id", primary_key=True)
class RolePermission(SQLModel, table=True):
"""
角色权限关联表
用于实现角色与权限之间的多对多关系。
每个记录表示一个角色拥有的一项权限。
Attributes:
role_id: 角色ID,外键关联到role表的id字段,作为复合主键的一部分
permission_id: 权限ID,外键关联到permission表的id字段,作为复合主键的一部分
"""
role_id: int = Field(foreign_key="role.id", primary_key=True)
permission_id: int = Field(foreign_key="permission.id", primary_key=True)
@@ -0,0 +1,52 @@
from typing import Optional, List, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from pydantic import BaseModel
from .relationship_table import UserRole, RolePermission
if TYPE_CHECKING:
from .user import User
from .permission import Permission
# ---------- 角色表 ----------
class Role(SQLModel, table=True):
"""
角色模型类,用于定义系统中的用户角色。
该类继承自SQLModel,映射到数据库中的角色表。每个角色可以有多个用户和多个权限,
通过中间表UserRole和RolePermission实现多对多关系。
Attributes:
id: 角色的唯一标识符,主键,自增
name: 角色名称,具有唯一性约束和索引,例如:admin, manager, operator, viewer
description: 角色的描述信息,可选字段
is_only_user: 标识该角色是否仅限单个用户使用,默认为False
users: 与该角色关联的用户列表,通过UserRole中间表建立多对多关系
permissions: 与该角色关联的权限列表,通过RolePermission中间表建立多对多关系
"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True) # admin, manager, operator, viewer
description: Optional[str] = None
is_only_user: bool = Field(default=False)
users: List["User"] = Relationship(back_populates="roles", link_model=UserRole)
permissions: List["Permission"] = Relationship(
back_populates="roles", link_model=RolePermission
)
class RoleUpdate(BaseModel):
"""
角色更新请求模型类,用于接收角色更新时的参数。
该类继承自Pydantic的BaseModel,所有字段均为可选,允许部分更新角色信息。
Attributes:
name: 更新后的角色名称,可选字段
description: 更新后的角色描述信息,可选字段
"""
name: Optional[str] = None
description: Optional[str] = None
@@ -5,24 +5,71 @@ from sqlmodel import SQLModel, Field
# 定义模型
class TodoBase(SQLModel):
"""
Todo任务的基础模型类,包含任务的通用字段。
Attributes:
title: 任务标题,必填字段
description: 任务描述,可选字段,默认为None
completed: 任务完成状态,布尔值,默认为False
"""
title: str
description: Optional[str] = None
completed: bool = False
class Todo(TodoBase, table=True):
"""
Todo任务的数据库表模型类,继承自TodoBase。
该类对应数据库中的todo表,用于存储和查询任务数据。
Attributes:
id: 任务唯一标识符,主键,由数据库自动生成
"""
id: Optional[int] = Field(default=None, primary_key=True)
class TodoCreate(TodoBase):
"""
创建Todo任务时使用的请求模型类。
继承自TodoBase,用于接收前端传入的新建任务数据。
不包含id字段,因为id由数据库自动生成。
"""
pass
class TodoRead(TodoBase):
"""
读取Todo任务时使用的响应模型类。
继承自TodoBase,用于向客户端返回任务数据。
包含完整的任务信息,包括自动生成的id字段。
Attributes:
id: 任务唯一标识符,整数类型,必填字段
"""
id: int
class TodoUpdate(SQLModel):
"""
更新Todo任务时使用的请求模型类。
所有字段均为可选,允许部分更新任务信息。
只有提供的字段会被更新,未提供的字段保持原值。
Attributes:
title: 任务标题,可选字段
description: 任务描述,可选字段
completed: 任务完成状态,可选字段
"""
title: Optional[str] = None
description: Optional[str] = None
completed: Optional[bool] = None
@@ -1,36 +1,127 @@
from typing import Optional
from sqlmodel import SQLModel, Field
from typing import Optional, List, Dict, TYPE_CHECKING
from sqlmodel import SQLModel, Field, Relationship
from pydantic import BaseModel
from .relationship_table import UserRole
from uuid import uuid4, UUID
if TYPE_CHECKING:
from .role import Role
class UserBase(SQLModel):
"""
用户基础模型
定义用户的核心属性,作为其他用户相关模型的基类。
包含用户的基本信息和状态标识。
Attributes:
email: 用户邮箱地址,唯一且建立索引
username: 用户名,可选,最大长度50字符
full_name: 用户全名,可选,最大长度100字符
is_active: 用户激活状态,默认为True
is_superuser: 是否为超级管理员,默认为False
created_at: 创建时间,可选字段
"""
email: str = Field(unique=True, index=True)
username: Optional[str] = Field(default=None, max_length=50)
full_name: Optional[str] = Field(default=None, max_length=100)
is_active: bool = Field(default=True)
is_superuser: bool = Field(default=False)
created_at: Optional[str] = Field(default=None) # 可自动填充,此处略
created_at: Optional[str] = Field(default=None)
class User(UserBase, table=True):
"""用户模型"""
"""
用户数据库模型
id: Optional[int] = Field(default=None, primary_key=True)
继承自UserBase,用于数据库表映射。
包含用户的完整信息以及与角色的多对多关系。
Attributes:
id: 用户唯一标识符,UUID类型,自动生成
hashed_password: 加密后的密码
roles: 用户关联的角色列表,通过UserRole中间表实现多对多关系
"""
id: UUID = Field(default_factory=uuid4, primary_key=True)
hashed_password: str
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRole)
class UserCreate(UserBase):
"""用户创建模型"""
"""
用户创建请求模型
用于创建新用户时的数据验证和传输。
在UserBase基础上增加了密码字段。
Attributes:
password: 用户明文密码,用于创建时传入
"""
password: str
class UserRead(UserBase):
"""用户读取模型"""
"""
用户读取响应模型
id: int
用于向客户端返回用户信息。
在UserBase基础上增加了用户ID字段。
Attributes:
id: 用户唯一标识符,UUID类型
"""
id: UUID
class UserLogin(BaseModel):
"""
用户登录请求模型
用于处理用户登录时的数据验证。
Attributes:
email: 用户邮箱地址
password: 用户密码
"""
email: str
password: str
class Userlogout(BaseModel):
"""
用户登出响应模型
用于返回登出操作的结果。
Attributes:
code: 响应状态码,默认为200表示成功
message: 响应消息,默认为"logout success"
"""
code: int = 200
message: str = "logout success"
class UserSession(BaseModel):
"""
用户会话信息模型
用于存储和返回用户的会话相关信息。
Attributes:
user_id: 用户唯一标识符
sessions: 会话列表,每个会话为字典类型,键值均为字符串或None
count: 会话总数
"""
user_id: str
sessions: List[Dict[str, Optional[str]]]
count: int
@@ -4,13 +4,14 @@
from typing import Optional
from fastapi import Request
from passlib.context import CryptContext
import bcrypt
from sqlalchemy.ext.asyncio import AsyncSession
from ..model.user import User
from .user import UserService
from ..core.jwt_cache import get_jwt_cache, TokenResponse, TokenData
from ..core.config import get_config
from .user import UserService
from plugins.jwt.jwt_cache import get_jwt_cache, TokenResponse, TokenData
class AuthService:
@@ -19,16 +20,29 @@ class AuthService:
def __init__(self):
self.jwt_cache = get_jwt_cache()
self.settings = get_config()
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def authenticate_user(
self, email: str, password: str, session: AsyncSession
) -> Optional[User]:
"""验证用户凭据"""
"""
验证用户凭据
通过邮箱和密码验证用户身份,使用 bcrypt 进行密码哈希比对。
Args:
email: 用户邮箱地址
password: 用户明文密码
session: 数据库异步会话对象
Returns:
验证成功返回 User 对象,失败返回 None
"""
user = await UserService.get_user_by_email(email, session)
if not user:
return None
if not self.pwd_context.verify(password, user.hashed_password):
if not bcrypt.checkpw(
password.encode("utf-8"), user.hashed_password.encode("utf-8")
):
return None
return user
@@ -39,7 +53,20 @@ class AuthService:
request: Optional[Request] = None,
device_id: Optional[str] = None,
) -> TokenResponse:
"""为用户创建访问和刷新令牌"""
"""
为用户创建访问和刷新令牌
根据用户信息和请求上下文生成 JWT access token 和 refresh token,
同时记录设备信息用于安全审计。
Args:
user: 已验证的用户对象
request: FastAPI 请求对象,用于提取客户端信息(可选)
device_id: 设备唯一标识符(可选)
Returns:
TokenResponse 对象,包含 access_token、refresh_token 及过期时间
"""
# 获取用户数据
user_data = {
@@ -86,7 +113,17 @@ class AuthService:
)
async def validate_access_token(self, token: str) -> Optional[TokenData]:
"""验证访问令牌"""
"""
验证访问令牌
校验 JWT access token 的有效性和完整性,提取用户身份信息。
Args:
token: JWT access token 字符串
Returns:
验证成功返回 TokenData 对象(包含 user_id、username、email),失败返回 None
"""
validation = self.jwt_cache.validate_token(token, token_type="access")
if not validation:
return None
@@ -104,15 +141,28 @@ class AuthService:
session: AsyncSession,
request: Optional[Request] = None,
) -> Optional[str]:
"""使用刷新令牌获取新的访问令牌"""
validation = self.jwt_cache.validate_token(refresh_token, token_type="access")
"""
使用刷新令牌获取新的访问令牌
验证 refresh token 的有效性,确认用户仍然存在,然后生成新的 access token。
保持原有的设备信息以确保会话连续性。
Args:
refresh_token: JWT refresh token 字符串
session: 数据库异步会话对象
request: FastAPI 请求对象,用于提取客户端信息(可选)
Returns:
验证成功返回新的 access token 字符串,失败返回 None
"""
validation = self.jwt_cache.validate_token(refresh_token, token_type="refresh")
if not validation:
return None
user_id = validation["user_id"]
# 获取用户
user = await UserService.get_user_by_id(int(user_id), session)
user = await UserService.get_user_by_id(user_id, session)
if not user:
return None
@@ -142,15 +192,39 @@ class AuthService:
return new_access_token
async def logout(self, token: str):
"""用户登出 - 使令牌失效"""
"""
用户登出 - 使令牌失效
将指定的 token 加入黑名单,使其立即失效。
Args:
token: 需要失效的 JWT token 字符串
"""
self.jwt_cache.invalidate_token(token)
async def logout_all(self, user_id: str):
"""使用户的所有令牌失效"""
"""
使用户的所有令牌失效
强制注销用户的所有活跃会话,适用于密码修改或安全事件场景。
Args:
user_id: 用户 ID 字符串
"""
self.jwt_cache.invalidate_user_tokens(str(user_id))
async def get_user_sessions(self, user_id: str):
"""获取用户的活跃会话"""
"""
获取用户的活跃会话
查询指定用户当前所有有效的 token 会话信息。
Args:
user_id: 用户 ID 字符串
Returns:
用户活跃会话列表
"""
return self.jwt_cache.get_user_sessions(str(user_id))
@@ -159,7 +233,14 @@ _auth_service = None
def get_auth_service() -> AuthService:
"""获取认证服务实例(单例模式)"""
"""
获取认证服务实例(单例模式)
确保整个应用中只有一个 AuthService 实例,避免重复初始化。
Returns:
AuthService 单例实例
"""
global _auth_service
if _auth_service is None:
_auth_service = AuthService()
@@ -0,0 +1,193 @@
from typing import Optional
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from ..model import Permission, RolePermission, Role, User
from .user import UserService
from .role import RoleService
class PermissionService:
"""权限服务类,提供权限的创建、查询、分配和解除绑定等功能"""
@classmethod
async def create_permission(
cls,
session: AsyncSession,
name: str,
resource: str,
action: str,
description: Optional[str] = None,
) -> Permission:
"""
创建新的权限记录
:param session: 数据库会话对象
:param name: 权限名称
:param resource: 资源标识
:param action: 操作类型
:param description: 权限描述,可选
:return: 创建的权限对象
"""
permission = Permission(
name=name, resource=resource, action=action, description=description
)
session.add(permission)
await session.commit()
await session.refresh(permission)
return permission
@classmethod
async def get_permission_by_id(
cls, session: AsyncSession, permission_id: int
) -> Optional[Permission]:
"""
根据ID获取权限
:param session: 数据库会话对象
:param permission_id: 权限ID
:return: 权限对象,如果不存在则返回None
"""
return await session.get(Permission, permission_id)
@classmethod
async def get_permission_by_name(
cls, session: AsyncSession, name: str
) -> Optional[Permission]:
"""
根据名称获取权限
:param session: 数据库会话对象
:param name: 权限名称
:return: 权限对象,如果不存在则返回None
"""
result = await session.execute(
select(Permission).where(Permission.name == name)
)
return result.scalar_one_or_none()
@classmethod
async def assign_permission_to_role(
cls, session: AsyncSession, role_id: int, permission_id: int
) -> RolePermission:
"""
将权限分配给角色
:param session: 数据库会话对象
:param role_id: 角色ID
:param permission_id: 权限ID
:return: 创建的角色权限关联对象
"""
role_permission = RolePermission(role_id=role_id, permission_id=permission_id)
session.add(role_permission)
await session.commit()
await session.refresh(role_permission)
return role_permission
@classmethod
async def assign_permission_to_user(
cls,
session: AsyncSession,
user_id: UUID,
permission_id: int,
) -> User:
"""
将权限分配给用户
如果用户没有专属角色,则会为用户创建一个专属角色,然后将权限分配给该角色
:param session: 数据库会话对象
:param user_id: 用户UUID
:param permission_id: 权限ID
:return: 更新后的用户对象
:raises HTTPException: 当权限或用户不存在时抛出404错误,当角色或权限ID无效时抛出500错误
"""
# 验证权限是否存在
permission = await cls.get_permission_by_id(session, permission_id)
if not permission:
raise HTTPException(status_code=404, detail="Permission not found")
# 验证用户是否存在
user = await UserService.get_user_by_id(user_id, session)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# 获取或创建用户的专属角色
role = await RoleService.get_role_by_name(session, str(user_id))
if role is None:
role = await RoleService.create_role(
session, str(user_id), is_only_user=True
)
# 验证角色和权限ID的有效性
if role.id is None:
raise HTTPException(status_code=500, detail="角色ID无效")
if permission.id is None:
raise HTTPException(status_code=500, detail="权限ID无效")
# 将权限分配给角色
await cls.assign_permission_to_role(session, role.id, permission.id)
user.roles.append(role)
await session.commit()
await session.refresh(user)
return user
@classmethod
async def unbind_permission_from_role(
cls, session: AsyncSession, role_id: int, permission_id: int
) -> Role:
"""
从角色中解除权限绑定
:param session: 数据库会话对象
:param role_id: 角色ID
:param permission_id: 权限ID
:return: 更新后的角色对象
:raises HTTPException: 当角色不存在时抛出404错误
"""
# 验证角色是否存在
role = await RoleService.get_role_by_id(session, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
# 从角色的权限列表中移除指定权限
role.permissions = [p for p in role.permissions if p.id != permission_id]
await session.commit()
await session.refresh(role)
return role
@classmethod
async def unbind_permission_from_user(
cls, session: AsyncSession, user_id: UUID, permission_id: int
):
"""
从用户中解除权限绑定
通过找到用户的专属角色,从该角色中移除指定权限
:param session: 数据库会话对象
:param user_id: 用户UUID
:param permission_id: 权限ID
:raises HTTPException: 当用户或角色不存在时抛出404错误
"""
# 验证用户是否存在
user = await UserService.get_user_by_id(user_id, session)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 获取用户的专属角色
role = await RoleService.get_role_by_name(session, str(user.id))
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
# 从角色权限列表中移除指定权限
role.permissions = [
permission
for permission in role.permissions
if permission.id != permission_id
]
await session.commit()
await session.refresh(role)
@@ -0,0 +1,187 @@
from typing import Optional
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from ..model import Role, RoleUpdate, UserRole
from ..service.user import UserService
class RoleService:
"""角色服务类,提供角色管理相关的业务逻辑"""
@staticmethod
async def create_role(
session: AsyncSession,
name: str,
description: Optional[str] = None,
is_only_user: bool = False,
) -> Role:
"""
创建新角色
Args:
session: 数据库会话对象
name: 角色名称
description: 角色描述,可选
is_only_user: 是否仅用于用户,默认为False
Returns:
Role: 创建成功的角色对象
"""
role = Role(name=name, description=description, is_only_user=is_only_user)
session.add(role)
await session.commit()
await session.refresh(role)
return role
@staticmethod
async def assign_role_to_user(
session: AsyncSession,
user_id: UUID,
role_id: int,
):
"""
为用户分配角色
Args:
session: 数据库会话对象
user_id: 用户UUID
role_id: 角色ID
"""
user_role = UserRole(user_id=user_id, role_id=role_id)
session.add(user_role)
await session.commit()
await session.refresh(user_role)
@classmethod
async def get_role_by_name(cls, session: AsyncSession, name: str) -> Optional[Role]:
"""
根据角色名称查询角色
Args:
session: 数据库会话对象
name: 角色名称
Returns:
Optional[Role]: 找到的角色对象,未找到则返回None
"""
result = await session.execute(select(Role).where(Role.name == name))
return result.scalar_one_or_none()
@classmethod
async def get_role_by_id(
cls, session: AsyncSession, role_id: int
) -> Optional[Role]:
"""
根据角色ID查询角色
Args:
session: 数据库会话对象
role_id: 角色ID
Returns:
Optional[Role]: 找到的角色对象,未找到则返回None
"""
result = await session.get(Role, role_id)
return result
@staticmethod
async def unbind_role_from_user(
session: AsyncSession,
user_id: UUID,
role_id: int,
):
"""
从用户解绑角色
Args:
session: 数据库会话对象
user_id: 用户UUID
role_id: 角色ID
Returns:
User: 更新后的用户对象
Raises:
HTTPException: 当用户不存在时抛出404错误
"""
user = await UserService.get_user_by_id(user_id, session)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
# 过滤掉指定角色,保留其他角色
user.roles = [role for role in user.roles if role.id != role_id]
await session.commit()
await session.refresh(user)
return user
@staticmethod
async def delete_role(
role_id: int,
session: AsyncSession,
) -> bool:
"""
删除角色
Args:
role_id: 角色ID
session: 数据库会话对象
Returns:
bool: 删除成功返回True
Raises:
HTTPException: 当角色被使用时抛出400错误,角色不存在时抛出404错误
"""
# 检查角色是否被用户使用
result = await session.execute(
select(UserRole).where(UserRole.role_id == role_id)
)
if result.scalars().all():
raise HTTPException(status_code=400, detail="Role is not empty")
role = await RoleService.get_role_by_id(session, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
await session.delete(role)
await session.commit()
return True
@classmethod
async def update_role(
cls,
role_id: int,
role_update: RoleUpdate,
session: AsyncSession,
) -> Optional[Role]:
"""
更新角色信息
Args:
role_id: 角色ID
role_update: 角色更新数据对象
session: 数据库会话对象
Returns:
Optional[Role]: 更新后的角色对象
Raises:
HTTPException: 当角色不存在时抛出404错误
"""
role = await cls.get_role_by_id(session, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
# 只更新提供的字段,保持原有值不变
role.name = role_update.name if role_update.name else role.name
role.description = (
role_update.description if role_update.description else role.description
)
await session.commit()
await session.refresh(role)
return role
@@ -7,8 +7,20 @@ from ..model.todo import TodoCreate, Todo, TodoUpdate
class TodoService:
"""待办事项服务类,提供待办事项的增删改查操作"""
@staticmethod
async def create_todo(todo: TodoCreate, session: AsyncSession) -> Todo:
"""
创建新的待办事项
Args:
todo: 待办事项创建数据对象,包含待办事项的初始信息
session: 异步数据库会话对象,用于执行数据库操作
Returns:
Todo: 创建成功后的待办事项对象,包含生成的ID等信息
"""
db_todo = Todo.model_validate(todo)
session.add(db_todo)
await session.commit()
@@ -21,6 +33,17 @@ class TodoService:
limit: int,
session: AsyncSession,
) -> Sequence[Todo]:
"""
获取待办事项列表(支持分页)
Args:
offset: 查询偏移量,指定从第几条记录开始查询
limit: 查询限制数量,指定最多返回多少条记录
session: 异步数据库会话对象,用于执行数据库操作
Returns:
Sequence[Todo]: 待办事项对象列表,按查询条件返回的结果集
"""
result = await session.execute(select(Todo).offset(offset).limit(limit))
todos = result.scalars().all()
return todos
@@ -30,6 +53,16 @@ class TodoService:
todo_id: int,
session: AsyncSession,
) -> Optional[Todo]:
"""
根据ID获取单个待办事项
Args:
todo_id: 待办事项的唯一标识ID
session: 异步数据库会话对象,用于执行数据库操作
Returns:
Optional[Todo]: 如果找到则返回待办事项对象,否则返回None
"""
todo = await session.get(Todo, todo_id)
return todo
@@ -39,9 +72,22 @@ class TodoService:
todo_update: TodoUpdate,
session: AsyncSession,
) -> Optional[Todo]:
"""
更新待办事项信息
Args:
todo_id: 待办事项的唯一标识ID
todo_update: 待办事项更新数据对象,包含需要更新的字段信息
session: 异步数据库会话对象,用于执行数据库操作
Returns:
Optional[Todo]: 如果更新成功则返回更新后的待办事项对象,如果待办事项不存在则返回None
"""
todo = await session.get(Todo, todo_id)
if not todo:
return None
# 提取需要更新的字段数据,排除未设置的字段
update_data = todo_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
@@ -57,6 +103,16 @@ class TodoService:
todo_id: int,
session: AsyncSession,
) -> bool:
"""
删除待办事项
Args:
todo_id: 待办事项的唯一标识ID
session: 异步数据库会话对象,用于执行数据库操作
Returns:
bool: 如果删除成功返回True,如果待办事项不存在返回False
"""
todo = await session.get(Todo, todo_id)
if not todo:
return False
@@ -0,0 +1,81 @@
import os
import uuid
from pathlib import Path
from fastapi.exceptions import HTTPException
from fastapi import UploadFile
import aiofiles
from ..core.config import get_config
config = get_config()
class UploadService:
"""
文件上传服务类
提供文件上传功能,支持将上传的文件保存到指定目录,
并生成唯一的文件名以避免冲突。
"""
def __init__(self, upload_dir: str, chunk_size: int, file_type: str):
"""
初始化上传服务
Args:
upload_dir: 文件上传的根目录路径
chunk_size: 文件读取的块大小(字节),用于控制内存使用
file_type: 文件类型标识,用于创建对应的子目录
"""
self.upload_dir = upload_dir
self.file_type = file_type
self.chunk_size = chunk_size
UPLOAD_DIR = Path(f"{self.upload_dir}/{self.file_type}")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
async def upload(self, file_data: UploadFile):
"""
异步上传文件
接收上传的文件数据,生成唯一文件名,并将文件分块写入到服务器磁盘。
上传成功后返回文件的访问URL和新文件名。
Args:
file_data: FastAPI的UploadFile对象,包含上传的文件数据和元信息
Returns:
tuple: 包含两个元素的元组
- str: 文件的完整访问URL路径
- str: 生成的新文件名(UUID + 原始扩展名)
Raises:
HTTPException:
- 400: 当文件名为空或无效时抛出
- 500: 当文件写入过程中发生异常时抛出
"""
if file_data.filename is None:
raise HTTPException(status_code=400, detail="Invalid file name")
# 提取文件扩展名并生成唯一的UUID文件名
ext = os.path.splitext(file_data.filename)[1].lower()
new_filename = f"{uuid.uuid4().hex}{ext}"
file_path = os.path.join(self.upload_dir, self.file_type, new_filename)
try:
# 分块读取上传文件并异步写入磁盘,避免大文件占用过多内存
async with aiofiles.open(file_path, "wb") as out_file:
while chunk := await file_data.read(self.chunk_size):
await out_file.write(chunk)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
await file_data.close()
# 构建并返回文件的访问URL和新文件名
return (
f"{config['default']['prefix']}/{self.file_type}/{new_filename}".replace(
"\\", "/"
),
new_filename,
)
@@ -1,25 +1,64 @@
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from ..model.user import User
from ..core.security import get_password_hash
class UserService:
"""用户服务类,提供用户相关的业务逻辑操作"""
@staticmethod
async def get_user_by_email(email: str, session: AsyncSession) -> Optional[User]:
"""
根据邮箱地址查询用户
Args:
email: 用户的邮箱地址
session: 异步数据库会话
Returns:
如果找到则返回用户对象,否则返回None
"""
result = await session.execute(select(User).where(User.email == email)) # type: ignore
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_id(user_id: int, session: AsyncSession) -> Optional[User]:
async def get_user_by_id(user_id: UUID, session: AsyncSession) -> Optional[User]:
"""
根据用户ID查询用户
Args:
user_id: 用户的UUID标识
session: 异步数据库会话
Returns:
如果找到则返回用户对象,否则返回None
"""
return await session.get(User, user_id)
@staticmethod
async def create_user(
session: AsyncSession, email: str, password: str, **kwargs
) -> User:
"""
创建新用户
Args:
session: 异步数据库会话
email: 用户的邮箱地址
password: 用户的明文密码,将自动进行哈希加密
**kwargs: 其他用户属性参数
Returns:
创建成功的用户对象
Raises:
IntegrityError: 当邮箱已存在时可能抛出完整性错误
"""
hashed = get_password_hash(password)
user = User(email=email, hashed_password=hashed, **kwargs)
session.add(user)
@@ -0,0 +1,31 @@
from ..core.config import get_config
from .dev_init_db import init_db
from plugins.scheduler.scheduler import TaskService
config = get_config()
async def init_tasks_callback(task_service: TaskService):
"""
初始化任务回调函数
在开发环境下自动添加数据库初始化定时任务。
Args:
task_service (TaskService): 任务服务实例,用于管理和调度定时任务
Returns:
None: 此函数不返回任何值
Note:
仅在开发环境(env == "development")下执行数据库初始化任务
"""
if config["default"]["env"] == "development":
await task_service.add_date_job(
init_db,
run_date=None,
args=[],
job_id="task_name",
)
@@ -0,0 +1,92 @@
from ..core.logger import get_logger
from ..core.constant import PermissionConstant
from ..service.user import UserService
from ..service.permission import PermissionService
from plugins.scheduler.scheduler import get_task_service_instance
logger = get_logger("scheduler")
async def dev_init_admin(session):
"""
初始化系统管理员用户
检查是否存在邮箱为"admin"的管理员用户,如果不存在则创建一个新的超级管理员账户。
该函数用于系统首次部署时的管理员账户初始化。
Args:
session: 数据库会话对象,用于执行数据库操作
Returns:
None
Note:
- 默认管理员邮箱和密码均为"admin"
- 如果管理员已存在,则跳过创建并记录日志
- 新创建的用户具有超级管理员权限(is_superuser=True)
"""
admin_user = await UserService.get_user_by_email("admin", session)
if admin_user: # type: ignore
logger.info("管理员用户已存在") # type: ignore
return
await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore
async def init_permission(session):
"""
初始化系统权限数据
遍历所有预定义的权限常量,检查数据库中是否已存在对应权限记录。
对于不存在的权限,创建新的权限记录并关联对应的资源和操作类型。
Args:
session: 数据库会话对象,用于执行数据库操作
Returns:
None
Note:
- 权限格式为"resource:action",例如"user:create"
- 使用冒号分隔资源和操作,如果缺少操作部分则默认为空字符串
- 已存在的权限会被跳过,避免重复创建
- 每个新创建的权限都会记录到日志中
"""
for permission in PermissionConstant.get_all():
parts = permission.split(":", 1)
resource = parts[0]
action = parts[1] if len(parts) > 1 else ""
existing_permission = await PermissionService.get_permission_by_name(
session, permission
)
if existing_permission:
logger.debug(f"权限已存在: {permission}")
continue
await PermissionService.create_permission(session, permission, resource, action)
logger.info(f"创建权限: {permission}")
async def init_db():
"""
执行数据库初始化流程
协调执行所有数据库初始化任务,包括管理员账户创建和权限数据初始化。
该函数是系统启动时数据库初始化的入口点。
Returns:
None
Note:
- 通过任务服务获取异步会话工厂
- 如果会话工厂未初始化,则直接返回不执行任何操作
- 按顺序执行:先初始化管理员,再初始化权限
- 使用异步上下文管理器确保会话正确关闭
"""
task_service = get_task_service_instance()
if task_service.async_session_maker is None:
return
async with task_service.async_session_maker() as session:
await dev_init_admin(session)
await init_permission(session)
@@ -1,54 +1,93 @@
from module_bank import PythonToSQLite
from module_bank.encryption import Encryption
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import asyncio
from pathlib import Path
from module_bank import PythonToSQLite
from module_bank.encryption import Encryption
from sqlite_vfs.core import SQLiteVFS
from sqlite_vfs.folder_packer import FolderPacker
is_windows = platform.system() == "Windows"
def create_admin():
try:
cmd = [
sys.executable,
"../src/{{ cookiecutter.project_name }}/scripts/create_admin.py",
]
print(f"正在运行 create_admin.py: {' '.join(cmd)}")
def build_vue_web():
"""
打包 Vue Web 前端项目
# 创建包含 UTF-8 编码的环境变量
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
该函数执行以下操作:
1. 运行 npm build 命令构建 Vue 项目
2. 将构建产物打包到 SQLite VFS 虚拟文件系统中
result = subprocess.run(
cmd,
cwd="./build",
capture_output=True,
text=True,
encoding="utf-8", # 明确指定编码
errors="replace", # 替换无法解码的字符
env=env, # 传递修改后的环境变量
)
if result.returncode == 0:
print("创建管理员用户成功")
Returns:
None: 无返回值,通过打印信息反馈执行结果
"""
# 构建 Vue Web 命令
dist_dir = "./src/{{ cookiecutter.project_name }}-web/dist"
output_db = "./build/static.svfs"
fs_name = "frontend"
cmd = [
"npm",
"run",
"build",
]
result = subprocess.run(
cmd,
cwd="./src/{{ cookiecutter.project_name }}-web",
capture_output=True,
text=True,
shell=is_windows,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
print("打包 Vue Web 失败")
print("标准输出:", result.stdout)
print("错误输出:", result.stderr)
return
else:
print("Vue Web 打包成功")
if result.stdout:
print(result.stdout)
else:
print("创建管理员用户失败")
# 安全地打印错误信息,避免编码问题
try:
print("错误信息:", result.stderr)
except UnicodeEncodeError:
print(
"错误信息:", result.stderr.encode("ascii", "ignore").decode("ascii")
)
except Exception as e:
print("创建管理员用户失败")
print(f"异常: {e}")
# 检查 dist 目录是否存在
if not os.path.exists(dist_dir):
print(f"错误: dist 目录不存在: {dist_dir}")
return
vfs = SQLiteVFS(output_db, compress=False)
packer = FolderPacker(
sqlite_vfs=vfs,
exclude_patterns=[],
fs_name=fs_name,
)
try:
packer.pack_folder(dist_dir)
print(f"✅ 打包完成,文件保存为 {output_db}")
finally:
vfs.close()
def run_pyarmor_obfuscate():
"""
使用 PyArmor 对关键 Python 文件进行代码混淆
该函数遍历预定义的文件列表,对每个文件执行 pyarmor gen 命令进行混淆处理,
以保护源代码不被轻易反编译。
处理的文件包括:
- main.py (主入口文件)
- server.py (服务器文件)
- utils/launch.py (启动工具)
- utils/config.py (配置工具)
Returns:
None: 无返回值,遇到错误时提前返回
"""
# pyarmor obfuscate --output=obfuscated_script.py original_script.py
obfuscate_files = [
("main.py", "./"),
@@ -74,92 +113,207 @@ def run_pyarmor_obfuscate():
def run_pyinstaller():
"""运行 PyInstaller 打包成可执行文件"""
"""
使用 PyInstaller 将应用打包成单文件可执行程序
该函数根据操作系统平台(Windows/非Windows)构建不同的 PyInstaller 命令,
包含所有必要的隐藏导入以确保依赖库被正确打包。
Windows 和非 Windows 平台的主要区别:
- 非 Windows 平台额外包含 gunicorn 相关模块
Returns:
None: 无返回值,通过打印信息反馈执行结果
"""
try:
# 构建 PyInstaller 命令
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--onefile",
"--name",
"{{ cookiecutter.project_name }}",
"--distpath",
".",
"--workpath",
"./build_temp",
"--specpath",
"./specs",
"--clean",
# 隐藏导入
"--hidden-import",
"utils",
"--hidden-import",
"utils.launch",
"--hidden-import",
"utils.config",
"--hidden-import",
"server",
"--hidden-import",
"module_bank",
"--hidden-import",
"fastapi",
"--hidden-import",
"fastapi.middleware.cors",
"--hidden-import",
"uvicorn",
"--hidden-import",
"sqlmodel",
"--hidden-import",
"aiosqlite",
"--hidden-import",
"pyjwt",
"--hidden-import",
"sqlitedict",
"--hidden-import",
"passlib",
"--hidden-import",
"passlib.context",
"--hidden-import",
"passlib.handlers.bcrypt",
"--hidden-import",
"jwt",
# 其他选项
"--icon",
"../../public/{{ cookiecutter.project_name }}.ico",
"main.py",
]
if is_windows:
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--onefile",
"--noconfirm",
"--optimize",
"2",
"--strip",
"--name",
"{{ cookiecutter.project_name }}",
"--distpath",
".",
"--workpath",
"./build_temp",
"--specpath",
"./specs",
"--clean",
# 隐藏导入
"--hidden-import",
"utils",
"--hidden-import",
"utils.launch",
"--hidden-import",
"utils.config",
"--hidden-import",
"server",
"--hidden-import",
"module_bank",
"--hidden-import",
"fastapi",
"--hidden-import",
"fastapi.staticfiles",
"--hidden-import",
"fastapi.middleware.cors",
"--hidden-import",
"uvicorn",
"--hidden-import",
"sqlmodel",
"--hidden-import",
"aiosqlite",
"--hidden-import",
"aiofiles",
"--hidden-import",
"apscheduler",
"--hidden-import",
"diskcache",
"--hidden-import",
"bcrypt",
"--hidden-import",
"sqlite_vfs",
"--hidden-import",
"python_multipart",
"--hidden-import",
"sqlite_vfs.core",
"--hidden-import",
"jwt",
# 其他选项
"--icon",
"../../public/{{ cookiecutter.project_name }}.ico",
"main.py",
]
else:
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--onefile",
"--noconfirm",
"--optimize",
"2",
"--strip",
"--name",
"{{ cookiecutter.project_name }}",
"--distpath",
".",
"--workpath",
"./build_temp",
"--specpath",
"./specs",
"--clean",
# 隐藏导入
"--hidden-import",
"utils",
"--hidden-import",
"utils.launch",
"--hidden-import",
"utils.config",
"--hidden-import",
"server",
"--hidden-import",
"module_bank",
"--hidden-import",
"fastapi",
"--hidden-import",
"fastapi.staticfiles",
"--hidden-import",
"fastapi.middleware.cors",
"--hidden-import",
"uvicorn",
"--hidden-import",
"sqlmodel",
"--hidden-import",
"aiosqlite",
"--hidden-import",
"aiofiles",
"--hidden-import",
"apscheduler",
"--hidden-import",
"diskcache",
"--hidden-import",
"bcrypt",
"--hidden-import",
"sqlite_vfs",
"--hidden-import",
"python_multipart",
"--hidden-import",
"sqlite_vfs.core",
"--hidden-import",
"jwt",
# gunicorn
"--hidden-import",
"gunicorn.glogging",
"--hidden-import",
"gunicorn.app",
"--hidden-import",
"gunicorn.app.wsgiapp",
# 其他选项
"--icon",
"../../public/{{ cookiecutter.project_name }}.ico",
"main.py",
]
print(f"正在运行 PyInstaller: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd="./build", capture_output=True, text=True)
if result.returncode == 0:
print("PyInstaller 打包成功!")
print("PyInstaller 打包成功!")
print(result.stdout)
else:
print("PyInstaller 打包失败!")
print("PyInstaller 打包失败!")
print("错误信息:", result.stderr)
except FileNotFoundError:
print("错误:未找到 PyInstaller,请先安装 PyInstaller:pip install pyinstaller")
print("错误:未找到 PyInstaller,请先安装 PyInstaller:pip install pyinstaller")
except Exception as e:
print(f"PyInstaller 执行出错: {e}")
async def init_database():
"""
异步初始化数据库
该函数执行以下操作:
1. 从配置中读取数据库连接信息
2. 创建异步数据库引擎和会话工厂
3. 设置 SQLite 优化参数(WAL 模式和同步策略)
4. 创建所有 SQLModel 定义的数据库表
5. 初始化权限系统和默认管理员账户
Raises:
ValueError: 当数据库配置不存在时抛出
OperationalError: 当数据库操作出现异常时抛出(已存在表的情况除外)
Returns:
None: 无返回值
"""
from sqlalchemy.ext.asyncio import create_async_engine
from ..app.core.config import get_config
from sqlmodel import SQLModel, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..app.core.config import get_config
from ..app.task.dev_init_db import init_permission, dev_init_admin
database = get_config()["database"]
database_type = database["type"]
database_config = database.get(database_type, None)
if database_config is None:
raise ValueError("配置不存在")
os.makedirs("./build/data", exist_ok=True)
engine = create_async_engine(
"sqlite+aiosqlite:///./build/database.db", **database_config
"sqlite+aiosqlite:///./build/data/database.db", **database_config
)
async_session_maker = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with engine.begin() as conn:
@@ -170,12 +324,35 @@ async def init_database():
await conn.run_sync(
lambda conn: SQLModel.metadata.create_all(bind=conn, checkfirst=True)
)
except OperationalError as e:
if "already exists" in str(e):
raise
# 添加管理员用户
async with async_session_maker() as session:
# await UserService.create_user(session, "admin", "admin", is_superuser=True) # type: ignore
await init_permission(session)
await dev_init_admin(session)
async def main():
"""
主构建流程函数
该函数协调整个应用的构建过程,按顺序执行以下步骤:
1. 加密打包 Python 模块(app 和 plugins)到 SQLite 数据库文件
2. 创建并准备 build 目录结构
3. 复制必要的源文件和配置文件到 build 目录
4. 注入动态生成的加密密钥到配置文件
5. 对关键文件进行代码混淆
6. 使用 PyInstaller 打包成可执行文件
7. 清理临时构建文件
8. 初始化数据库
9. 构建 Vue 前端项目
Returns:
None: 无返回值
"""
# 打包模块
key = Encryption.generate_key()
resources_path = Path("resources")
@@ -228,6 +405,13 @@ async def main():
with open("./build/utils/launch.py", "w", encoding="utf-8") as f:
f.write(context)
jwt_key = Encryption.generate_key()
with open("./build/config.toml", "r", encoding="utf-8") as f:
context = f.read()
context = context.replace("your-secret-key-change-in-production", jwt_key)
with open("build/config.toml", "w", encoding="utf-8") as f:
f.write(context)
# 混淆
run_pyarmor_obfuscate()
@@ -241,13 +425,23 @@ async def main():
shutil.rmtree(r"build/pyarmor_runtime_000000", ignore_errors=True)
os.remove(r"build/main.py")
os.remove(r"build/server.py")
# os.remove(r"build/config.toml")
print("构建完成")
# 初始化数据库
await init_database()
# 构建 vue 项目
build_vue_web()
def build():
"""
构建入口函数
该函数作为整个构建流程的入口点,通过 asyncio.run() 启动异步主构建流程。
Returns:
None: 无返回值
"""
asyncio.run(main())
@@ -1,5 +1,6 @@
import os
import shutil
# 需要遍历的目录
root_dir = "./"
# 遍历目录
@@ -8,20 +8,28 @@ def generate_markdown_from_py_files(directory, output_file):
# 排除 venv 目录
dirs[:] = [d for d in dirs if d != ".venv"]
dirs[:] = [d for d in dirs if d != ".vscode"]
# dirs[:] = [d for d in dirs if d != "scripts"]
dirs[:] = [d for d in dirs if d != "node_modules"]
dirs[:] = [d for d in dirs if d != "build"]
dirs[:] = [d for d in dirs if d != "dist"]
for file in files:
if (
file.endswith(".py")
or file.endswith(".rs")
or file.endswith(".toml")
or file.endswith(".md")
or file.endswith(".css")
or file.endswith(".vue")
or file.endswith(".ts")
or file.endswith(".json")
or file.endswith(".html")
or file.endswith(".sh")
or file == "Dockerfile"
):
file_path = os.path.join(root, file)
if file == "output.md":
if file == "output.md" or file == "package-lock.json":
continue
md_file.write(f"`{file_path}`\n")
md_file.write("```python\n")
md_file.write("```\n")
with open(file_path, "r", encoding="utf-8") as py_file:
md_file.write(py_file.read())
md_file.write("\n```\n\n")