初始提交
This commit is contained in:
@@ -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
|
||||
@@ -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}')
|
||||
Reference in New Issue
Block a user