From 32d15e8c92c9d5bc1dc59370afe8fa5ca65fb245 Mon Sep 17 00:00:00 2001 From: konjacpotato Date: Thu, 20 Nov 2025 21:41:13 +0800 Subject: [PATCH] import project --- .dockerignore | 6 ++ .env | 16 ++++++ .env.prod | 15 +++++ .env.test | 14 +++++ .gitea/workflows/deploy-workflow.yml | 30 ++++++++++ .gitignore | 1 + Dockerfile | 27 +++++++++ README.md | 0 api/deps.py | 9 +++ api/v1/routers.py | 6 ++ config/app.py | 18 ++++++ config/database.py | 18 ++++++ config/env_loader.py | 22 ++++++++ config/settings.py | 33 +++++++++++ docker-compose.yml | 7 +++ main.py | 84 ++++++++++++++++++++++++++++ models/base.py | 4 ++ requirements.txt | 11 ++++ scheduler/jobs.py | 6 ++ scheduler/scheduler.py | 37 ++++++++++++ utils/logger.py | 37 ++++++++++++ 21 files changed, 401 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.prod create mode 100644 .env.test create mode 100644 .gitea/workflows/deploy-workflow.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api/deps.py create mode 100644 api/v1/routers.py create mode 100644 config/app.py create mode 100644 config/database.py create mode 100644 config/env_loader.py create mode 100644 config/settings.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 models/base.py create mode 100644 requirements.txt create mode 100644 scheduler/jobs.py create mode 100644 scheduler/scheduler.py create mode 100644 utils/logger.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..28db922 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitea +.gitignore +Readme.md +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..d1efd13 --- /dev/null +++ b/.env @@ -0,0 +1,16 @@ +ENV=dev + +DEBUG=true +TIMEZONE=UTC +APP_NAME=MemeApp + +# 日志配置 +LOG_LEVEL=DEBUG +LOG_TYPE=console + +# 数据库配置 +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASS=123456 +DB_NAME=mydb \ No newline at end of file diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..44cb35b --- /dev/null +++ b/.env.prod @@ -0,0 +1,15 @@ +ENV=prod + +DEBUG=false + +# 日志配置 +LOG_LEVEL=INFO +LOG_TYPE=file +LOG_FILE_PATH=logs + +# 数据库配置 +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASS=123456 +DB_NAME=mydb \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..3003006 --- /dev/null +++ b/.env.test @@ -0,0 +1,14 @@ +ENV=test + +DEBUG=true + +# 日志配置 +LOG_LEVEL=DEBUG +LOG_TYPE=console + +# 数据库配置 +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASS=123456 +DB_NAME=mydb \ No newline at end of file diff --git a/.gitea/workflows/deploy-workflow.yml b/.gitea/workflows/deploy-workflow.yml new file mode 100644 index 0000000..d4730b4 --- /dev/null +++ b/.gitea/workflows/deploy-workflow.yml @@ -0,0 +1,30 @@ +name: Gitea Actions Demo +run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 +on: [push] + +jobs: + deploy: + runs-on: ubuntu-latest + container: + image: gitea/runner-images:ubuntu-latest + steps: + - name: clone project code + run: git clone ${{ gitea.server_url }}/${{ gitea.repository }} . + - name: List files + run: ls -la + - name: Stop running containers + run: | + docker compose down || true + - name: Remove old image + run: | + IMAGE_NAME=$(basename "$PWD") + echo "Removing old image: $IMAGE_NAME" + docker images | grep "$IMAGE_NAME" && docker rmi -f $(docker images "$IMAGE_NAME" -q) || echo "No old image found." + - name: Build new image + run: | + docker build -t $(basename "$PWD"):latest . + - name: Start containers + run: | + docker compose up -d + - name: Show container status + run: docker ps \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d64e48e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# 使用官方轻量级 Python 基础镜像 +FROM python:3.12-slim + +# 时区(可选) +ENV TZ=Asia/Shanghai + +# 设置工作目录(容器内路径) +WORKDIR /app + +# 将项目文件复制到容器中 +COPY . /app + +# (可选)如果你有 requirements.txt,则先复制并安装依赖 +RUN if [ -f requirements.txt ]; then \ + pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/; \ + fi + +# 设置环境变量(防止 Python 缓存文件) +ENV PYTHONUNBUFFERED=1 + +# 暴露端口 +EXPOSE 8000 + +#---------------------------- +# 启动 FastAPI(生产模式) +#---------------------------- +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/api/deps.py b/api/deps.py new file mode 100644 index 0000000..49e72fa --- /dev/null +++ b/api/deps.py @@ -0,0 +1,9 @@ +from config.database import SessionLocal +from fastapi import Depends + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/api/v1/routers.py b/api/v1/routers.py new file mode 100644 index 0000000..249423e --- /dev/null +++ b/api/v1/routers.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +# from .users import router as user_router + +api_router = APIRouter() + +# api_router.include_router(user_router, prefix="/users", tags=["users"]) \ No newline at end of file diff --git a/config/app.py b/config/app.py new file mode 100644 index 0000000..84fdbad --- /dev/null +++ b/config/app.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI +from config.settings import settings +from api.v1.routers import api_router + +def create_app(lifespan=None) -> FastAPI: + app = FastAPI( + title=settings.APP_NAME, + debug=settings.DEBUG, + lifespan=lifespan, + ) + + # 注册路由 + app.include_router(api_router, prefix="/api/v1") + + return app + + +# app = create_app() \ No newline at end of file diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..4a79780 --- /dev/null +++ b/config/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session +from config.settings import settings + +SQLALCHEMY_SYNC_URL = ( + f"postgresql+psycopg://{settings.DB_USER}:{settings.DB_PASS}" + f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}" +) + +engine = create_engine( + SQLALCHEMY_SYNC_URL, + echo=False, # 开发可改 True + future=True +) + +SessionLocal = scoped_session( + sessionmaker(bind=engine, autoflush=False, autocommit=False) +) \ No newline at end of file diff --git a/config/env_loader.py b/config/env_loader.py new file mode 100644 index 0000000..01521dd --- /dev/null +++ b/config/env_loader.py @@ -0,0 +1,22 @@ +import os +from dotenv import load_dotenv + +def load_env(): + """ + 自动根据 ENV 加载对应的 .env 文件 + """ + base_file = ".env" + prod_file = ".env.prod" + test_file = ".env.test" + + # 先加载基础 .env + if os.path.exists(base_file): + load_dotenv(base_file) + + # 根据参数 ENV 再加载其他环境 + env = os.getenv("ENV", "dev") + + if env == "prod" and os.path.exists(prod_file): + load_dotenv(prod_file, override=True) + elif env == "test" and os.path.exists(test_file): + load_dotenv(test_file, override=True) \ No newline at end of file diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..3a8b6d9 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,33 @@ +from pydantic_settings import BaseSettings +from pydantic import Field +from config.env_loader import load_env + +# 先加载 ENV & .env +load_env() + +class Settings(BaseSettings): + # 环境 + ENV: str = Field("dev") + DEBUG: bool = Field(True) + TIMEZONE: str = Field("UTC") + APP_NAME: str = Field("MemeApp") + + # 日志 + LOG_LEVEL: str = Field("LOG_LEVEL") + LOG_FILE_PATH: str = Field("logs") + LOG_TYPE: str = Field("console") + + # 数据库 + DB_HOST: str + DB_PORT: int + DB_USER: str + DB_PASS: str + DB_NAME: str + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +# 全局唯一配置实例 +settings = Settings() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c42f62b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3.8" + +services: + app: + image: meme:latest + container_name: meme + restart: always diff --git a/main.py b/main.py new file mode 100644 index 0000000..2a9a0b7 --- /dev/null +++ b/main.py @@ -0,0 +1,84 @@ +from fastapi import FastAPI +from fastapi.concurrency import asynccontextmanager +from config.settings import settings +from utils.logger import logger +from scheduler.scheduler import scheduler +import scheduler.jobs as jobs +from config.app import create_app + +""" +启动方式: + python -m uvicorn main:app --reload + +说明: + - 使用 FastAPI lifespan 管理 APScheduler 生命周期(替代已废弃的 on_event) + - 避免 Uvicorn reload 模式下调度器重复启动 + - 所有 Job 在应用启动时统一注册 +""" + + +def _add_jobs(): + """ + 注册所有定时任务。 + + 注意: + - 增加重复检查,避免 reload 或多进程导致重复添加。 + - replace_existing=True 保证任务更新时无需手动删除。 + """ + if not scheduler.get_job("heartbeat-job"): + scheduler.add_job( + jobs.job_heartbeat, + trigger="interval", + seconds=10, + id="heartbeat-job", + replace_existing=True, + ) + logger.info("Job 'heartbeat-job' registered.") + else: + logger.info("Job 'heartbeat-job' already exists. Skipped.") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + FastAPI 应用生命周期管理。 + + startup: + - 注册定时任务 + - 启动 APScheduler(避免 reload 环境下重复启动) + + shutdown: + - 安全关闭 APScheduler + + yield: + - 让 FastAPI 在此期间正常运行 + """ + logger.info("==== Lifespan: startup ====") + + # 防止 Uvicorn reload 启动两个进程导致重复启动 scheduler + if scheduler.running: + logger.warning("Scheduler already running. Skip start.") + else: + _add_jobs() + + try: + scheduler.start() + logger.info("APScheduler started.") + except Exception: + logger.exception("Failed to start APScheduler:") + raise + + yield # ---- 应用运行中 ---- + + logger.info("==== Lifespan: shutdown ====") + + # 仅在 scheduler 正常运行时关闭 + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("APScheduler stopped.") + else: + logger.info("Scheduler was not running. Skip shutdown.") + + +# 创建 FastAPI 应用(注入 lifespan 管理逻辑) +app = create_app(lifespan=lifespan) \ No newline at end of file diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..2278416 --- /dev/null +++ b/models/base.py @@ -0,0 +1,4 @@ +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd230d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +python-dotenv +pydantic-settings +loguru +sqlalchemy +psycopg +psycopg_binary +alembic +apscheduler +fastapi +uvicorn +gunicorn \ No newline at end of file diff --git a/scheduler/jobs.py b/scheduler/jobs.py new file mode 100644 index 0000000..96af507 --- /dev/null +++ b/scheduler/jobs.py @@ -0,0 +1,6 @@ +from utils.logger import logger +import datetime + + +def job_heartbeat(): + logger.info(f"[heartbeat] {datetime.datetime.now()}") \ No newline at end of file diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py new file mode 100644 index 0000000..3e7f674 --- /dev/null +++ b/scheduler/scheduler.py @@ -0,0 +1,37 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.executors.pool import ThreadPoolExecutor, ProcessPoolExecutor +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from loguru import logger +from config.settings import settings + +SCHEDULER_DB_URL = ( + f"postgresql+psycopg://{settings.DB_USER}:{settings.DB_PASS}" + f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}" +) + +def create_scheduler(): + job_stores = { + "default": SQLAlchemyJobStore(url=SCHEDULER_DB_URL) + } + + executors = { + "default": ThreadPoolExecutor(10), + "processpool": ProcessPoolExecutor(2) + } + + job_defaults = { + "coalesce": False, # 重叠任务处理 + "max_instances": 3 + } + + scheduler = BackgroundScheduler( + # jobstores=job_stores, + executors=executors, + job_defaults=job_defaults, + timezone=settings.TIMEZONE, + ) + + return scheduler + + +scheduler = create_scheduler() \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..138663d --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,37 @@ +import sys +import os +from loguru import logger +from config.settings import settings + +# 移除默认的 handler(否则重复输出) +logger.remove() + +if "console" in settings.LOG_TYPE: + # ======== 控制台输出 ======== + logger.add( + sys.stdout, + level=settings.LOG_LEVEL, + format="{time:YYYY-MM-DD HH:mm:ss} " + "| {level: <8} " + "| {name}:{function}:{line} " + "- {message}", + ) + +if "file" in settings.LOG_TYPE: + # 日志目录 + LOG_DIR = settings.LOG_FILE_PATH + if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + + # ======== 文件输出(按天切割)======== + logger.add( + f"{LOG_DIR}/app_{{time:YYYY-MM-DD}}.log", + rotation="00:00", # 每天 0 点切割 + retention="7 days", # 保存 7 天 + encoding="utf-8", + level=settings.LOG_LEVEL, + enqueue=True, # 多线程安全 + compression="zip", # 自动压缩旧日志 + ) + +__all__ = ["logger"] \ No newline at end of file