初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -0,0 +1,109 @@
from urllib.parse import quote_plus
from sqlalchemy import Engine, create_engine
from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncEngine, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from config.env import DataBaseConfig
def build_async_sqlalchemy_database_url() -> str:
"""
构建异步 SQLAlchemy 数据库连接 URL
:return: 异步 SQLAlchemy 数据库连接 URL
"""
if DataBaseConfig.db_type == 'postgresql':
return (
f'postgresql+asyncpg://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
)
return (
f'mysql+asyncmy://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
)
ASYNC_SQLALCHEMY_DATABASE_URL = build_async_sqlalchemy_database_url()
def build_sync_sqlalchemy_database_url() -> str:
"""
构建同步 SQLAlchemy 数据库连接 URL
:return: 同步 SQLAlchemy 数据库连接 URL
"""
if DataBaseConfig.db_type == 'postgresql':
return (
f'postgresql+psycopg2://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
)
return (
f'mysql+pymysql://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
)
SYNC_SQLALCHEMY_DATABASE_URL = build_sync_sqlalchemy_database_url()
def create_async_db_engine(echo: bool | None = None) -> AsyncEngine:
"""
创建异步 SQLAlchemy Engine
:param echo: 可选,是否输出 SQLAlchemy SQL 日志
:return: 异步 SQLAlchemy Engine
"""
return create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
echo=DataBaseConfig.db_echo if echo is None else echo,
max_overflow=DataBaseConfig.db_max_overflow,
pool_size=DataBaseConfig.db_pool_size,
pool_recycle=DataBaseConfig.db_pool_recycle,
pool_timeout=DataBaseConfig.db_pool_timeout,
)
def create_sync_db_engine(echo: bool | None = None) -> Engine:
"""
创建同步 SQLAlchemy Engine
:param echo: 可选,是否输出 SQLAlchemy SQL 日志
:return: 同步 SQLAlchemy Engine
"""
return create_engine(
SYNC_SQLALCHEMY_DATABASE_URL,
echo=DataBaseConfig.db_echo if echo is None else echo,
max_overflow=DataBaseConfig.db_max_overflow,
pool_size=DataBaseConfig.db_pool_size,
pool_recycle=DataBaseConfig.db_pool_recycle,
pool_timeout=DataBaseConfig.db_pool_timeout,
)
def create_async_session_local(engine: AsyncEngine) -> async_sessionmaker:
"""
创建异步 Session 工厂
:param engine: 异步 SQLAlchemy Engine
:return: 异步 Session 工厂
"""
return async_sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_sync_session_local(engine: Engine) -> sessionmaker:
"""
创建同步 Session 工厂
:param engine: 同步 SQLAlchemy Engine
:return: 同步 Session 工厂
"""
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
async_engine = create_async_db_engine()
AsyncSessionLocal = create_async_session_local(async_engine)
class Base(AsyncAttrs, DeclarativeBase):
pass
+334
View File
@@ -0,0 +1,334 @@
import argparse
import configparser
import os
import sys
from typing import Literal
from dotenv import load_dotenv
from pydantic import computed_field
from pydantic_settings import BaseSettings
class AppSettings(BaseSettings):
"""
应用配置
"""
app_env: str = 'dev'
app_name: str = 'ShiErFeng-FastAPI'
app_root_path: str = '/dev-api'
app_host: str = '0.0.0.0'
app_port: int = 9099
app_version: str = '1.0.0'
app_reload: bool = True
app_workers: int = 1
app_ip_location_query: bool = True
app_same_time_login: bool = True
app_demo_mode: bool = False
app_disable_swagger: bool = False
app_disable_redoc: bool = False
app_trusted_proxy_ips: str = '127.0.0.1,::1'
app_trusted_proxy_hops: int = 1
class JwtSettings(BaseSettings):
"""
Jwt配置
"""
jwt_secret_key: str = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55'
jwt_algorithm: str = 'HS256'
jwt_expire_minutes: int = 1440
jwt_redis_expire_minutes: int = 30
class DataBaseSettings(BaseSettings):
"""
数据库配置
"""
db_type: Literal['mysql', 'postgresql'] = 'mysql'
db_host: str = '127.0.0.1'
db_port: int = 3306
db_username: str = 'root'
db_password: str = 'mysqlroot'
db_database: str = 'shierfeng-fastapi'
db_echo: bool = True
db_max_overflow: int = 10
db_pool_size: int = 50
db_pool_recycle: int = 3600
db_pool_timeout: int = 30
@computed_field
@property
def sqlglot_parse_dialect(self) -> str:
if self.db_type == 'postgresql':
return 'postgres'
return self.db_type
class RedisSettings(BaseSettings):
"""
Redis配置
"""
redis_host: str = '127.0.0.1'
redis_port: int = 6379
redis_username: str = ''
redis_password: str = ''
redis_database: int = 2
class LogSettings(BaseSettings):
"""
日志与队列配置
"""
log_mask_enabled: bool = True
log_mask_placeholder: str = '******'
log_mask_fields: str = (
'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,'
'authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,'
'sms_code,captcha_code,system_prompt'
)
log_partial_mask_fields: str = 'phonenumber,phone,mobile,email'
log_config_secret_patterns: str = 'password,token,secret,key,private,credential,access,jwt,captcha,sms'
log_stream_key: str = 'log:stream'
log_stream_group: str = 'log_aggregator'
log_stream_consumer_prefix: str = 'worker'
log_stream_batch_size: int = 100
log_stream_block_ms: int = 2000
log_stream_maxlen: int = 100000
log_stream_claim_idle_ms: int = 60000
log_stream_claim_interval_ms: int = 5000
log_stream_claim_batch_size: int = 100
log_stream_dedup_ttl: int = 3600
log_stream_dedup_prefix: str = 'log:dedup'
loguru_json: bool = False
loguru_level: str = 'INFO'
loguru_stdout: bool = True
log_file_enabled: bool = True
log_file_base_dir: str = 'logs'
loguru_rotation: str = '50MB'
loguru_retention: str = '30 days'
loguru_compression: str = 'zip'
log_instance_id: str = 'prod'
log_service_name: str = 'shierfeng-fastapi-backend'
log_worker_id: str = 'auto'
class TransportCryptoSettings(BaseSettings):
"""
传输层加解密配置
"""
transport_crypto_enabled: bool = True
transport_crypto_mode: Literal['off', 'optional', 'required'] = 'optional'
transport_crypto_algorithm: str = 'RSA_OAEP_AES_256_GCM'
transport_crypto_kid: str = 'default'
transport_crypto_public_key: str = ''
transport_crypto_private_key: str = ''
transport_crypto_legacy_key_pairs: str = '[]'
transport_crypto_rsa_key_size: int = 2048
transport_crypto_public_key_ttl_seconds: int = 3600
transport_crypto_frontend_config_ttl_seconds: int = 300
transport_crypto_max_get_url_length: int = 4096
transport_crypto_clock_skew_seconds: int = 120
transport_crypto_replay_ttl_seconds: int = 300
transport_crypto_enabled_paths: str = ''
transport_crypto_required_paths: str = ''
transport_crypto_exclude_paths: str = (
'/openapi.json,/docs,/docs/oauth2-redirect,/redoc,'
'/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
)
class GenSettings:
"""
代码生成配置
"""
author = 'insistence'
package_name = 'module_admin.system'
auto_remove_pre = False
table_prefix = 'sys_'
allow_overwrite = False
GEN_PATH = 'vf_admin/gen_path'
def __init__(self) -> None:
if not os.path.exists(self.GEN_PATH):
os.makedirs(self.GEN_PATH)
class UploadSettings:
"""
上传配置
"""
UPLOAD_PREFIX = '/profile'
UPLOAD_PATH = 'vf_admin/upload_path'
UPLOAD_MACHINE = 'A'
DEFAULT_ALLOWED_EXTENSION = [
# 图片
'bmp',
'gif',
'jpg',
'jpeg',
'png',
# word excel powerpoint
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'html',
'htm',
'txt',
# 压缩文件
'rar',
'zip',
'gz',
'bz2',
# 视频格式
'mp4',
'avi',
'rmvb',
# pdf
'pdf',
]
DOWNLOAD_PATH = 'vf_admin/download_path'
def __init__(self) -> None:
if not os.path.exists(self.UPLOAD_PATH):
os.makedirs(self.UPLOAD_PATH)
if not os.path.exists(self.DOWNLOAD_PATH):
os.makedirs(self.DOWNLOAD_PATH)
class CachePathConfig:
"""
缓存目录配置
"""
PATH = os.path.join(os.path.abspath(os.getcwd()), 'caches')
PATHSTR = 'caches'
class GetConfig:
"""
获取配置
"""
def __init__(self) -> None:
self.parse_cli_args()
def get_app_config(self) -> AppSettings:
"""
获取应用配置
"""
# 实例化应用配置模型
return AppSettings()
def get_jwt_config(self) -> JwtSettings:
"""
获取Jwt配置
"""
# 实例化Jwt配置模型
return JwtSettings()
def get_database_config(self) -> DataBaseSettings:
"""
获取数据库配置
"""
# 实例化数据库配置模型
return DataBaseSettings()
def get_redis_config(self) -> RedisSettings:
"""
获取Redis配置
"""
# 实例化Redis配置模型
return RedisSettings()
def get_log_config(self) -> LogSettings:
"""
获取日志配置
"""
return LogSettings()
def get_transport_crypto_config(self) -> TransportCryptoSettings:
"""
获取传输层加解密配置
"""
return TransportCryptoSettings()
def get_gen_config(self) -> GenSettings:
"""
获取代码生成配置
"""
# 实例化代码生成配置
return GenSettings()
def get_upload_config(self) -> UploadSettings:
"""
获取上传配置
"""
# 实例上传配置
return UploadSettings()
@staticmethod
def parse_cli_args() -> None:
"""
解析命令行参数
"""
# 检查是否在alembic环境中运行,如果是则跳过参数解析
if 'alembic' in sys.argv[0] or any('alembic' in arg for arg in sys.argv):
ini_config = configparser.ConfigParser()
ini_config.read('alembic.ini', encoding='utf-8')
if 'settings' in ini_config:
# 获取env选项
env_value = ini_config['settings'].get('env')
os.environ['APP_ENV'] = env_value if env_value else 'dev'
elif 'uvicorn' in sys.argv[0]:
# 使用uvicorn启动时,命令行参数需要按照uvicorn的文档进行配置,无法自定义参数
pass
else:
# 使用argparse定义命令行参数
parser = argparse.ArgumentParser(description='命令行参数')
parser.add_argument('--env', type=str, default='', help='运行环境')
# 解析命令行参数
args, _ = parser.parse_known_args()
# 设置环境变量,如果未设置命令行参数,默认APP_ENV为dev
os.environ['APP_ENV'] = args.env if args.env else 'dev'
# 读取运行环境
run_env = os.environ.get('APP_ENV', '')
# 运行环境未指定时默认加载.env.dev
env_file = '.env.dev'
# 运行环境不为空时按命令行参数加载对应.env文件
if run_env != '':
env_file = f'.env.{run_env}'
# 加载配置
load_dotenv(env_file)
# 实例化获取配置类
get_config = GetConfig()
# 应用配置
AppConfig = get_config.get_app_config()
# Jwt配置
JwtConfig = get_config.get_jwt_config()
# 数据库配置
DataBaseConfig = get_config.get_database_config()
# Redis配置
RedisConfig = get_config.get_redis_config()
# 日志配置
LogConfig = get_config.get_log_config()
# 传输层加解密配置
TransportCryptoConfig = get_config.get_transport_crypto_config()
# 代码生成配置
GenConfig = get_config.get_gen_config()
# 上传配置
UploadConfig = get_config.get_upload_config()
@@ -0,0 +1,37 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from config.database import AsyncSessionLocal, Base, async_engine
from utils.log_util import logger
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
每一个请求处理完毕后会关闭当前连接,不同的请求使用不同的连接
:return:
"""
async with AsyncSessionLocal() as current_db:
yield current_db
async def init_create_table() -> None:
"""
应用启动时初始化数据库连接
:return:
"""
logger.info('🔎 初始化数据库连接...')
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info('✅️ 数据库连接成功')
async def close_async_engine() -> None:
"""
应用关闭时释放数据库连接池
:return:
"""
await async_engine.dispose()
@@ -0,0 +1,107 @@
from fastapi import FastAPI
from redis import asyncio as aioredis
from redis.exceptions import AuthenticationError, RedisError
from redis.exceptions import TimeoutError as RedisTimeoutError
from config.database import AsyncSessionLocal
from config.env import RedisConfig
from module_admin.service.config_service import ConfigService
from module_admin.service.dict_service import DictDataService
from utils.log_util import logger
class RedisUtil:
"""
Redis相关方法
"""
@classmethod
async def create_redis_pool(cls, log_enabled: bool = True, log_start_enabled: bool | None = None) -> aioredis.Redis:
"""
应用启动时初始化redis连接
:param log_enabled: 是否输出日志
:param log_start_enabled: 是否输出开始连接日志
:return: Redis连接对象
"""
redis = await aioredis.from_url(
url=f'redis://{RedisConfig.redis_host}',
port=RedisConfig.redis_port,
username=RedisConfig.redis_username,
password=RedisConfig.redis_password,
db=RedisConfig.redis_database,
encoding='utf-8',
decode_responses=True,
)
if log_start_enabled is None:
log_start_enabled = log_enabled
if log_enabled or log_start_enabled:
await cls.check_redis_connection(redis, log_enabled=log_enabled, log_start_enabled=log_start_enabled)
return redis
@classmethod
async def check_redis_connection(
cls, redis: aioredis.Redis, log_enabled: bool = True, log_start_enabled: bool | None = None
) -> None:
"""
检查redis连接状态
:param redis: redis对象
:param log_enabled: 是否输出日志
:param log_start_enabled: 是否输出开始连接日志
:return: None
"""
if log_start_enabled is None:
log_start_enabled = log_enabled
if log_start_enabled:
logger.info('🔎 开始连接redis...')
try:
connection = await redis.ping()
if not log_enabled:
return
if connection:
logger.info('✅️ redis连接成功')
else:
logger.error('❌️ redis连接失败')
except AuthenticationError as e:
if log_enabled:
logger.error(f'❌️ redis用户名或密码错误,详细错误信息:{e}')
except RedisTimeoutError as e:
if log_enabled:
logger.error(f'❌️ redis连接超时,详细错误信息:{e}')
except RedisError as e:
if log_enabled:
logger.error(f'❌️ redis连接错误,详细错误信息:{e}')
@classmethod
async def close_redis_pool(cls, app: FastAPI) -> None:
"""
应用关闭时关闭redis连接
:param app: fastapi对象
:return:
"""
await app.state.redis.close()
logger.info('✅️ 关闭redis连接成功')
@classmethod
async def init_sys_dict(cls, redis: FastAPI) -> None:
"""
应用启动时缓存字典表
:param redis: redis对象
:return:
"""
async with AsyncSessionLocal() as session:
await DictDataService.init_cache_sys_dict_services(session, redis)
@classmethod
async def init_sys_config(cls, redis: aioredis.Redis) -> None:
"""
应用启动时缓存参数配置表
:param redis: redis对象
:return:
"""
async with AsyncSessionLocal() as session:
await ConfigService.init_cache_sys_config_services(session, redis)
@@ -0,0 +1,940 @@
import asyncio
import importlib
import json
from asyncio import iscoroutinefunction
from collections.abc import Callable
from datetime import datetime, timedelta
from typing import Any
from apscheduler.events import EVENT_ALL, SchedulerEvent
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.executors.pool import ProcessPoolExecutor
from apscheduler.job import Job
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.jobstores.redis import RedisJobStore
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.combining import OrTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from redis import asyncio as aioredis
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncEngine
import module_task # noqa: F401
from common.constant import LockConstant
from config.database import (
SYNC_SQLALCHEMY_DATABASE_URL,
create_async_db_engine,
create_async_session_local,
create_sync_db_engine,
create_sync_session_local,
)
from config.env import AppConfig, LogConfig, RedisConfig
from module_admin.dao.job_dao import JobDao
from module_admin.entity.vo.job_vo import JobLogModel, JobModel
from module_admin.service.job_log_service import JobLogService
from utils.log_util import logger
from utils.server_util import StartupUtil, WorkerIdUtil
# 重写Cron定时
class MyCronTrigger(CronTrigger):
CRON_EXPRESSION_LENGTH_MIN = 6
CRON_EXPRESSION_LENGTH_MAX = 7
WEEKDAY_COUNT = 5
@classmethod
def from_crontab(cls, expr: str, timezone: str | None = None) -> 'MyCronTrigger':
values = expr.split()
if len(values) != cls.CRON_EXPRESSION_LENGTH_MIN and len(values) != cls.CRON_EXPRESSION_LENGTH_MAX:
raise ValueError(f'Wrong number of fields; got {len(values)}, expected 6 or 7')
second = values[0]
minute = values[1]
hour = values[2]
if '?' in values[3]:
day = None
elif 'L' in values[5]:
day = f'last {values[5].replace("L", "")}'
elif 'W' in values[3]:
day = cls.__find_recent_workday(int(values[3].split('W')[0]))
else:
day = values[3].replace('L', 'last')
month = values[4]
if '?' in values[5] or 'L' in values[5]:
week = None
elif '#' in values[5]:
week = int(values[5].split('#')[1])
else:
week = values[5]
day_of_week = int(values[5].split('#')[0]) - 1 if '#' in values[5] else None
year = values[6] if len(values) == cls.CRON_EXPRESSION_LENGTH_MAX else None
return cls(
second=second,
minute=minute,
hour=hour,
day=day,
month=month,
week=week,
day_of_week=day_of_week,
year=year,
timezone=timezone,
)
@classmethod
def __find_recent_workday(cls, day: int) -> int:
now = datetime.now()
date = datetime(now.year, now.month, day)
if date.weekday() < cls.WEEKDAY_COUNT:
return date.day
diff = 1
while True:
previous_day = date - timedelta(days=diff)
if previous_day.weekday() < cls.WEEKDAY_COUNT:
return previous_day.day
diff += 1
redis_config = {
'host': RedisConfig.redis_host,
'port': RedisConfig.redis_port,
'username': RedisConfig.redis_username,
'password': RedisConfig.redis_password,
'db': RedisConfig.redis_database,
}
job_defaults = {'coalesce': False, 'max_instance': 1}
scheduler = AsyncIOScheduler()
class SchedulerUtil:
"""
定时任务相关方法
"""
# 分布式锁相关类变量
_is_leader: bool = False
_worker_id: str = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id)
_redis: aioredis.Redis | None = None
_job_update_time_cache: dict[str, datetime] = {}
_sync_channel: str = 'scheduler:sync:request'
_sync_listener_task: asyncio.Task | None = None
_lock_lost_task: asyncio.Task | None = None
_sync_task: asyncio.Task | None = None
_sync_pending: bool = False
_sync_lock: asyncio.Lock = asyncio.Lock()
_last_sync_at: datetime | None = None
_sync_debounce_seconds: float = 0.5
_sync_min_interval_seconds: float = 2.0
_reacquire_task: asyncio.Task | None = None
_reacquire_interval_seconds: float = 5.0
_sync_async_engine: AsyncEngine | None = None
_sync_async_sessionmaker: Any | None = None
_disposed_sync_engines: bool = False
# 懒加载的同步 Engine 和 SessionLocal
_jobstore_engine: Engine | None = None
_listener_engine: Engine | None = None
_session_local: Any | None = None
_scheduler_configured: bool = False
@classmethod
def _get_jobstore_engine(cls) -> Engine:
"""
懒加载获取 jobstore 使用的同步 Engine
:return: 同步 Engine
"""
if cls._jobstore_engine is None:
cls._jobstore_engine = create_sync_db_engine(echo=False)
return cls._jobstore_engine
@classmethod
def _get_listener_engine(cls) -> Engine:
"""
懒加载获取 listener 使用的同步 Engine
:return: 同步 Engine
"""
if cls._listener_engine is None:
cls._listener_engine = create_sync_db_engine()
return cls._listener_engine
@classmethod
def _get_session_local(cls) -> Any:
"""
懒加载获取同步 SessionLocal
:return: SessionLocal
"""
if cls._session_local is None:
cls._session_local = create_sync_session_local(cls._get_listener_engine())
return cls._session_local
@classmethod
def _configure_scheduler(cls) -> None:
"""
配置 scheduler(懒加载 jobstore)
:return: None
"""
if cls._scheduler_configured:
return
job_stores = {
'default': MemoryJobStore(),
'sqlalchemy': SQLAlchemyJobStore(url=SYNC_SQLALCHEMY_DATABASE_URL, engine=cls._get_jobstore_engine()),
'redis': RedisJobStore(**redis_config),
}
executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)}
scheduler.configure(jobstores=job_stores, executors=executors, job_defaults=job_defaults)
cls._scheduler_configured = True
@classmethod
def _should_enable_scheduler_sync(cls) -> bool:
"""
判断是否需要启用多 worker 的任务状态同步机制
:return: 是否开启定时同步与监听
"""
return not AppConfig.app_reload and AppConfig.app_workers > 1
@classmethod
async def init_system_scheduler(cls, redis: aioredis.Redis) -> None:
"""
应用启动时初始化定时任务(使用分布式锁确保只有一个worker启动scheduler)
:param redis: Redis连接对象
:return:
"""
cls._redis = redis
logger.info(f'🔎 Worker {cls._worker_id} 尝试获取 Application 锁...')
acquired = await StartupUtil.acquire_startup_log_gate(
redis=redis,
lock_key=LockConstant.APP_STARTUP_LOCK_KEY,
worker_id=cls._worker_id,
lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS,
)
if acquired:
await cls._start_scheduler_as_leader(redis)
else:
cls._is_leader = False
logger.info(f'⏸️ Worker {cls._worker_id} 未持有 Application 锁,跳过 Scheduler 启动')
@classmethod
async def _start_scheduler_as_leader(cls, redis: aioredis.Redis) -> None:
"""
以 Leader 身份启动 Scheduler(内部方法,调用前需确保已持有锁)
:param redis: Redis连接对象
:return: None
"""
cls._is_leader = True
cls._disposed_sync_engines = False
logger.info(f'🎯 Worker {cls._worker_id} 持有 Application 锁,开始启动定时任务...')
# 懒加载配置 scheduler
cls._configure_scheduler()
scheduler.start()
# 加载数据库中的定时任务
async with cls._get_sync_async_session() as session:
job_list = await JobDao.get_job_list_for_scheduler(session)
for item in job_list:
cls._add_job_to_scheduler(item)
# 添加事件监听器
scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL)
if cls._should_enable_scheduler_sync():
# 添加任务状态同步任务(每30秒从数据库同步一次任务状态)
scheduler.add_job(
func=cls.request_scheduler_sync,
trigger='interval',
seconds=30,
id='_scheduler_job_sync',
name='Scheduler任务同步',
replace_existing=True,
)
cls._sync_listener_task = asyncio.create_task(cls._listen_sync_channel(redis))
logger.info('✅️ 系统初始定时任务加载成功')
@classmethod
def on_lock_lost(cls) -> None:
"""
锁丢失处理入口
:return: None
"""
if not cls._is_leader:
return
cls._is_leader = False
logger.warning(f'⚠️ Worker {cls._worker_id} 失去 Application 锁')
if cls._lock_lost_task:
cls._lock_lost_task.cancel()
cls._lock_lost_task = asyncio.create_task(cls._handle_lock_lost())
@classmethod
async def _handle_lock_lost(cls) -> None:
"""
处理锁丢失后的资源释放
:return: None
"""
if cls._sync_listener_task:
cls._sync_listener_task.cancel()
try:
await cls._sync_listener_task
except asyncio.CancelledError:
pass
cls._sync_listener_task = None
if cls._sync_task:
cls._sync_task.cancel()
try:
await cls._sync_task
except asyncio.CancelledError:
pass
cls._sync_task = None
cls._sync_pending = False
if getattr(scheduler, 'running', False):
scheduler.shutdown()
await cls._dispose_sync_async_engine()
cls._dispose_sync_engines()
cls._ensure_reacquire_task()
@classmethod
async def _sync_jobs_from_database(cls) -> None:
"""
从数据库同步任务状态,确保多worker环境下任务状态一致
"""
if not cls._is_leader:
return
try:
async with cls._get_sync_async_session() as session:
db_jobs_all = await JobDao.get_all_job_list_for_scheduler(session)
db_jobs_enabled = [job for job in db_jobs_all if job.status == '0']
db_enabled_ids = {str(job.job_id) for job in db_jobs_enabled}
db_job_map = {str(job.job_id): job for job in db_jobs_enabled}
db_job_update_time_map = {
str(job.job_id): job.update_time for job in db_jobs_enabled if job.update_time is not None
}
scheduler_jobs = scheduler.get_jobs()
scheduler_job_map = {job.id: job for job in scheduler_jobs if not job.id.startswith('_')}
scheduler_job_ids = set(scheduler_job_map.keys())
jobs_to_remove = scheduler_job_ids - db_enabled_ids
for job_id in jobs_to_remove:
scheduler.remove_job(job_id=job_id)
logger.info(f'🗑️ 同步移除任务: {job_id}')
cls._refresh_job_update_cache(job_id, None)
jobs_to_add = db_enabled_ids - scheduler_job_ids
for job_id in jobs_to_add:
job_info = db_job_map.get(job_id)
if job_info:
cls._add_job_to_scheduler(job_info)
logger.info(f'➕ 同步添加任务: {job_info.job_name}')
cls._refresh_job_update_cache(job_id, job_info.update_time)
jobs_to_update = db_enabled_ids & scheduler_job_ids
for job_id in jobs_to_update:
job_info = db_job_map.get(job_id)
scheduler_job = scheduler_job_map.get(job_id)
job_update_time = db_job_update_time_map.get(job_id)
cls._sync_update_job(job_id, job_info, scheduler_job, job_update_time)
except Exception as e:
logger.error(f'❌ 任务同步异常: {e}')
@classmethod
def _is_job_config_in_sync(cls, scheduler_job: Job, job_info: JobModel) -> bool:
"""
判断任务配置是否一致
:param scheduler_job: 调度器任务对象
:param job_info: 数据库任务对象
:return: 是否一致
"""
job_state = scheduler_job.__getstate__()
job_kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else None
job_args = job_info.job_args.split(',') if job_info.job_args else None
job_executor = job_info.job_executor
if iscoroutinefunction(cls._import_function(job_info.invoke_target)):
job_executor = 'default'
expected = {
'name': job_info.job_name,
'executor': job_executor,
'jobstore': job_info.job_group,
'misfire_grace_time': 1000000000000 if job_info.misfire_policy == '3' else None,
'coalesce': job_info.misfire_policy == '2',
'max_instances': 3 if job_info.concurrent == '0' else 1,
'trigger': str(MyCronTrigger.from_crontab(job_info.cron_expression)),
'args': tuple(job_args) if job_args else None,
'kwargs': job_kwargs if job_kwargs else None,
'func': str(cls._import_function(job_info.invoke_target)),
}
current = {
'name': job_state.get('name'),
'executor': job_state.get('executor'),
'jobstore': scheduler_job._jobstore_alias,
'misfire_grace_time': job_state.get('misfire_grace_time'),
'coalesce': job_state.get('coalesce'),
'max_instances': job_state.get('max_instances'),
'trigger': str(job_state.get('trigger')),
'args': job_state.get('args'),
'kwargs': job_state.get('kwargs'),
'func': str(job_state.get('func')),
}
return expected == current
@classmethod
def _sync_update_job(
cls, job_id: str, job_info: JobModel | None, scheduler_job: Job | None, job_update_time: datetime | None
) -> None:
"""
同步更新任务配置
:param job_id: 任务ID
:param job_info: 数据库任务对象
:param scheduler_job: 调度器任务对象
:param job_update_time: 任务更新时间
:return: None
"""
if not job_info or not scheduler_job:
return
if cls._should_skip_job_update(job_id, job_update_time):
return
if not cls._is_job_config_in_sync(scheduler_job, job_info):
scheduler.remove_job(job_id=job_id)
cls._add_job_to_scheduler(job_info)
logger.info(f'♻️ 同步更新任务: {job_info.job_name}')
cls._refresh_job_update_cache(job_id, job_update_time)
@classmethod
def _should_skip_job_update(cls, job_id: str, job_update_time: datetime | None) -> bool:
"""
判断是否跳过同步更新
:param job_id: 任务ID
:param job_update_time: 任务更新时间
:return: 是否跳过
"""
if job_update_time is None:
return False
return cls._job_update_time_cache.get(job_id) == job_update_time
@classmethod
def _refresh_job_update_cache(cls, job_id: str, job_update_time: datetime | None) -> None:
"""
刷新任务更新时间缓存
:param job_id: 任务ID
:param job_update_time: 任务更新时间
:return: None
"""
if job_update_time is not None:
cls._job_update_time_cache[job_id] = job_update_time
else:
cls._job_update_time_cache.pop(job_id, None)
@classmethod
async def request_scheduler_sync(cls) -> None:
"""
请求调度器同步任务状态
:return: None
"""
if cls._is_leader:
cls._sync_pending = True
cls._ensure_sync_task()
return
if cls._redis:
await cls._redis.publish(cls._sync_channel, cls._worker_id)
@classmethod
def _ensure_sync_task(cls) -> None:
"""
启动同步调度任务
:return: None
"""
if cls._sync_task and not cls._sync_task.done():
return
cls._sync_task = asyncio.create_task(cls._run_sync_loop())
@classmethod
def _get_sync_async_session(cls) -> Any:
"""
获取同步任务使用的异步 Session
:return: 异步 Session
"""
if not cls._sync_async_sessionmaker:
cls._sync_async_engine = create_async_db_engine(echo=False)
cls._sync_async_sessionmaker = create_async_session_local(cls._sync_async_engine)
return cls._sync_async_sessionmaker()
@classmethod
async def _dispose_sync_async_engine(cls) -> None:
"""
释放同步任务使用的异步 Engine
:return: None
"""
if cls._sync_async_engine:
await cls._sync_async_engine.dispose()
cls._sync_async_engine = None
cls._sync_async_sessionmaker = None
@classmethod
def _dispose_sync_engines(cls) -> None:
"""
释放 Scheduler 使用的同步 Engine
:return: None
"""
if cls._disposed_sync_engines:
return
if cls._jobstore_engine:
cls._jobstore_engine.dispose()
cls._jobstore_engine = None
if cls._listener_engine:
cls._listener_engine.dispose()
cls._listener_engine = None
cls._session_local = None
cls._disposed_sync_engines = True
@classmethod
def _ensure_reacquire_task(cls) -> None:
"""
启动锁重新竞争任务
:return: None
"""
if not cls._redis:
return
if cls._reacquire_task and not cls._reacquire_task.done():
return
cls._reacquire_task = asyncio.create_task(cls._run_reacquire_loop())
@classmethod
async def _run_reacquire_loop(cls) -> None:
"""
循环尝试重新获取锁并恢复调度器
:return: None
"""
try:
while not cls._is_leader:
if not cls._redis:
await asyncio.sleep(cls._reacquire_interval_seconds)
continue
acquired = await StartupUtil.acquire_startup_log_gate(
redis=cls._redis,
lock_key=LockConstant.APP_STARTUP_LOCK_KEY,
worker_id=cls._worker_id,
lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS,
)
if acquired:
# 直接调用 _start_scheduler_as_leader,避免重复获取锁
await cls._start_scheduler_as_leader(cls._redis)
return
await asyncio.sleep(cls._reacquire_interval_seconds)
except asyncio.CancelledError:
raise
finally:
cls._reacquire_task = None
@classmethod
async def _run_sync_loop(cls) -> None:
"""
执行同步调度循环
:return: None
"""
try:
while True:
if not cls._sync_pending:
break
cls._sync_pending = False
await asyncio.sleep(cls._sync_debounce_seconds)
await cls._sync_with_throttle()
except asyncio.CancelledError:
raise
finally:
cls._sync_task = None
@classmethod
async def _sync_with_throttle(cls) -> None:
"""
按节流规则执行同步
:return: None
"""
async with cls._sync_lock:
if not cls._is_leader:
return
if cls._last_sync_at:
elapsed = datetime.now() - cls._last_sync_at
min_interval = timedelta(seconds=cls._sync_min_interval_seconds)
if elapsed < min_interval:
await asyncio.sleep((min_interval - elapsed).total_seconds())
await cls._sync_jobs_from_database()
cls._last_sync_at = datetime.now()
@classmethod
async def _listen_sync_channel(cls, redis: aioredis.Redis) -> None:
"""
监听同步请求通道
:param redis: Redis连接对象
:return: None
"""
while True:
pubsub = redis.pubsub()
try:
await pubsub.subscribe(cls._sync_channel)
async for message in pubsub.listen():
if not cls._is_leader:
continue
if message.get('type') != 'message':
continue
await cls.request_scheduler_sync()
except asyncio.CancelledError:
await pubsub.unsubscribe(cls._sync_channel)
await pubsub.close()
raise
except Exception as e:
logger.error(f'❌ Scheduler 同步监听异常: {e},5秒后重试...')
await pubsub.close()
await asyncio.sleep(5)
finally:
try:
await pubsub.close()
except Exception:
pass
@classmethod
async def _execute_async_job_with_log(
cls, job_func: Callable[..., Any], job_info: JobModel, args: list, kwargs: dict
) -> None:
"""
执行异步任务并记录日志
:param job_func: 任务函数
:param job_info: 任务对象信息
:param args: 位置参数
:param kwargs: 关键字参数
:return: None
"""
status = '0'
exception_info = ''
job_executor = job_info.job_executor
if iscoroutinefunction(job_func):
job_executor = 'default'
try:
await job_func(*args, **kwargs)
except Exception as e:
status = '1'
exception_info = str(e)
logger.error(f'❌ 异步执行任务 {job_info.job_name} 失败: {e}')
finally:
cls._record_job_execution_log(job_info, job_executor, status, exception_info)
@classmethod
def _record_job_execution_log(cls, job_info: JobModel, job_executor: str, status: str, exception_info: str) -> None:
"""
记录任务执行日志(用于非 Leader Worker 直接执行任务时)
:param job_info: 任务对象信息
:param job_executor: 任务执行器
:param status: 执行状态 0-成功 1-失败
:param exception_info: 异常信息
:return: None
"""
try:
job_args = job_info.job_args if job_info.job_args else ''
job_kwargs = job_info.job_kwargs if job_info.job_kwargs else '{}'
job_trigger = str(MyCronTrigger.from_crontab(job_info.cron_expression)) if job_info.cron_expression else ''
job_message = (
f'事件类型: DirectExecution(非Leader), 任务ID: {job_info.job_id}, '
f'任务名称: {job_info.job_name}, 执行于{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}'
)
job_log = JobLogModel(
jobName=job_info.job_name,
jobGroup=job_info.job_group,
jobExecutor=job_executor,
invokeTarget=job_info.invoke_target,
jobArgs=job_args,
jobKwargs=job_kwargs,
jobTrigger=job_trigger,
jobMessage=job_message,
status=status,
exceptionInfo=exception_info,
createTime=datetime.now(),
)
session = cls._get_session_local()()
try:
JobLogService.add_job_log_services(session, job_log)
finally:
session.close()
except Exception as e:
logger.error(f'❌ 记录任务执行日志失败: {e}')
@classmethod
def _prepare_scheduler_job_add(cls, job_info: JobModel) -> dict[str, Any]:
"""
构建调度器任务参数
:param job_info: 任务对象信息
:return: 调度器任务参数
"""
job_func = cls._import_function(job_info.invoke_target)
job_executor = job_info.job_executor
if iscoroutinefunction(job_func):
job_executor = 'default'
return {
'func': job_func,
'trigger': MyCronTrigger.from_crontab(job_info.cron_expression),
'args': job_info.job_args.split(',') if job_info.job_args else None,
'kwargs': json.loads(job_info.job_kwargs) if job_info.job_kwargs else None,
'id': str(job_info.job_id),
'name': job_info.job_name,
'misfire_grace_time': 1000000000000 if job_info.misfire_policy == '3' else None,
'coalesce': job_info.misfire_policy == '2',
'max_instances': 3 if job_info.concurrent == '0' else 1,
'jobstore': job_info.job_group,
'executor': job_executor,
}
@classmethod
def _add_job_to_scheduler(cls, job_info: JobModel) -> None:
"""
内部方法:将任务添加到调度器(不检查应用锁状态,仅供内部使用)
:param job_info: 任务对象信息
"""
try:
# 先移除已存在的同ID任务
existing_job = scheduler.get_job(job_id=str(job_info.job_id))
if existing_job:
scheduler.remove_job(job_id=str(job_info.job_id))
scheduler.add_job(**cls._prepare_scheduler_job_add(job_info))
except Exception as e:
logger.error(f'❌ 添加任务 {job_info.job_name} 失败: {e}')
@classmethod
async def close_system_scheduler(cls) -> None:
"""
应用关闭时关闭定时任务
:return:
"""
if cls._sync_listener_task:
cls._sync_listener_task.cancel()
try:
await cls._sync_listener_task
except asyncio.CancelledError:
pass
cls._sync_listener_task = None
if cls._sync_task:
cls._sync_task.cancel()
try:
await cls._sync_task
except asyncio.CancelledError:
pass
cls._sync_task = None
cls._sync_pending = False
if cls._reacquire_task:
cls._reacquire_task.cancel()
try:
await cls._reacquire_task
except asyncio.CancelledError:
pass
cls._reacquire_task = None
await cls._dispose_sync_async_engine()
cls._dispose_sync_engines()
if cls._lock_lost_task:
cls._lock_lost_task.cancel()
try:
await cls._lock_lost_task
except asyncio.CancelledError:
pass
cls._lock_lost_task = None
if getattr(scheduler, 'running', False):
scheduler.shutdown()
logger.info('✅️ 关闭定时任务成功')
# 释放锁
if cls._redis:
current_holder = await cls._redis.get(LockConstant.APP_STARTUP_LOCK_KEY)
if current_holder == cls._worker_id:
await cls._redis.delete(LockConstant.APP_STARTUP_LOCK_KEY)
logger.info(f'🔓 Worker {cls._worker_id} 释放 Application 锁')
@classmethod
def _import_function(cls, func_path: str) -> Callable[..., Any]:
"""
动态导入函数
:param func_path: 函数字符串,如module_task.scheduler_test.job
:return: 导入的函数对象
"""
module_path, func_name = func_path.rsplit('.', 1)
module = importlib.import_module(module_path)
return getattr(module, func_name)
@classmethod
def get_scheduler_job(cls, job_id: str | int) -> Job:
"""
根据任务id获取任务对象
:param job_id: 任务id
:return: 任务对象
"""
query_job = scheduler.get_job(job_id=str(job_id))
return query_job
@classmethod
def add_scheduler_job(cls, job_info: JobModel) -> None:
"""
根据输入的任务对象信息添加任务
:param job_info: 任务对象信息
:return:
"""
# 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会加载)
if not cls._is_leader:
return
scheduler.add_job(**cls._prepare_scheduler_job_add(job_info))
@classmethod
def execute_scheduler_job_once(cls, job_info: JobModel) -> None:
"""
根据输入的任务对象执行一次任务
:param job_info: 任务对象信息
:return:
"""
job_func = cls._import_function(job_info.invoke_target)
job_executor = job_info.job_executor
if iscoroutinefunction(job_func):
job_executor = 'default'
# 非应用锁 worker:直接执行函数(不通过 scheduler)
if not cls._is_leader:
logger.info(f'📍 当前 Worker 未持有 Application 锁,直接执行任务 {job_info.job_name}')
args = job_info.job_args.split(',') if job_info.job_args else []
kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else {}
status = '0'
exception_info = ''
try:
if iscoroutinefunction(job_func):
asyncio.create_task(cls._execute_async_job_with_log(job_func, job_info, args, kwargs)) # noqa: RUF006
else:
job_func(*args, **kwargs)
except Exception as e:
status = '1'
exception_info = str(e)
logger.error(f'❌ 直接执行任务 {job_info.job_name} 失败: {e}')
finally:
# 同步任务记录日志(异步任务在 _execute_async_job_with_log 中记录)
if not iscoroutinefunction(job_func):
cls._record_job_execution_log(job_info, job_executor, status, exception_info)
return
# 应用锁 worker:通过 scheduler 执行
job_trigger = DateTrigger()
if job_info.status == '0':
job_trigger = OrTrigger(triggers=[DateTrigger(), MyCronTrigger.from_crontab(job_info.cron_expression)])
scheduler.add_job(
func=job_func,
trigger=job_trigger,
args=job_info.job_args.split(',') if job_info.job_args else None,
kwargs=json.loads(job_info.job_kwargs) if job_info.job_kwargs else None,
id=str(job_info.job_id),
name=job_info.job_name,
misfire_grace_time=1000000000000 if job_info.misfire_policy == '3' else None,
coalesce=job_info.misfire_policy == '2',
max_instances=3 if job_info.concurrent == '0' else 1,
jobstore=job_info.job_group,
executor=job_executor,
)
@classmethod
def remove_scheduler_job(cls, job_id: str | int) -> None:
"""
根据任务id移除任务
:param job_id: 任务id
:return:
"""
# 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会根据状态加载)
if not cls._is_leader:
return
query_job = cls.get_scheduler_job(job_id=job_id)
if query_job:
scheduler.remove_job(job_id=str(job_id))
@classmethod
def scheduler_event_listener(cls, event: SchedulerEvent) -> None:
"""
调度器事件监听器,记录任务执行日志
"""
try:
# 获取事件类型和任务ID
event_type = event.__class__.__name__
# 获取任务执行异常信息
status = '0'
exception_info = ''
if event_type == 'JobExecutionEvent' and event.exception:
exception_info = str(event.exception)
status = '1'
if hasattr(event, 'job_id'):
job_id = event.job_id
# 跳过内部系统任务(以 _ 开头的任务ID),不记录日志
if str(job_id).startswith('_'):
return
query_job = cls.get_scheduler_job(job_id=job_id)
if query_job:
query_job_info = query_job.__getstate__()
# 获取任务名称
job_name = query_job_info.get('name')
# 获取任务组名
job_group = query_job._jobstore_alias
# 获取任务执行器
job_executor = query_job_info.get('executor')
# 获取调用目标字符串
invoke_target = query_job_info.get('func')
# 获取调用函数位置参数(安全处理)
args = query_job_info.get('args')
job_args = ','.join(str(arg) for arg in args) if args else ''
# 获取调用函数关键字参数
kwargs = query_job_info.get('kwargs')
job_kwargs = json.dumps(kwargs) if kwargs else '{}'
# 获取任务触发器
job_trigger = str(query_job_info.get('trigger'))
# 构造日志消息
job_message = f'事件类型: {event_type}, 任务ID: {job_id}, 任务名称: {job_name}, 执行于{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}'
job_log = JobLogModel(
jobName=job_name,
jobGroup=job_group,
jobExecutor=job_executor,
invokeTarget=invoke_target,
jobArgs=job_args,
jobKwargs=job_kwargs,
jobTrigger=job_trigger,
jobMessage=job_message,
status=status,
exceptionInfo=exception_info,
createTime=datetime.now(),
)
session = cls._get_session_local()()
try:
JobLogService.add_job_log_services(session, job_log)
finally:
session.close()
except Exception as e:
logger.error(f'❌ 调度任务事件监听器异常: {e}')