#!/usr/bin/env python # -*- coding: utf-8 -*- """ Scheduler Tasks - 定时任务函数示例 定义可被调度器调用的任务函数 """ import logging from datetime import datetime logger = logging.getLogger(__name__) async def test_task(job_code: str = None, word: str = None, **kwargs): """ 测试任务 这是一个简单的测试任务,用于验证调度器是否正常工作。 Args: job_code: 任务编码(由调度器自动传入) **kwargs: 其他参数 """ logger.info(f"[{job_code}-{word}] 测试任务执行开始: {datetime.now()}") # 模拟任务执行 import asyncio await asyncio.sleep(10) logger.info(f"[{job_code}] 测试任务执行完成: {datetime.now()}") return f"测试任务执行成功: {datetime.now()}" async def cleanup_task(job_code: str = None, days: int = 30, **kwargs): """ 清理任务 清理过期的日志数据。 Args: job_code: 任务编码(由调度器自动传入) days: 保留最近N天的数据 **kwargs: 其他参数 """ logger.info(f"[{job_code}] 清理任务执行开始,保留最近 {days} 天数据") try: from datetime import timedelta from sqlalchemy import select, delete from app.database import AsyncSessionLocal from scheduler.model import SchedulerLog cutoff = datetime.now() - timedelta(days=days) async with AsyncSessionLocal() as db: # 删除过期日志 result = await db.execute( select(SchedulerLog).where(SchedulerLog.start_time < cutoff) ) logs = result.scalars().all() count = len(logs) for log in logs: await db.delete(log) await db.commit() logger.info(f"[{job_code}] 清理任务执行完成,删除了 {count} 条日志") return f"清理了 {count} 条过期日志" except Exception as e: logger.error(f"[{job_code}] 清理任务执行失败: {str(e)}") raise def sync_test_task(job_code: str = None, **kwargs): """ 同步测试任务 这是一个同步任务示例,用于演示同步任务的使用。 Args: job_code: 任务编码(由调度器自动传入) **kwargs: 其他参数 """ import time logger.info(f"[{job_code}] 同步测试任务执行开始: {datetime.now()}") # 模拟任务执行 time.sleep(1) logger.info(f"[{job_code}] 同步测试任务执行完成: {datetime.now()}") return f"同步测试任务执行成功: {datetime.now()}" async def restore_database_task( job_code: str = None, file_path: str = './db_init.json', app_name: str = None, clear_before_restore: bool = True, exclude_tables: list = None, **kwargs ): """ 数据库数据恢复任务 从 JSON 文件恢复数据库数据,类似 Django 的 loaddata 命令。 Args: job_code: 任务编码(由调度器自动传入) file_path: JSON 数据文件路径(必填) app_name: 应用名称过滤(可选),如 core、scheduler,只恢复指定应用的数据 clear_before_restore: 恢复前是否清空目标表(默认 True) exclude_tables: 排除的表名列表(可选),如 ['scheduler_log', 'core_operation_log'] **kwargs: 其他参数(包含 task_logger) Returns: str: 恢复结果摘要 Raises: ValueError: 文件路径未指定或文件不存在 Exception: 数据恢复过程中的错误 """ exclude_tables = exclude_tables or ['core_city', 'core_street', 'core_village', 'core_area','core_province', 'core_scheduler_job', 'core_scheduler_log'] import json from pathlib import Path from typing import Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete from app.database import AsyncSessionLocal, Base from scheduler.task_utils import TaskLoggerWrapper # 创建日志包装器:同时输出到控制台和实时日志 log = TaskLoggerWrapper(job_code, kwargs.get('task_logger')) await log.info(f"数据库恢复任务开始执行") # 验证文件路径 if not file_path: raise ValueError("file_path 参数不能为空") data_file = Path(file_path) if not data_file.exists(): raise ValueError(f"数据文件不存在: {file_path}") if not data_file.suffix.lower() == '.json': raise ValueError(f"只支持 JSON 格式的数据文件: {file_path}") await log.info(f"从文件加载数据: {file_path}") def parse_datetime(value): """解析日期时间字符串""" if isinstance(value, str): # 尝试解析 ISO 格式的日期时间字符串 try: # 支持多种格式 if 'T' in value or ' ' in value: # 包含时间部分 return datetime.fromisoformat(value.replace('Z', '+00:00')) elif len(value) == 10 and value.count('-') == 2: # 只有日期部分 YYYY-MM-DD from datetime import date return date.fromisoformat(value) except (ValueError, AttributeError): pass return value async def auto_import_models(): """自动加载所有模型类定义""" import importlib project_root = Path(__file__).parent.parent scan_dirs = ["zq_demo", "core", "scheduler", "online_dev", "ai_platform"] loaded_count = 0 for scan_dir in scan_dirs: scan_path = project_root / scan_dir if not scan_path.exists(): continue for model_file in scan_path.rglob("*model.py"): relative_path = model_file.relative_to(project_root) module_path = str(relative_path.with_suffix("")).replace("/", ".").replace("\\", ".") try: importlib.import_module(module_path) loaded_count += 1 except ImportError as e: logger.warning(f"[{job_code}] 加载模型定义失败 {module_path}: {e}") await log.info(f"已加载 {loaded_count} 个模型定义文件") try: # 自动加载所有模型类定义(不是导入数据) await log.info(f"开始加载模型定义...") await auto_import_models() # 读取 JSON 文件 with open(data_file, 'r', encoding='utf-8') as f: data = json.load(f) await log.info(f"读取到 {len(data)} 条记录") # 构建模型映射 model_map: Dict[str, Any] = {} for mapper in Base.registry.mappers: model_class = mapper.class_ model_key = f"{model_class.__module__}.{model_class.__name__}" model_map[model_key] = model_class # 如果指定了 app_name,过滤数据 if app_name: data = [item for item in data if item.get("model", "").startswith(app_name)] await log.info(f"过滤后剩余 {len(data)} 条记录(应用: {app_name})") # 排除指定的表 if exclude_tables: original_count = len(data) data = [ item for item in data if not (model_map.get(item.get("model")) and model_map[item.get("model")].__tablename__ in exclude_tables) ] excluded_count = original_count - len(data) if excluded_count > 0: await log.info(f"排除 {excluded_count} 条记录(表: {', '.join(exclude_tables)})") success_count = 0 error_count = 0 skipped_count = 0 from sqlalchemy import text # 阶段1:清空表(每个表独立事务,避免长时间持有锁) if clear_before_restore: tables_to_clear = set() for item in data: model_name = item.get("model") if model_name in model_map: tables_to_clear.add(model_name) # 按表名排序,便于追踪进度 sorted_tables = sorted(tables_to_clear, key=lambda x: model_map[x].__tablename__) total_tables = len(sorted_tables) await log.info(f"准备清空 {total_tables} 个表...") # 每个表独立事务清空 for idx, model_name in enumerate(sorted_tables, 1): model_class = model_map[model_name] table_name = model_class.__tablename__ await log.info(f"[{idx}/{total_tables}] 清空表: {table_name}") async with AsyncSessionLocal() as session: try: # 使用 TRUNCATE CASCADE 处理外键依赖 await session.execute(text(f'TRUNCATE TABLE "{table_name}" CASCADE')) await session.commit() except Exception as e: await session.rollback() # 如果 TRUNCATE 失败,尝试 DELETE await log.warning(f"TRUNCATE 失败,使用 DELETE: {e}") try: await session.execute(delete(model_class)) await session.commit() except Exception as e2: await log.error(f"DELETE 也失败: {e2}") await session.rollback() await log.info(f"已清空 {total_tables} 个表") # 阶段2:导入数据(分批提交,每批独立事务) total_records = len(data) await log.info(f"开始导入 {total_records} 条记录...") batch_size = 500 batch = [] for idx, item in enumerate(data, 1): model_name = item.get("model") fields = item.get("fields", {}) if model_name not in model_map: skipped_count += 1 continue model_class = model_map[model_name] # 转换日期时间字段(对所有字段值尝试转换) for key, value in fields.items(): fields[key] = parse_datetime(value) batch.append((model_class, fields)) # 每 batch_size 条提交一次 if len(batch) >= batch_size: async with AsyncSessionLocal() as session: try: for model_class, fields in batch: instance = model_class(**fields) session.add(instance) await session.commit() success_count += len(batch) except Exception as e: await log.error(f"批量导入失败: {e}") error_count += len(batch) await session.rollback() batch = [] progress = round(idx / total_records * 100, 1) await log.info(f"进度: {progress}% ({idx}/{total_records}) - 成功: {success_count}, 失败: {error_count}, 跳过: {skipped_count}") # 提交剩余的数据 if batch: async with AsyncSessionLocal() as session: try: for model_class, fields in batch: instance = model_class(**fields) session.add(instance) await session.commit() success_count += len(batch) except Exception as e: await log.error(f"最后批次导入失败: {e}") error_count += len(batch) await session.rollback() result = f"恢复完成: 成功 {success_count} 条, 失败 {error_count} 条, 跳过 {skipped_count} 条" await log.info(f"{result}") return result except Exception as e: await log.error(f"数据库恢复任务执行失败: {str(e)}") raise async def backup_database_task( job_code: str = None, output_path: str = None, app_name: str = None, exclude_tables: list = None, **kwargs ): """ 数据库数据备份任务 将数据库数据导出到 JSON 文件,类似 Django 的 dumpdata 命令。 Args: job_code: 任务编码(由调度器自动传入) output_path: 输出文件路径(可选),默认为 backups/backup_YYYYMMDD_HHMMSS.json app_name: 应用名称过滤(可选),如 core、scheduler,只备份指定应用的数据 exclude_tables: 排除的表名列表(可选),如 ['scheduler_log', 'core_operation_log'] **kwargs: 其他参数 Returns: str: 备份结果摘要,包含文件路径和记录数 """ exclude_tables = exclude_tables or [] import json from pathlib import Path from decimal import Decimal from datetime import date from sqlalchemy import inspect from app.database import AsyncSessionLocal, Base logger.info(f"[{job_code}] 数据库备份任务开始执行") class DateTimeEncoder(json.JSONEncoder): """自定义 JSON 编码器""" def default(self, obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, date): return obj.isoformat() if isinstance(obj, Decimal): return float(obj) return super().default(obj) def auto_import_models(): """自动导入所有模型""" import importlib project_root = Path(__file__).parent.parent scan_dirs = ["zq_demo", "core", "scheduler", "online_dev", "ai_platform"] for scan_dir in scan_dirs: scan_path = project_root / scan_dir if not scan_path.exists(): continue for model_file in scan_path.rglob("*model.py"): relative_path = model_file.relative_to(project_root) module_path = str(relative_path.with_suffix("")).replace("/", ".").replace("\\", ".") try: importlib.import_module(module_path) except ImportError as e: logger.warning(f"[{job_code}] 导入模型失败 {module_path}: {e}") try: # 自动导入所有模型 auto_import_models() # 确定输出路径 if output_path: output_file = Path(output_path) else: project_root = Path(__file__).parent.parent backups_dir = project_root / "backups" backups_dir.mkdir(exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"backup_{app_name}_{timestamp}.json" if app_name else f"backup_{timestamp}.json" output_file = backups_dir / filename # 确保输出目录存在 output_file.parent.mkdir(parents=True, exist_ok=True) all_data = [] async with AsyncSessionLocal() as session: # 获取所有模型 models = [] for mapper in Base.registry.mappers: model_class = mapper.class_ # 如果指定了 app_name,只导出该应用的模型 if app_name: module_name = model_class.__module__ if not module_name.startswith(app_name): continue # 排除指定的表 if model_class.__tablename__ in exclude_tables: logger.info(f"[{job_code}] 跳过表: {model_class.__tablename__}") continue models.append(model_class) # 按表名排序 models.sort(key=lambda m: m.__tablename__) logger.info(f"[{job_code}] 准备备份 {len(models)} 个表") # 导出每个表 from sqlalchemy import select for model_class in models: logger.info(f"[{job_code}] 备份表: {model_class.__tablename__}") result = await session.execute(select(model_class)) items = result.scalars().all() for item in items: item_dict = {} for column in inspect(model_class).columns: value = getattr(item, column.name) item_dict[column.name] = value all_data.append({ "model": f"{model_class.__module__}.{model_class.__name__}", "pk": item.id if hasattr(item, 'id') else None, "fields": item_dict }) logger.info(f"[{job_code}] - 备份 {len(items)} 条记录") # 写入文件 with open(output_file, 'w', encoding='utf-8') as f: json.dump(all_data, f, ensure_ascii=False, indent=2, cls=DateTimeEncoder) result = f"备份完成: 共 {len(all_data)} 条记录,保存到 {output_file}" logger.info(f"[{job_code}] {result}") return result except Exception as e: logger.error(f"[{job_code}] 数据库备份任务执行失败: {str(e)}") raise