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 @@ + + + + + + \ 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")