diff --git a/{{ cookiecutter.project_name }}/.gitignore b/{{ cookiecutter.project_name }}/.gitignore
index f19c072..8d21926 100644
--- a/{{ cookiecutter.project_name }}/.gitignore
+++ b/{{ cookiecutter.project_name }}/.gitignore
@@ -10,4 +10,12 @@ wheels/
.venv
logs/
resources/
-output.md
\ No newline at end of file
+output.md
+*.db
+*.svfs
+
+# resources
+uploads/
+
+# docker
+*.tar
diff --git a/{{ cookiecutter.project_name }}/Dockerfile b/{{ cookiecutter.project_name }}/Dockerfile
new file mode 100644
index 0000000..19d7b69
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/Dockerfile
@@ -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 }}"]
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/README.md b/{{ cookiecutter.project_name }}/README.md
index c67ae07..12e4165 100644
--- a/{{ cookiecutter.project_name }}/README.md
+++ b/{{ cookiecutter.project_name }}/README.md
@@ -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`
+
diff --git a/{{ cookiecutter.project_name }}/config.toml b/{{ cookiecutter.project_name }}/config.toml
index 97471bd..dfb8b9f 100644
--- a/{{ cookiecutter.project_name }}/config.toml
+++ b/{{ cookiecutter.project_name }}/config.toml
@@ -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"
diff --git a/{{ cookiecutter.project_name }}/deploy.sh b/{{ cookiecutter.project_name }}/deploy.sh
new file mode 100644
index 0000000..fb34dde
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/deploy.sh
@@ -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
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/public/config.toml b/{{ cookiecutter.project_name }}/public/config.toml
index c65335a..f28c07b 100644
--- a/{{ cookiecutter.project_name }}/public/config.toml
+++ b/{{ cookiecutter.project_name }}/public/config.toml
@@ -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"
diff --git a/{{ cookiecutter.project_name }}/public/main.py b/{{ cookiecutter.project_name }}/public/main.py
index 6250102..3603467 100644
--- a/{{ cookiecutter.project_name }}/public/main.py
+++ b/{{ cookiecutter.project_name }}/public/main.py
@@ -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")
diff --git a/{{ cookiecutter.project_name }}/public/server.py b/{{ cookiecutter.project_name }}/public/server.py
index 022f923..6e586ec 100644
--- a/{{ cookiecutter.project_name }}/public/server.py
+++ b/{{ cookiecutter.project_name }}/public/server.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/public/utils/config.py b/{{ cookiecutter.project_name }}/public/utils/config.py
index 0e3d837..bef1306 100644
--- a/{{ cookiecutter.project_name }}/public/utils/config.py
+++ b/{{ cookiecutter.project_name }}/public/utils/config.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/public/utils/launch.py b/{{ cookiecutter.project_name }}/public/utils/launch.py
index d18e7c9..27be687 100644
--- a/{{ cookiecutter.project_name }}/public/utils/launch.py
+++ b/{{ cookiecutter.project_name }}/public/utils/launch.py
@@ -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")
# 获取对应运行模式的配置
diff --git a/{{ cookiecutter.project_name }}/pyproject.toml b/{{ cookiecutter.project_name }}/pyproject.toml
index 66dfcb6..3c978c7 100644
--- a/{{ cookiecutter.project_name }}/pyproject.toml
+++ b/{{ cookiecutter.project_name }}/pyproject.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"
\ No newline at end of file
+serve = "{{ cookiecutter.project_name }}:serve"
+gunicorn = "{{ cookiecutter.project_name }}:gunicorn_serve"
diff --git a/{{ cookiecutter.project_name }}/pyrightconfig.json b/{{ cookiecutter.project_name }}/pyrightconfig.json
index a499e3b..2f5d117 100644
--- a/{{ cookiecutter.project_name }}/pyrightconfig.json
+++ b/{{ cookiecutter.project_name }}/pyrightconfig.json
@@ -4,6 +4,6 @@
"pythonVersion": "{{ cookiecutter.python_version }}",
"extraPaths": [
"./",
- "./.venv/lib/python3.12/site-packages"
+ "./.venv/lib/python{{ cookiecutter.python_version }}/site-packages"
]
}
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/requirements.txt b/{{ cookiecutter.project_name }}/requirements.txt
index 528d3f4..3516563 100644
--- a/{{ cookiecutter.project_name }}/requirements.txt
+++ b/{{ cookiecutter.project_name }}/requirements.txt
@@ -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
\ No newline at end of file
+python-multipart
+sqlite-vfs
+sqlmodel[aiosqlite]
+tomli
+typing-extensions
+uvicorn
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/plugins/jwt/__init__.py b/{{ cookiecutter.project_name }}/src/plugins/jwt/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/jwt_cache.py b/{{ cookiecutter.project_name }}/src/plugins/jwt/jwt_cache.py
similarity index 54%
rename from {{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/jwt_cache.py
rename to {{ cookiecutter.project_name }}/src/plugins/jwt/jwt_cache.py
index 756ff4e..318cd1e 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/jwt_cache.py
+++ b/{{ cookiecutter.project_name }}/src/plugins/jwt/jwt_cache.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/plugins/scheduler/__init__.py b/{{ cookiecutter.project_name }}/src/plugins/scheduler/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/{{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py b/{{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py
new file mode 100644
index 0000000..1cd3d3c
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/plugins/scheduler/scheduler.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore
new file mode 100644
index 0000000..cd68f14
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.gitignore
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json
new file mode 100644
index 0000000..29a2402
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/prettierrc",
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 100
+}
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/index.html b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/index.html
new file mode 100644
index 0000000..9e5fc8f
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/package.json b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/package.json
new file mode 100644
index 0000000..fa19196
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/public/favicon.ico b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/public/favicon.ico
new file mode 100644
index 0000000..79a6135
Binary files /dev/null and b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/public/favicon.ico differ
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/App.vue b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/App.vue
new file mode 100644
index 0000000..5e07a41
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/App.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/chongming64x64.png b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/chongming64x64.png
new file mode 100644
index 0000000..827ca41
Binary files /dev/null and b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/chongming64x64.png differ
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.png b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.png
new file mode 100644
index 0000000..12c681f
Binary files /dev/null and b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.png differ
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.svg b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.svg
new file mode 100644
index 0000000..267be7e
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/assets/logo.svg
@@ -0,0 +1,175 @@
+
+
+
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/main.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/main.ts
new file mode 100644
index 0000000..a628d07
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/main.ts
@@ -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')
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/router/index.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/router/index.ts
new file mode 100644
index 0000000..63a3c92
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/router/index.ts
@@ -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
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/auth.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/auth.ts
new file mode 100644
index 0000000..9ac5acf
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/auth.ts
@@ -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 || ''
+ })
+ }
+ }
+ }
+})
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/todo.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/todo.ts
new file mode 100644
index 0000000..96b71dc
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/stores/todo.ts
@@ -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)
+ },
+ },
+})
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/LoginView.vue b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/LoginView.vue
new file mode 100644
index 0000000..ce44549
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/LoginView.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+ 登录
+
+
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+ 还没有账号?立即注册
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/RegisterView.vue b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/RegisterView.vue
new file mode 100644
index 0000000..9851f3f
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/RegisterView.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+ 注册
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 注册
+
+
+
+ 已有账号?去登录
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/TodoView.vue b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/TodoView.vue
new file mode 100644
index 0000000..7434a99
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/src/views/TodoView.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+
+
+
+
+
+ 新建待办
+
+
+
+
+
+
+
+
+
+ 添加
+
+
+
+
+
+
+ 我的待办 ({{ todoStore.todos.length }})
+
+
+
+
+
+
+
+
toggleComplete(todo)" />
+
+ {{ todo.title }}
+ {{ todo.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 已完成
+
+
+ 保存
+
+
+ 取消
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.app.json b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.app.json
new file mode 100644
index 0000000..0804c9f
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.app.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.json b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.json
new file mode 100644
index 0000000..66b5e57
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.app.json"
+ }
+ ]
+}
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.node.json b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.node.json
new file mode 100644
index 0000000..93488bb
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/tsconfig.node.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"
+ }
+}
\ No newline at end of file
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/vite.config.ts b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/vite.config.ts
new file mode 100644
index 0000000..24e59ab
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}-web/vite.config.ts
@@ -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))
+ },
+ },
+})
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/__init__.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/__init__.py
index 75020ce..a1db873 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/__init__.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/__init__.py
@@ -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()
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/__init__.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/__init__.py
index f111a16..77fa455 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/__init__.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/__init__.py
@@ -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 "请查看服务器日志",
+ },
+ )
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/__init__.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/__init__.py
index 7dd6c03..1d0c335 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/__init__.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/__init__.py
@@ -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"])
# 导出路由
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/deps.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/deps.py
index 3fe9b06..d48178e 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/deps.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/deps.py
@@ -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,
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/auth.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/auth.py
index d30942c..65f91a3 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/auth.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/auth.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/todo.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/todo.py
index 1dd1ca7..5967a04 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/todo.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/todo.py
@@ -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")
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/upload.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/upload.py
new file mode 100644
index 0000000..8449010
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/api/routers/upload.py
@@ -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)
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/cache.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/cache.py
new file mode 100644
index 0000000..acfcc7b
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/cache.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/config.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/config.py
index ce1b459..b704d5d 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/config.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/config.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/constant.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/constant.py
new file mode 100644
index 0000000..0af4fb8
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/constant.py
@@ -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" # 导出客户
+
+ ...
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/logger.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/logger.py
index 22ad3cc..d61db26 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/logger.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/logger.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/security.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/security.py
index 36e7f4e..23ce9ca 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/security.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/security.py
@@ -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")
+ )
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/static_file.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/static_file.py
new file mode 100644
index 0000000..0880cbe
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/core/static_file.py
@@ -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)
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/__init__.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/__init__.py
index e69de29..f9a7555 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/__init__.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/__init__.py
@@ -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",
+]
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/http_error.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/http_error.py
new file mode 100644
index 0000000..28f7937
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/http_error.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/permission.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/permission.py
new file mode 100644
index 0000000..7e1f600
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/permission.py
@@ -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
+ )
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/relationship_table.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/relationship_table.py
new file mode 100644
index 0000000..ba1eb80
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/relationship_table.py
@@ -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)
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/role.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/role.py
new file mode 100644
index 0000000..c730f50
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/role.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/todo.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/todo.py
index e53af47..7fa9b3c 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/todo.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/todo.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/user.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/user.py
index 02cbf3f..4b15444 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/user.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/model/user.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/auth.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/auth.py
index 46b4f18..ff0e8ae 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/auth.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/auth.py
@@ -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()
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/permission.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/permission.py
new file mode 100644
index 0000000..e247961
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/permission.py
@@ -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)
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/role.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/role.py
new file mode 100644
index 0000000..0bb6aad
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/role.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/todo.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/todo.py
index 18a29a5..d88fc38 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/todo.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/todo.py
@@ -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
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/uploads.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/uploads.py
new file mode 100644
index 0000000..2b9727c
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/uploads.py
@@ -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,
+ )
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/user.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/user.py
index e140a78..b751680 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/user.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/service/user.py
@@ -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)
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/__init__.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/__init__.py
new file mode 100644
index 0000000..f975158
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/__init__.py
@@ -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",
+ )
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/dev_init_db.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/dev_init_db.py
new file mode 100644
index 0000000..4a86f0e
--- /dev/null
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/app/task/dev_init_db.py
@@ -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)
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/build.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/build.py
index bcbfdab..a952fb3 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/build.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/build.py
@@ -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())
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/clean_pycache.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/clean_pycache.py
index 070343f..d402b80 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/clean_pycache.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/clean_pycache.py
@@ -1,5 +1,6 @@
import os
import shutil
+
# 需要遍历的目录
root_dir = "./"
# 遍历目录
diff --git a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/code_content.py b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/code_content.py
index fea6ecb..164f79d 100644
--- a/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/code_content.py
+++ b/{{ cookiecutter.project_name }}/src/{{ cookiecutter.project_name }}/scripts/code_content.py
@@ -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")