手动模式
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""核心模块"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
配置管理模块
|
||||
负责加载、验证和管理系统配置
|
||||
"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""日志级别枚举"""
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlerConfig:
|
||||
"""爬虫配置"""
|
||||
base_url: str
|
||||
timeout: int
|
||||
max_retries: int
|
||||
retry_delay: float
|
||||
max_retry_delay: float
|
||||
backoff_factor: float
|
||||
user_agents: list
|
||||
proxies: list
|
||||
request_delay: float
|
||||
request_delay_max: float
|
||||
keyword: list
|
||||
start_date: str
|
||||
end_date: str
|
||||
max_pages: int
|
||||
page_size: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
"""数据库配置"""
|
||||
enabled: bool
|
||||
type: str
|
||||
host: str
|
||||
port: int
|
||||
name: str
|
||||
user: str
|
||||
password: str
|
||||
pool_size: int
|
||||
max_overflow: int
|
||||
pool_timeout: int
|
||||
pool_recycle: int
|
||||
data_retention_days: int
|
||||
auto_cleanup: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeChatConfig:
|
||||
"""企业微信配置"""
|
||||
enabled: bool
|
||||
corp_id: str
|
||||
agent_id: str
|
||||
secret: str
|
||||
token: str
|
||||
encoding_aes_key: str
|
||||
port: int
|
||||
host: str
|
||||
debug: bool
|
||||
use_proxy: bool
|
||||
proxy_api_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownConfig:
|
||||
"""Markdown配置"""
|
||||
enabled: bool
|
||||
output_file: str
|
||||
max_entries: int
|
||||
include_today_highlight: bool
|
||||
template_file: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchedulerConfig:
|
||||
"""调度配置"""
|
||||
enabled: bool
|
||||
timezone: str
|
||||
jobs: list
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitoringConfig:
|
||||
"""监控配置"""
|
||||
enabled: bool
|
||||
health_check_interval: int
|
||||
alert_on_failure: bool
|
||||
max_consecutive_failures: int
|
||||
metrics_enabled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemConfig:
|
||||
"""系统配置"""
|
||||
debug: bool
|
||||
log_level: LogLevel
|
||||
log_file: str
|
||||
log_max_size: int
|
||||
log_backup_count: int
|
||||
crawler: CrawlerConfig
|
||||
database: DatabaseConfig
|
||||
wechat_app: WeChatConfig
|
||||
markdown: MarkdownConfig
|
||||
scheduler: Optional[SchedulerConfig]
|
||||
monitoring: MonitoringConfig
|
||||
sources: Dict[str, Dict[str, Any]]
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器"""
|
||||
|
||||
def __init__(self, config_file: Optional[str] = None):
|
||||
"""
|
||||
初始化配置管理器
|
||||
|
||||
Args:
|
||||
config_file: 配置文件路径,如果为None则使用默认路径
|
||||
"""
|
||||
if config_file is None:
|
||||
# 默认配置文件路径
|
||||
current_dir = Path(__file__).parent.parent
|
||||
self.config_file = current_dir / "config" / "config.yaml"
|
||||
else:
|
||||
self.config_file = Path(config_file)
|
||||
|
||||
self._config_data = {}
|
||||
self._config = None
|
||||
|
||||
def load_config(self) -> SystemConfig:
|
||||
"""
|
||||
加载配置文件
|
||||
|
||||
Returns:
|
||||
SystemConfig: 系统配置对象
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 配置文件不存在
|
||||
yaml.YAMLError: 配置文件格式错误
|
||||
ValueError: 配置验证失败
|
||||
"""
|
||||
if not self.config_file.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {self.config_file}")
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
self._config_data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise yaml.YAMLError(f"配置文件格式错误: {e}")
|
||||
|
||||
# 验证配置
|
||||
self._validate_config()
|
||||
|
||||
# 解析配置
|
||||
self._config = self._parse_config()
|
||||
return self._config
|
||||
|
||||
def _validate_config(self):
|
||||
"""验证配置完整性"""
|
||||
required_keys = [
|
||||
'debug', 'log_level', 'log_file', 'log_max_size', 'log_backup_count',
|
||||
'crawler', 'database', 'wechat_app', 'markdown',
|
||||
'monitoring', 'sources'
|
||||
]
|
||||
|
||||
for key in required_keys:
|
||||
if key not in self._config_data:
|
||||
raise ValueError(f"配置文件缺少必需的配置项: {key}")
|
||||
|
||||
# 验证爬虫配置
|
||||
crawler_required = [
|
||||
'base_url', 'timeout', 'max_retries', 'retry_delay', 'max_retry_delay',
|
||||
'backoff_factor', 'user_agents', 'proxies', 'request_delay',
|
||||
'request_delay_max', 'keyword', 'max_pages', 'page_size'
|
||||
]
|
||||
|
||||
for key in crawler_required:
|
||||
if key not in self._config_data['crawler']:
|
||||
raise ValueError(f"爬虫配置缺少必需项: {key}")
|
||||
|
||||
# 验证数据库配置
|
||||
if self._config_data.get('database', {}).get('enabled', False):
|
||||
db_required = ['type', 'host', 'port', 'name', 'user', 'password']
|
||||
for key in db_required:
|
||||
if key not in self._config_data['database']:
|
||||
raise ValueError(f"数据库配置缺少必需项: {key}")
|
||||
|
||||
# 验证企业微信配置
|
||||
if self._config_data.get('wechat_app', {}).get('enabled', False):
|
||||
wechat_required = ['corp_id', 'agent_id', 'secret', 'token', 'encoding_aes_key']
|
||||
for key in wechat_required:
|
||||
if key not in self._config_data['wechat_app']:
|
||||
raise ValueError(f"企业微信配置缺少必需项: {key}")
|
||||
|
||||
def _parse_config(self) -> SystemConfig:
|
||||
"""解析配置数据"""
|
||||
crawler_data = self._config_data['crawler']
|
||||
crawler = CrawlerConfig(
|
||||
base_url=crawler_data['base_url'],
|
||||
timeout=crawler_data['timeout'],
|
||||
max_retries=crawler_data['max_retries'],
|
||||
retry_delay=crawler_data['retry_delay'],
|
||||
max_retry_delay=crawler_data['max_retry_delay'],
|
||||
backoff_factor=crawler_data['backoff_factor'],
|
||||
user_agents=crawler_data['user_agents'],
|
||||
proxies=crawler_data['proxies'],
|
||||
request_delay=crawler_data['request_delay'],
|
||||
request_delay_max=crawler_data['request_delay_max'],
|
||||
keyword=crawler_data['keyword'],
|
||||
start_date=crawler_data.get('start_date', ''),
|
||||
end_date=crawler_data.get('end_date', ''),
|
||||
max_pages=crawler_data['max_pages'],
|
||||
page_size=crawler_data['page_size']
|
||||
)
|
||||
|
||||
db_data = self._config_data['database']
|
||||
database = DatabaseConfig(
|
||||
enabled=db_data.get('enabled', False),
|
||||
type=db_data.get('type', 'postgresql'),
|
||||
host=db_data.get('host', 'localhost'),
|
||||
port=db_data.get('port', 5432),
|
||||
name=db_data.get('name', ''),
|
||||
user=db_data.get('user', ''),
|
||||
password=db_data.get('password', ''),
|
||||
pool_size=db_data.get('pool_size', 5),
|
||||
max_overflow=db_data.get('max_overflow', 10),
|
||||
pool_timeout=db_data.get('pool_timeout', 30),
|
||||
pool_recycle=db_data.get('pool_recycle', 3600),
|
||||
data_retention_days=db_data.get('data_retention_days', 90),
|
||||
auto_cleanup=db_data.get('auto_cleanup', True)
|
||||
)
|
||||
|
||||
wechat_data = self._config_data['wechat_app']
|
||||
wechat_app = WeChatConfig(
|
||||
enabled=wechat_data.get('enabled', False),
|
||||
corp_id=wechat_data.get('corp_id', ''),
|
||||
agent_id=wechat_data.get('agent_id', ''),
|
||||
secret=wechat_data.get('secret', ''),
|
||||
token=wechat_data.get('token', ''),
|
||||
encoding_aes_key=wechat_data.get('encoding_aes_key', ''),
|
||||
port=wechat_data.get('port', 18001),
|
||||
host=wechat_data.get('host', '0.0.0.0'),
|
||||
debug=wechat_data.get('debug', False),
|
||||
use_proxy=wechat_data.get('use_proxy', False),
|
||||
proxy_api_url=wechat_data.get('proxy_api_url', 'https://api.v6ole.top')
|
||||
)
|
||||
|
||||
md_data = self._config_data['markdown']
|
||||
markdown = MarkdownConfig(
|
||||
enabled=md_data.get('enabled', True),
|
||||
output_file=md_data.get('output_file', 'onu.md'),
|
||||
max_entries=md_data.get('max_entries', 1000),
|
||||
include_today_highlight=md_data.get('include_today_highlight', True),
|
||||
template_file=md_data.get('template_file', 'templates/announcement.md')
|
||||
)
|
||||
|
||||
# scheduler配置为可选
|
||||
scheduler = None
|
||||
if 'scheduler' in self._config_data:
|
||||
scheduler_data = self._config_data['scheduler']
|
||||
scheduler = SchedulerConfig(
|
||||
enabled=scheduler_data.get('enabled', False),
|
||||
timezone=scheduler_data.get('timezone', 'Asia/Shanghai'),
|
||||
jobs=scheduler_data.get('jobs', [])
|
||||
)
|
||||
|
||||
monitoring_data = self._config_data['monitoring']
|
||||
monitoring = MonitoringConfig(
|
||||
enabled=monitoring_data.get('enabled', True),
|
||||
health_check_interval=monitoring_data.get('health_check_interval', 300),
|
||||
alert_on_failure=monitoring_data.get('alert_on_failure', True),
|
||||
max_consecutive_failures=monitoring_data.get('max_consecutive_failures', 3),
|
||||
metrics_enabled=monitoring_data.get('metrics_enabled', True)
|
||||
)
|
||||
|
||||
return SystemConfig(
|
||||
debug=self._config_data['debug'],
|
||||
log_level=LogLevel(self._config_data['log_level']),
|
||||
log_file=self._config_data['log_file'],
|
||||
log_max_size=self._config_data['log_max_size'],
|
||||
log_backup_count=self._config_data['log_backup_count'],
|
||||
crawler=crawler,
|
||||
database=database,
|
||||
wechat_app=wechat_app,
|
||||
markdown=markdown,
|
||||
scheduler=scheduler,
|
||||
monitoring=monitoring,
|
||||
sources=self._config_data['sources']
|
||||
)
|
||||
|
||||
def get_config(self) -> SystemConfig:
|
||||
"""
|
||||
获取配置对象
|
||||
|
||||
Returns:
|
||||
SystemConfig: 系统配置对象
|
||||
|
||||
Raises:
|
||||
RuntimeError: 配置未加载
|
||||
"""
|
||||
if self._config is None:
|
||||
raise RuntimeError("配置未加载,请先调用 load_config()")
|
||||
return self._config
|
||||
|
||||
def reload_config(self) -> SystemConfig:
|
||||
"""
|
||||
重新加载配置
|
||||
|
||||
Returns:
|
||||
SystemConfig: 重新加载的系统配置对象
|
||||
"""
|
||||
self._config = None
|
||||
return self.load_config()
|
||||
|
||||
def get_value(self, key_path: str, default=None) -> Any:
|
||||
"""
|
||||
通过路径获取配置值
|
||||
|
||||
Args:
|
||||
key_path: 配置路径,如 'crawler.timeout' 或 'database.host'
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
配置值或默认值
|
||||
"""
|
||||
keys = key_path.split('.')
|
||||
value = self._config_data
|
||||
|
||||
try:
|
||||
for key in keys:
|
||||
value = value[key]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
# 全局配置管理器实例
|
||||
_config_manager = None
|
||||
|
||||
|
||||
def get_config_manager(config_file: Optional[str] = None) -> ConfigManager:
|
||||
"""
|
||||
获取全局配置管理器实例
|
||||
|
||||
Args:
|
||||
config_file: 配置文件路径
|
||||
|
||||
Returns:
|
||||
ConfigManager: 配置管理器实例
|
||||
"""
|
||||
global _config_manager
|
||||
if _config_manager is None:
|
||||
_config_manager = ConfigManager(config_file)
|
||||
return _config_manager
|
||||
|
||||
|
||||
def load_config(config_file: Optional[str] = None) -> SystemConfig:
|
||||
"""
|
||||
加载系统配置
|
||||
|
||||
Args:
|
||||
config_file: 配置文件路径
|
||||
|
||||
Returns:
|
||||
SystemConfig: 系统配置对象
|
||||
"""
|
||||
manager = get_config_manager(config_file)
|
||||
return manager.load_config()
|
||||
|
||||
|
||||
def get_config() -> SystemConfig:
|
||||
"""
|
||||
获取当前加载的配置
|
||||
|
||||
Returns:
|
||||
SystemConfig: 系统配置对象
|
||||
|
||||
Raises:
|
||||
RuntimeError: 配置未加载
|
||||
"""
|
||||
manager = get_config_manager()
|
||||
return manager.get_config()
|
||||
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
PostgreSQL数据库连接和操作模块
|
||||
提供数据库连接池、CRUD操作、数据清理等功能
|
||||
"""
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import pool, extras
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from contextlib import contextmanager
|
||||
from typing import List, Dict, Any, Optional, Generator
|
||||
from datetime import datetime, timedelta
|
||||
import threading
|
||||
from dataclasses import asdict
|
||||
|
||||
from .models import Announcement, AnnouncementSource, AnnouncementType, CrawlResult, CrawlStatus
|
||||
from .config_manager import get_config
|
||||
from .logger import get_logger
|
||||
from .reliability import retry_on_exception, RetryConfig
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DatabaseConnectionPool:
|
||||
"""数据库连接池管理器"""
|
||||
|
||||
_instance = None
|
||||
_pool = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._pool is None:
|
||||
self._pool = None
|
||||
self._config = None
|
||||
|
||||
def init_pool(self, config):
|
||||
"""
|
||||
初始化连接池
|
||||
|
||||
Args:
|
||||
config: 数据库配置
|
||||
"""
|
||||
if self._pool is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._config = config
|
||||
self._pool = psycopg2.pool.SimpleConnectionPool(
|
||||
minconn=config.pool_size,
|
||||
maxconn=config.pool_size + config.max_overflow,
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
database=config.name,
|
||||
user=config.user,
|
||||
password=config.password,
|
||||
connect_timeout=config.pool_timeout
|
||||
)
|
||||
logger.info("数据库连接池初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接池初始化失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_connection(self):
|
||||
"""
|
||||
获取数据库连接
|
||||
|
||||
Returns:
|
||||
数据库连接对象
|
||||
|
||||
Raises:
|
||||
Exception: 获取连接失败
|
||||
"""
|
||||
if self._pool is None:
|
||||
raise Exception("数据库连接池未初始化")
|
||||
|
||||
try:
|
||||
conn = self._pool.getconn()
|
||||
# 设置自动提交为False,需要手动提交
|
||||
conn.autocommit = False
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f"获取数据库连接失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def return_connection(self, conn):
|
||||
"""
|
||||
返回数据库连接到连接池
|
||||
|
||||
Args:
|
||||
conn: 数据库连接对象
|
||||
"""
|
||||
if self._pool and conn:
|
||||
try:
|
||||
self._pool.putconn(conn)
|
||||
except Exception as e:
|
||||
logger.warning(f"返回数据库连接失败: {str(e)}")
|
||||
|
||||
def close_all(self):
|
||||
"""关闭所有连接"""
|
||||
if self._pool:
|
||||
try:
|
||||
self._pool.closeall()
|
||||
logger.info("数据库连接池已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭数据库连接池失败: {str(e)}")
|
||||
|
||||
|
||||
# 全局连接池实例
|
||||
_connection_pool = DatabaseConnectionPool()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
"""
|
||||
获取数据库连接的上下文管理器
|
||||
|
||||
Yields:
|
||||
数据库连接对象
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = _connection_pool.get_connection()
|
||||
yield conn
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接错误: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
_connection_pool.return_connection(conn)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_cursor(commit: bool = True):
|
||||
"""
|
||||
获取数据库游标的上下文管理器
|
||||
|
||||
Args:
|
||||
commit: 是否自动提交事务
|
||||
|
||||
Yields:
|
||||
数据库游标对象
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
yield cursor
|
||||
if commit:
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"数据库操作错误: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""数据库管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
if self.config.database.enabled:
|
||||
_connection_pool.init_pool(self.config.database)
|
||||
else:
|
||||
logger.warning("数据库功能已禁用")
|
||||
|
||||
def init_database(self):
|
||||
"""初始化数据库表结构"""
|
||||
if not self.config.database.enabled:
|
||||
return
|
||||
|
||||
logger.info("开始初始化数据库表结构")
|
||||
|
||||
# 创建表的SQL语句
|
||||
create_tables_sql = """
|
||||
-- 公告表
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
publish_date TIMESTAMP NOT NULL,
|
||||
purchase_name VARCHAR(200),
|
||||
content_url TEXT,
|
||||
source_code VARCHAR(50) NOT NULL,
|
||||
source_name VARCHAR(100) NOT NULL,
|
||||
announcement_type VARCHAR(50) NOT NULL,
|
||||
crawled_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
content_hash VARCHAR(32) UNIQUE,
|
||||
keyword_matched BOOLEAN DEFAULT FALSE,
|
||||
date_filtered BOOLEAN DEFAULT TRUE,
|
||||
is_new BOOLEAN DEFAULT TRUE,
|
||||
is_today BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 公告来源表
|
||||
CREATE TABLE IF NOT EXISTS announcement_sources (
|
||||
code VARCHAR(50) PRIMARY KEY,
|
||||
category_id INTEGER NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL
|
||||
);
|
||||
|
||||
-- 爬取结果表
|
||||
CREATE TABLE IF NOT EXISTS crawl_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_code VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
total_count INTEGER DEFAULT 0,
|
||||
new_count INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
duration FLOAT DEFAULT 0.0,
|
||||
FOREIGN KEY (source_code) REFERENCES announcement_sources(code)
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_publish_date ON announcements(publish_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_source_code ON announcements(source_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_content_hash ON announcements(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_results_crawled_at ON crawl_results(crawled_at DESC);
|
||||
|
||||
-- 创建更新时间触发器
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
DROP TRIGGER IF EXISTS update_announcements_updated_at ON announcements;
|
||||
CREATE TRIGGER update_announcements_updated_at
|
||||
BEFORE UPDATE ON announcements
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
"""
|
||||
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(create_tables_sql)
|
||||
logger.info("数据库表结构初始化完成")
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def save_announcement(self, announcement: Announcement) -> bool:
|
||||
"""
|
||||
保存公告到数据库
|
||||
|
||||
Args:
|
||||
announcement: 公告对象
|
||||
|
||||
Returns:
|
||||
bool: 保存是否成功
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return False
|
||||
|
||||
# 生成内容哈希(如果还没有)
|
||||
if not announcement.content_hash:
|
||||
announcement.generate_content_hash()
|
||||
|
||||
sql = """
|
||||
INSERT INTO announcements (
|
||||
title, publish_date, purchase_name, content_url, source_code, source_name,
|
||||
announcement_type, crawled_at, content_hash, keyword_matched,
|
||||
date_filtered, is_new, is_today
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (content_hash) DO NOTHING
|
||||
"""
|
||||
|
||||
values = (
|
||||
announcement.title,
|
||||
announcement.publish_date,
|
||||
announcement.purchase_name,
|
||||
announcement.content_url,
|
||||
announcement.source_code,
|
||||
announcement.source_name,
|
||||
announcement.announcement_type.value,
|
||||
announcement.crawled_at,
|
||||
announcement.content_hash,
|
||||
announcement.keyword_matched,
|
||||
announcement.date_filtered,
|
||||
announcement.is_new,
|
||||
announcement.is_today
|
||||
)
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, values)
|
||||
affected_rows = cursor.rowcount
|
||||
if affected_rows > 0:
|
||||
logger.debug(f"成功保存公告: {announcement.title[:50]}...")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"公告已存在,跳过保存: {announcement.title[:50]}...")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"保存公告失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def save_announcements_batch(self, announcements: List[Announcement]) -> int:
|
||||
"""
|
||||
批量保存公告
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
|
||||
Returns:
|
||||
int: 成功保存的数量
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return 0
|
||||
|
||||
if not announcements:
|
||||
return 0
|
||||
|
||||
# 为没有哈希的公告生成哈希
|
||||
for announcement in announcements:
|
||||
if not announcement.content_hash:
|
||||
announcement.generate_content_hash()
|
||||
|
||||
sql = """
|
||||
INSERT INTO announcements (
|
||||
title, publish_date, purchase_name, content_url, source_code, source_name,
|
||||
announcement_type, crawled_at, content_hash, keyword_matched,
|
||||
date_filtered, is_new, is_today
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (content_hash) DO NOTHING
|
||||
"""
|
||||
|
||||
values = []
|
||||
for announcement in announcements:
|
||||
values.append((
|
||||
announcement.title,
|
||||
announcement.publish_date,
|
||||
announcement.purchase_name,
|
||||
announcement.content_url,
|
||||
announcement.source_code,
|
||||
announcement.source_name,
|
||||
announcement.announcement_type.value,
|
||||
announcement.crawled_at,
|
||||
announcement.content_hash,
|
||||
announcement.keyword_matched,
|
||||
announcement.date_filtered,
|
||||
announcement.is_new,
|
||||
announcement.is_today
|
||||
))
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
extras.execute_batch(cursor, sql, values)
|
||||
affected_rows = cursor.rowcount
|
||||
logger.info(f"批量保存公告完成,成功保存 {affected_rows} 条")
|
||||
return affected_rows
|
||||
except Exception as e:
|
||||
logger.error(f"批量保存公告失败: {str(e)}")
|
||||
return 0
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def get_announcements(self,
|
||||
source_code: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0) -> List[Announcement]:
|
||||
"""
|
||||
查询公告
|
||||
|
||||
Args:
|
||||
source_code: 来源代码过滤
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
limit: 限制数量
|
||||
offset: 偏移量
|
||||
|
||||
Returns:
|
||||
List[Announcement]: 公告列表
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return []
|
||||
|
||||
sql = """
|
||||
SELECT * FROM announcements
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if source_code:
|
||||
sql += " AND source_code = %s"
|
||||
params.append(source_code)
|
||||
|
||||
if start_date:
|
||||
sql += " AND publish_date >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
sql += " AND publish_date <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
sql += " ORDER BY publish_date DESC LIMIT %s OFFSET %s"
|
||||
params.extend([limit, offset])
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
announcements = []
|
||||
for row in rows:
|
||||
# 转换数据类型
|
||||
row_dict = dict(row)
|
||||
row_dict['announcement_type'] = AnnouncementType(row_dict['announcement_type'])
|
||||
announcements.append(Announcement.from_dict(row_dict))
|
||||
|
||||
return announcements
|
||||
except Exception as e:
|
||||
logger.error(f"查询公告失败: {str(e)}")
|
||||
return []
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def save_crawl_result(self, result: CrawlResult) -> bool:
|
||||
"""
|
||||
保存爬取结果
|
||||
|
||||
Args:
|
||||
result: 爬取结果对象
|
||||
|
||||
Returns:
|
||||
bool: 保存是否成功
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return False
|
||||
|
||||
sql = """
|
||||
INSERT INTO crawl_results (
|
||||
source_code, status, total_count, new_count, error_message,
|
||||
crawled_at, duration
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
values = (
|
||||
result.source.code,
|
||||
result.status.value,
|
||||
result.total_count,
|
||||
result.new_count,
|
||||
result.error_message,
|
||||
result.crawled_at,
|
||||
result.duration
|
||||
)
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, values)
|
||||
logger.debug(f"保存爬取结果: {result.source.name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"保存爬取结果失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def cleanup_expired_data(self, days: int = 90) -> int:
|
||||
"""
|
||||
清理过期数据
|
||||
|
||||
Args:
|
||||
days: 保留天数
|
||||
|
||||
Returns:
|
||||
int: 清理的记录数
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return 0
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
sql = "DELETE FROM announcements WHERE created_at < %s"
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, (cutoff_date,))
|
||||
deleted_count = cursor.rowcount
|
||||
logger.info(f"清理过期数据完成,删除 {deleted_count} 条记录")
|
||||
return deleted_count
|
||||
except Exception as e:
|
||||
logger.error(f"清理过期数据失败: {str(e)}")
|
||||
return 0
|
||||
|
||||
@retry_on_exception(RetryConfig(max_retries=3))
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取统计信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 统计数据
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return {}
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_announcements,
|
||||
COUNT(CASE WHEN is_today THEN 1 END) as today_announcements,
|
||||
COUNT(CASE WHEN is_new THEN 1 END) as new_announcements,
|
||||
COUNT(DISTINCT source_code) as sources_count,
|
||||
MAX(crawled_at) as last_crawl_time
|
||||
FROM announcements
|
||||
"""
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql)
|
||||
result = cursor.fetchone()
|
||||
return dict(result) if result else {}
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
def is_announcement_exists(self, content_hash: str) -> bool:
|
||||
"""
|
||||
检查公告是否已存在
|
||||
|
||||
Args:
|
||||
content_hash: 内容哈希
|
||||
|
||||
Returns:
|
||||
bool: 是否存在
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return False
|
||||
|
||||
sql = "SELECT 1 FROM announcements WHERE content_hash = %s LIMIT 1"
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, (content_hash,))
|
||||
return cursor.fetchone() is not None
|
||||
except Exception as e:
|
||||
logger.error(f"检查公告存在性失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_recent_announcements(self, hours: int = 24) -> List[Announcement]:
|
||||
"""
|
||||
获取最近的公告
|
||||
|
||||
Args:
|
||||
hours: 最近小时数
|
||||
|
||||
Returns:
|
||||
List[Announcement]: 公告列表
|
||||
"""
|
||||
if not self.config.database.enabled:
|
||||
return []
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||
|
||||
sql = """
|
||||
SELECT * FROM announcements
|
||||
WHERE crawled_at >= %s
|
||||
ORDER BY crawled_at DESC
|
||||
"""
|
||||
|
||||
try:
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, (cutoff_time,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
announcements = []
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
row_dict['announcement_type'] = AnnouncementType(row_dict['announcement_type'])
|
||||
announcements.append(Announcement.from_dict(row_dict))
|
||||
|
||||
return announcements
|
||||
except Exception as e:
|
||||
logger.error(f"获取最近公告失败: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
# 全局数据库管理器实例
|
||||
_db_manager = None
|
||||
|
||||
|
||||
def get_database_manager() -> DatabaseManager:
|
||||
"""
|
||||
获取数据库管理器实例
|
||||
|
||||
Returns:
|
||||
DatabaseManager: 数据库管理器实例
|
||||
"""
|
||||
global _db_manager
|
||||
if _db_manager is None:
|
||||
_db_manager = DatabaseManager()
|
||||
return _db_manager
|
||||
|
||||
|
||||
def init_database():
|
||||
"""初始化数据库"""
|
||||
manager = get_database_manager()
|
||||
manager.init_database()
|
||||
|
||||
|
||||
def cleanup_database():
|
||||
"""清理数据库连接"""
|
||||
_connection_pool.close_all()
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
统一日志管理模块
|
||||
提供结构化日志记录功能,支持控制台和文件输出
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from .config_manager import get_config
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""带颜色的日志格式化器"""
|
||||
|
||||
# ANSI颜色代码
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # 青色
|
||||
'INFO': '\033[32m', # 绿色
|
||||
'WARNING': '\033[33m', # 黄色
|
||||
'ERROR': '\033[31m', # 红色
|
||||
'CRITICAL': '\033[35m', # 紫色
|
||||
}
|
||||
RESET = '\033[0m' # 重置颜色
|
||||
|
||||
def format(self, record):
|
||||
# 检查是否已经包含ANSI颜色代码
|
||||
if '\033[' in record.levelname:
|
||||
# 如果已经着色,直接返回原始格式
|
||||
return super().format(record)
|
||||
|
||||
# 添加颜色
|
||||
if record.levelname in self.COLORS:
|
||||
# 为levelname添加颜色
|
||||
colored_levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.RESET}"
|
||||
# 使用原始levelname来确定消息的颜色
|
||||
record.msg = f"{self.COLORS[record.levelname]}{record.msg}{self.RESET}"
|
||||
record.levelname = colored_levelname
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class Logger:
|
||||
"""统一日志管理器"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._initialized:
|
||||
self._loggers = {}
|
||||
self._config = None
|
||||
self._initialized = True
|
||||
|
||||
def init_logger(self, name: str = "gx_gp_monitor", config=None) -> logging.Logger:
|
||||
"""
|
||||
初始化日志器
|
||||
|
||||
Args:
|
||||
name: 日志器名称
|
||||
config: 配置对象,如果为None则从全局配置加载
|
||||
|
||||
Returns:
|
||||
logging.Logger: 配置好的日志器实例
|
||||
"""
|
||||
if name in self._loggers:
|
||||
return self._loggers[name]
|
||||
|
||||
# 获取配置
|
||||
if config is None:
|
||||
try:
|
||||
self._config = get_config()
|
||||
except RuntimeError:
|
||||
# 配置未加载,使用默认配置
|
||||
self._config = self._get_default_config()
|
||||
else:
|
||||
self._config = config
|
||||
|
||||
# 创建日志器
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(getattr(logging, self._config.log_level.value))
|
||||
|
||||
# 避免重复添加处理器
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, self._config.log_level.value))
|
||||
|
||||
# 使用彩色格式化器(如果支持)
|
||||
if sys.platform != 'win32' and 'TERM' in os.environ:
|
||||
colored_formatter = ColoredFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
console_handler.setFormatter(colored_formatter)
|
||||
else:
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器(如果配置了日志文件)
|
||||
if self._config.log_file:
|
||||
log_dir = Path(self._config.log_file).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
self._config.log_file,
|
||||
maxBytes=self._config.log_max_size,
|
||||
backupCount=self._config.log_backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(getattr(logging, self._config.log_level.value))
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
self._loggers[name] = logger
|
||||
return logger
|
||||
|
||||
def _get_default_config(self):
|
||||
"""获取默认配置"""
|
||||
from .config_manager import LogLevel
|
||||
|
||||
class DefaultConfig:
|
||||
def __init__(self):
|
||||
self.log_level = LogLevel.INFO
|
||||
self.log_file = "logs/gx_gp_monitor.log"
|
||||
self.log_max_size = 10485760 # 10MB
|
||||
self.log_backup_count = 5
|
||||
|
||||
return DefaultConfig()
|
||||
|
||||
def get_logger(self, name: str = "gx_gp_monitor") -> logging.Logger:
|
||||
"""
|
||||
获取日志器
|
||||
|
||||
Args:
|
||||
name: 日志器名称
|
||||
|
||||
Returns:
|
||||
logging.Logger: 日志器实例
|
||||
"""
|
||||
if name not in self._loggers:
|
||||
return self.init_logger(name)
|
||||
return self._loggers[name]
|
||||
|
||||
def log_crawl_start(self, source_name: str, logger: Optional[logging.Logger] = None):
|
||||
"""记录爬取开始"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
logger.info(f"开始爬取 {source_name}")
|
||||
|
||||
def log_crawl_success(self, source_name: str, count: int, duration: float,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录爬取成功"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
logger.info(f"{source_name} 爬取完成,共获取 {count} 条公告,耗时 {duration:.2f}秒")
|
||||
|
||||
def log_crawl_error(self, source_name: str, error: str,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录爬取错误"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
logger.error(f"{source_name} 爬取失败: {error}")
|
||||
|
||||
def log_announcement_filtered(self, reason: str, count: int,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录公告筛选信息"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
logger.info(f"公告筛选 - {reason}: {count} 条")
|
||||
|
||||
def log_database_operation(self, operation: str, table: str, count: int = 0,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录数据库操作"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
if count > 0:
|
||||
logger.info(f"数据库操作 - {operation} {table}: {count} 条记录")
|
||||
else:
|
||||
logger.info(f"数据库操作 - {operation} {table}")
|
||||
|
||||
def log_notification_sent(self, channel: str, recipient_count: int,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录通知发送"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
logger.info(f"通知发送 - {channel}: 向 {recipient_count} 个接收者发送")
|
||||
|
||||
def log_system_metrics(self, metrics: Dict[str, Any],
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录系统指标"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
metrics_str = ", ".join([f"{k}={v}" for k, v in metrics.items()])
|
||||
logger.info(f"系统指标: {metrics_str}")
|
||||
|
||||
def log_performance_warning(self, operation: str, duration: float, threshold: float,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""记录性能警告"""
|
||||
if logger is None:
|
||||
logger = self.get_logger()
|
||||
logger.warning(f"性能警告 - {operation} 耗时 {duration:.2f}秒,超过阈值 {threshold:.2f}秒")
|
||||
|
||||
|
||||
# 全局日志管理器实例
|
||||
_logger_manager = Logger()
|
||||
|
||||
|
||||
def get_logger(name: str = "gx_gp_monitor") -> logging.Logger:
|
||||
"""
|
||||
获取日志器
|
||||
|
||||
Args:
|
||||
name: 日志器名称
|
||||
|
||||
Returns:
|
||||
logging.Logger: 日志器实例
|
||||
"""
|
||||
return _logger_manager.get_logger(name)
|
||||
|
||||
|
||||
def init_logger(name: str = "gx_gp_monitor", config=None) -> logging.Logger:
|
||||
"""
|
||||
初始化并获取日志器
|
||||
|
||||
Args:
|
||||
name: 日志器名称
|
||||
config: 配置对象
|
||||
|
||||
Returns:
|
||||
logging.Logger: 日志器实例
|
||||
"""
|
||||
return _logger_manager.init_logger(name, config)
|
||||
|
||||
|
||||
def log_function_call(func_name: str, args: Optional[Dict[str, Any]] = None,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
装饰器:记录函数调用
|
||||
|
||||
Args:
|
||||
func_name: 函数名称
|
||||
args: 函数参数
|
||||
logger: 日志器实例
|
||||
"""
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
nonlocal logger
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
|
||||
start_time = datetime.now()
|
||||
logger.debug(f"调用函数: {func_name}")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.debug(f"函数 {func_name} 执行完成,耗时 {duration:.3f}秒")
|
||||
return result
|
||||
except Exception as e:
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.error(f"函数 {func_name} 执行失败,耗时 {duration:.3f}秒: {str(e)}")
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def log_info(message: str, logger: Optional[logging.Logger] = None):
|
||||
"""记录信息日志"""
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
logger.info(message)
|
||||
|
||||
|
||||
def log_warning(message: str, logger: Optional[logging.Logger] = None):
|
||||
"""记录警告日志"""
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
logger.warning(message)
|
||||
|
||||
|
||||
def log_error(message: str, logger: Optional[logging.Logger] = None):
|
||||
"""记录错误日志"""
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
logger.error(message)
|
||||
|
||||
|
||||
def log_debug(message: str, logger: Optional[logging.Logger] = None):
|
||||
"""记录调试日志"""
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
logger.debug(message)
|
||||
|
||||
|
||||
# 便捷的爬取日志记录函数
|
||||
def log_crawl_start(source_name: str):
|
||||
"""记录爬取开始"""
|
||||
_logger_manager.log_crawl_start(source_name)
|
||||
|
||||
|
||||
def log_crawl_success(source_name: str, count: int, duration: float):
|
||||
"""记录爬取成功"""
|
||||
_logger_manager.log_crawl_success(source_name, count, duration)
|
||||
|
||||
|
||||
def log_crawl_error(source_name: str, error: str):
|
||||
"""记录爬取错误"""
|
||||
_logger_manager.log_crawl_error(source_name, error)
|
||||
|
||||
|
||||
def log_announcement_filtered(reason: str, count: int):
|
||||
"""记录公告筛选信息"""
|
||||
_logger_manager.log_announcement_filtered(reason, count)
|
||||
|
||||
|
||||
def log_database_operation(operation: str, table: str, count: int = 0):
|
||||
"""记录数据库操作"""
|
||||
_logger_manager.log_database_operation(operation, table, count)
|
||||
|
||||
|
||||
def log_notification_sent(channel: str, recipient_count: int):
|
||||
"""记录通知发送"""
|
||||
_logger_manager.log_notification_sent(channel, recipient_count)
|
||||
|
||||
|
||||
def log_system_metrics(metrics: Dict[str, Any]):
|
||||
"""记录系统指标"""
|
||||
_logger_manager.log_system_metrics(metrics)
|
||||
|
||||
|
||||
def log_performance_warning(operation: str, duration: float, threshold: float):
|
||||
"""记录性能警告"""
|
||||
_logger_manager.log_performance_warning(operation, duration, threshold)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
数据模型定义
|
||||
定义系统使用的数据结构和模型
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AnnouncementType(Enum):
|
||||
"""公告类型枚举"""
|
||||
PURCHASE = "purchase" # 采购公告
|
||||
RESULT = "result" # 结果公告
|
||||
CONTRACT = "contract" # 合同公告
|
||||
CORRECTION = "correction" # 更正公告
|
||||
PRE_ANNOUNCEMENT = "pre_announcement" # 招标文件预公示
|
||||
SINGLE_SOURCE = "single_source" # 单一来源公示
|
||||
ELECTRONIC_MARKET = "electronic_market" # 电子卖场公示
|
||||
ACCEPTANCE = "acceptance" # 履约验收公示
|
||||
ENGINEERING = "engineering" # 工程类公告
|
||||
FRAMEWORK_AGREEMENT = "framework_agreement" # 框架协议征集公告
|
||||
FRAMEWORK_RESULT = "framework_result" # 框架协议入围结果公告
|
||||
FRAMEWORK_SUMMARY = "framework_summary" # 框架协议成交结果汇总公告
|
||||
INTENTION = "intention" # 采购意向公开
|
||||
|
||||
|
||||
class CrawlStatus(Enum):
|
||||
"""爬取状态枚举"""
|
||||
PENDING = "pending" # 待爬取
|
||||
RUNNING = "running" # 爬取中
|
||||
SUCCESS = "success" # 成功
|
||||
FAILED = "failed" # 失败
|
||||
PARTIAL = "partial" # 部分成功
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnnouncementSource:
|
||||
"""公告来源"""
|
||||
code: str # 来源代码,如 "ZcyAnnouncement1"
|
||||
category_id: int # 分类ID
|
||||
name: str # 显示名称,如 "采购公告"
|
||||
type: AnnouncementType # 公告类型
|
||||
|
||||
|
||||
@dataclass
|
||||
class Announcement:
|
||||
"""公告数据模型"""
|
||||
id: Optional[int] = None # 数据库ID
|
||||
title: str = "" # 公告标题
|
||||
publish_date: datetime = field(default_factory=datetime.now) # 发布时间
|
||||
purchase_name: str = "" # 发布单位
|
||||
content_url: str = "" # 内容链接
|
||||
source_code: str = "" # 来源代码
|
||||
source_name: str = "" # 来源名称
|
||||
announcement_type: AnnouncementType = AnnouncementType.PURCHASE # 公告类型
|
||||
|
||||
# 爬取相关字段
|
||||
crawled_at: Optional[datetime] = None # 爬取时间
|
||||
created_at: Optional[datetime] = None # 创建时间
|
||||
updated_at: Optional[datetime] = None # 更新时间
|
||||
|
||||
# 去重字段
|
||||
content_hash: Optional[str] = None # 内容哈希,用于去重
|
||||
|
||||
# 筛选相关
|
||||
keyword_matched: bool = False # 是否匹配关键词
|
||||
date_filtered: bool = True # 是否在日期范围内
|
||||
|
||||
# 业务字段
|
||||
is_new: bool = True # 是否为新公告
|
||||
is_today: bool = False # 是否为今日公告
|
||||
|
||||
def __post_init__(self):
|
||||
"""后初始化处理"""
|
||||
if isinstance(self.announcement_type, str):
|
||||
self.announcement_type = AnnouncementType(self.announcement_type)
|
||||
|
||||
if self.publish_date and isinstance(self.publish_date, str):
|
||||
try:
|
||||
self.publish_date = datetime.fromisoformat(self.publish_date.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
# 如果解析失败,使用当前时间
|
||||
self.publish_date = datetime.now()
|
||||
|
||||
# 判断是否为今日公告
|
||||
today = datetime.now().date()
|
||||
if self.publish_date:
|
||||
self.is_today = self.publish_date.date() == today
|
||||
|
||||
@property
|
||||
def publish_date_str(self) -> str:
|
||||
"""获取发布日期字符串"""
|
||||
return self.publish_date.strftime("%Y-%m-%d") if self.publish_date else ""
|
||||
|
||||
@property
|
||||
def crawled_at_str(self) -> str:
|
||||
"""获取爬取时间字符串"""
|
||||
return self.crawled_at.strftime("%Y-%m-%d %H:%M:%S") if self.crawled_at else ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"publish_date": self.publish_date.isoformat() if self.publish_date else None,
|
||||
"purchase_name": self.purchase_name,
|
||||
"content_url": self.content_url,
|
||||
"source_code": self.source_code,
|
||||
"source_name": self.source_name,
|
||||
"announcement_type": self.announcement_type.value,
|
||||
"crawled_at": self.crawled_at.isoformat() if self.crawled_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"content_hash": self.content_hash,
|
||||
"keyword_matched": self.keyword_matched,
|
||||
"date_filtered": self.date_filtered,
|
||||
"is_new": self.is_new,
|
||||
"is_today": self.is_today
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Announcement':
|
||||
"""从字典创建实例"""
|
||||
# 处理枚举类型
|
||||
if 'announcement_type' in data and isinstance(data['announcement_type'], str):
|
||||
data['announcement_type'] = AnnouncementType(data['announcement_type'])
|
||||
|
||||
# 处理日期时间
|
||||
for date_field in ['publish_date', 'crawled_at', 'created_at', 'updated_at']:
|
||||
if date_field in data and data[date_field] and isinstance(data[date_field], str):
|
||||
try:
|
||||
data[date_field] = datetime.fromisoformat(data[date_field].replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
data[date_field] = None
|
||||
|
||||
return cls(**data)
|
||||
|
||||
def generate_content_hash(self) -> str:
|
||||
"""生成内容哈希用于去重"""
|
||||
import hashlib
|
||||
content = f"{self.title}|{self.publish_date_str}|{self.purchase_name}|{self.content_url}|{self.source_code}"
|
||||
self.content_hash = hashlib.md5(content.encode('utf-8')).hexdigest()
|
||||
return self.content_hash
|
||||
|
||||
def matches_keywords(self, keywords: List[str]) -> bool:
|
||||
"""检查是否匹配关键词"""
|
||||
if not keywords:
|
||||
return True
|
||||
|
||||
search_text = f"{self.title} {self.purchase_name}".lower()
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in search_text:
|
||||
self.keyword_matched = True
|
||||
return True
|
||||
|
||||
self.keyword_matched = False
|
||||
return False
|
||||
|
||||
def in_date_range(self, start_date: Optional[str], end_date: Optional[str]) -> bool:
|
||||
"""检查是否在日期范围内"""
|
||||
if not self.publish_date:
|
||||
self.date_filtered = False
|
||||
return False
|
||||
|
||||
publish_date = self.publish_date.date()
|
||||
|
||||
try:
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date).date()
|
||||
if publish_date < start:
|
||||
self.date_filtered = False
|
||||
return False
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date).date()
|
||||
if publish_date > end:
|
||||
self.date_filtered = False
|
||||
return False
|
||||
|
||||
self.date_filtered = True
|
||||
return True
|
||||
except ValueError:
|
||||
# 日期格式错误时,默认通过
|
||||
self.date_filtered = True
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlResult:
|
||||
"""爬取结果"""
|
||||
source: AnnouncementSource # 公告来源
|
||||
status: CrawlStatus # 爬取状态
|
||||
total_count: int = 0 # 总公告数
|
||||
new_count: int = 0 # 新增公告数
|
||||
error_message: Optional[str] = None # 错误信息
|
||||
announcements: List[Announcement] = field(default_factory=list) # 公告列表
|
||||
crawled_at: datetime = field(default_factory=datetime.now) # 爬取时间
|
||||
duration: float = 0.0 # 爬取耗时(秒)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlSession:
|
||||
"""爬取会话"""
|
||||
session_id: str # 会话ID
|
||||
start_time: datetime # 开始时间
|
||||
end_time: Optional[datetime] = None # 结束时间
|
||||
status: CrawlStatus = CrawlStatus.PENDING # 会话状态
|
||||
total_sources: int = 0 # 总来源数
|
||||
completed_sources: int = 0 # 已完成来源数
|
||||
total_announcements: int = 0 # 总公告数
|
||||
new_announcements: int = 0 # 新增公告数
|
||||
results: List[CrawlResult] = field(default_factory=list) # 各来源结果
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""获取会话持续时间"""
|
||||
if self.end_time and self.start_time:
|
||||
return (self.end_time - self.start_time).total_seconds()
|
||||
elif self.start_time:
|
||||
return (datetime.now() - self.start_time).total_seconds()
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def progress(self) -> float:
|
||||
"""获取完成进度(0-1)"""
|
||||
if self.total_sources == 0:
|
||||
return 0.0
|
||||
return self.completed_sources / self.total_sources
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationMessage:
|
||||
"""通知消息"""
|
||||
title: str # 消息标题
|
||||
content: str # 消息内容
|
||||
message_type: str = "text" # 消息类型:text, markdown, card
|
||||
recipients: List[str] = field(default_factory=lambda: ["@all"]) # 接收者列表
|
||||
attachments: Optional[Dict[str, Any]] = None # 附件信息
|
||||
created_at: datetime = field(default_factory=datetime.now) # 创建时间
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemMetrics:
|
||||
"""系统指标"""
|
||||
timestamp: datetime = field(default_factory=datetime.now) # 时间戳
|
||||
total_announcements: int = 0 # 总公告数
|
||||
today_announcements: int = 0 # 今日公告数
|
||||
new_announcements_today: int = 0 # 今日新增公告数
|
||||
crawl_sessions_today: int = 0 # 今日爬取会话数
|
||||
last_crawl_duration: float = 0.0 # 最后一次爬取耗时
|
||||
database_size: int = 0 # 数据库大小(字节)
|
||||
memory_usage: float = 0.0 # 内存使用率
|
||||
disk_usage: float = 0.0 # 磁盘使用率
|
||||
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
高可用性模块
|
||||
提供重试机制、超时控制、幂等操作、异常处理和恢复功能
|
||||
"""
|
||||
|
||||
import time
|
||||
import random
|
||||
import hashlib
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from typing import Callable, Any, Optional, Type, Union, List
|
||||
from datetime import datetime, timedelta
|
||||
import threading
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from .logger import get_logger
|
||||
from .config_manager import get_config
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RetryConfig:
|
||||
"""重试配置"""
|
||||
|
||||
def __init__(self,
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
backoff_factor: float = 2.0,
|
||||
jitter: bool = True):
|
||||
"""
|
||||
初始化重试配置
|
||||
|
||||
Args:
|
||||
max_retries: 最大重试次数
|
||||
initial_delay: 初始延迟时间(秒)
|
||||
max_delay: 最大延迟时间(秒)
|
||||
backoff_factor: 退避因子
|
||||
jitter: 是否添加随机抖动
|
||||
"""
|
||||
self.max_retries = max_retries
|
||||
self.initial_delay = initial_delay
|
||||
self.max_delay = max_delay
|
||||
self.backoff_factor = backoff_factor
|
||||
self.jitter = jitter
|
||||
|
||||
|
||||
class TimeoutConfig:
|
||||
"""超时配置"""
|
||||
|
||||
def __init__(self,
|
||||
connect_timeout: float = 10.0,
|
||||
read_timeout: float = 30.0,
|
||||
total_timeout: Optional[float] = None):
|
||||
"""
|
||||
初始化超时配置
|
||||
|
||||
Args:
|
||||
connect_timeout: 连接超时时间(秒)
|
||||
read_timeout: 读取超时时间(秒)
|
||||
total_timeout: 总超时时间(秒)
|
||||
"""
|
||||
self.connect_timeout = connect_timeout
|
||||
self.read_timeout = read_timeout
|
||||
self.total_timeout = total_timeout or (connect_timeout + read_timeout)
|
||||
|
||||
|
||||
class CircuitBreakerState:
|
||||
"""熔断器状态"""
|
||||
CLOSED = "closed" # 关闭状态,正常工作
|
||||
OPEN = "open" # 打开状态,快速失败
|
||||
HALF_OPEN = "half_open" # 半开状态,测试恢复
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""熔断器实现"""
|
||||
|
||||
def __init__(self,
|
||||
failure_threshold: int = 5,
|
||||
recovery_timeout: int = 60,
|
||||
expected_exception: Type[Exception] = Exception):
|
||||
"""
|
||||
初始化熔断器
|
||||
|
||||
Args:
|
||||
failure_threshold: 失败阈值
|
||||
recovery_timeout: 恢复超时时间(秒)
|
||||
expected_exception: 期望的异常类型
|
||||
"""
|
||||
self.failure_threshold = failure_threshold
|
||||
self.recovery_timeout = recovery_timeout
|
||||
self.expected_exception = expected_exception
|
||||
|
||||
self.state = CircuitBreakerState.CLOSED
|
||||
self.failure_count = 0
|
||||
self.last_failure_time = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""装饰器实现"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return self._execute_with_circuit_breaker(func, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def _execute_with_circuit_breaker(self, func: Callable, *args, **kwargs) -> Any:
|
||||
"""使用熔断器执行函数"""
|
||||
if self.state == CircuitBreakerState.OPEN:
|
||||
if self._should_attempt_reset():
|
||||
self.state = CircuitBreakerState.HALF_OPEN
|
||||
logger.info("熔断器半开,尝试恢复")
|
||||
else:
|
||||
raise CircuitBreakerOpenException("熔断器已打开")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
self._on_success()
|
||||
return result
|
||||
except self.expected_exception as e:
|
||||
self._on_failure()
|
||||
raise
|
||||
|
||||
def _should_attempt_reset(self) -> bool:
|
||||
"""检查是否应该尝试重置"""
|
||||
if self.last_failure_time is None:
|
||||
return True
|
||||
return (datetime.now() - self.last_failure_time).total_seconds() >= self.recovery_timeout
|
||||
|
||||
def _on_success(self):
|
||||
"""成功时的处理"""
|
||||
with self._lock:
|
||||
if self.state == CircuitBreakerState.HALF_OPEN:
|
||||
self.state = CircuitBreakerState.CLOSED
|
||||
self.failure_count = 0
|
||||
logger.info("熔断器关闭,服务恢复正常")
|
||||
|
||||
def _on_failure(self):
|
||||
"""失败时的处理"""
|
||||
with self._lock:
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = datetime.now()
|
||||
|
||||
if self.failure_count >= self.failure_threshold:
|
||||
self.state = CircuitBreakerState.OPEN
|
||||
logger.warning(f"熔断器打开,失败次数达到阈值: {self.failure_count}")
|
||||
|
||||
|
||||
class CircuitBreakerOpenException(Exception):
|
||||
"""熔断器打开异常"""
|
||||
pass
|
||||
|
||||
|
||||
class IdempotencyKey:
|
||||
"""幂等性键生成器"""
|
||||
|
||||
@staticmethod
|
||||
def generate(*args, **kwargs) -> str:
|
||||
"""
|
||||
生成幂等性键
|
||||
|
||||
Args:
|
||||
*args: 位置参数
|
||||
**kwargs: 关键字参数
|
||||
|
||||
Returns:
|
||||
str: 幂等性键
|
||||
"""
|
||||
# 将参数转换为字符串并排序
|
||||
key_parts = []
|
||||
|
||||
# 处理位置参数
|
||||
for i, arg in enumerate(args):
|
||||
key_parts.append(f"arg_{i}:{str(arg)}")
|
||||
|
||||
# 处理关键字参数(排序以保证一致性)
|
||||
for key in sorted(kwargs.keys()):
|
||||
key_parts.append(f"{key}:{str(kwargs[key])}")
|
||||
|
||||
# 生成哈希
|
||||
key_string = "|".join(key_parts)
|
||||
return hashlib.md5(key_string.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class IdempotencyManager:
|
||||
"""幂等性管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._executed_keys = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def is_executed(self, key: str) -> bool:
|
||||
"""
|
||||
检查操作是否已执行
|
||||
|
||||
Args:
|
||||
key: 幂等性键
|
||||
|
||||
Returns:
|
||||
bool: 是否已执行
|
||||
"""
|
||||
with self._lock:
|
||||
return key in self._executed_keys
|
||||
|
||||
def mark_executed(self, key: str):
|
||||
"""
|
||||
标记操作已执行
|
||||
|
||||
Args:
|
||||
key: 幂等性键
|
||||
"""
|
||||
with self._lock:
|
||||
self._executed_keys.add(key)
|
||||
|
||||
def clear_expired_keys(self, max_age_seconds: int = 3600):
|
||||
"""
|
||||
清理过期的键(简化实现,实际应该使用时间戳)
|
||||
|
||||
Args:
|
||||
max_age_seconds: 最大年龄(秒)
|
||||
"""
|
||||
# 这里简化实现,实际项目中应该记录时间戳
|
||||
pass
|
||||
|
||||
|
||||
def retry_on_exception(retry_config: Optional[RetryConfig] = None,
|
||||
exceptions: tuple = (Exception,),
|
||||
logger: Optional[Any] = None) -> Callable:
|
||||
"""
|
||||
重试装饰器
|
||||
|
||||
Args:
|
||||
retry_config: 重试配置
|
||||
exceptions: 需要重试的异常类型
|
||||
logger: 日志器
|
||||
|
||||
Returns:
|
||||
Callable: 装饰器函数
|
||||
"""
|
||||
if retry_config is None:
|
||||
retry_config = RetryConfig()
|
||||
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(retry_config.max_retries + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt < retry_config.max_retries:
|
||||
# 计算延迟时间
|
||||
delay = min(
|
||||
retry_config.initial_delay * (retry_config.backoff_factor ** attempt),
|
||||
retry_config.max_delay
|
||||
)
|
||||
|
||||
# 添加随机抖动
|
||||
if retry_config.jitter:
|
||||
delay = delay * (0.5 + random.random() * 0.5)
|
||||
|
||||
logger.warning(
|
||||
f"函数 {func.__name__} 执行失败 (尝试 {attempt + 1}/{retry_config.max_retries + 1}): {str(e)},"
|
||||
f"等待 {delay:.2f} 秒后重试"
|
||||
)
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logger.error(
|
||||
f"函数 {func.__name__} 在 {retry_config.max_retries + 1} 次尝试后仍然失败: {str(e)}"
|
||||
)
|
||||
|
||||
raise last_exception
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def timeout_wrapper(timeout_config: Optional[TimeoutConfig] = None) -> Callable:
|
||||
"""
|
||||
超时装饰器
|
||||
|
||||
Args:
|
||||
timeout_config: 超时配置
|
||||
|
||||
Returns:
|
||||
Callable: 装饰器函数
|
||||
"""
|
||||
if timeout_config is None:
|
||||
timeout_config = TimeoutConfig()
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
import signal
|
||||
|
||||
def timeout_handler(signum, frame):
|
||||
raise TimeoutError(f"函数 {func.__name__} 执行超时")
|
||||
|
||||
# 设置信号处理器
|
||||
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(int(timeout_config.total_timeout))
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
signal.alarm(0) # 取消闹钟
|
||||
return result
|
||||
finally:
|
||||
signal.signal(signal.SIGALRM, old_handler)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_with_retry(timeout_config: Optional[TimeoutConfig] = None,
|
||||
retry_config: Optional[RetryConfig] = None):
|
||||
"""
|
||||
创建带有重试机制的HTTP会话
|
||||
|
||||
Args:
|
||||
timeout_config: 超时配置
|
||||
retry_config: 重试配置
|
||||
|
||||
Yields:
|
||||
requests.Session: 配置好的会话对象
|
||||
"""
|
||||
if timeout_config is None:
|
||||
timeout_config = TimeoutConfig()
|
||||
|
||||
if retry_config is None:
|
||||
retry_config = RetryConfig()
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
# 配置重试策略
|
||||
retry_strategy = Retry(
|
||||
total=retry_config.max_retries,
|
||||
backoff_factor=retry_config.backoff_factor,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
# 设置默认超时
|
||||
session.timeout = (timeout_config.connect_timeout, timeout_config.read_timeout)
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def safe_execute(func: Callable,
|
||||
fallback: Optional[Callable] = None,
|
||||
exceptions: tuple = (Exception,),
|
||||
logger: Optional[Any] = None) -> Any:
|
||||
"""
|
||||
安全执行函数,提供降级处理
|
||||
|
||||
Args:
|
||||
func: 要执行的函数
|
||||
fallback: 降级函数
|
||||
exceptions: 需要捕获的异常类型
|
||||
logger: 日志器
|
||||
|
||||
Returns:
|
||||
Any: 函数执行结果或降级结果
|
||||
"""
|
||||
if logger is None:
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
return func()
|
||||
except exceptions as e:
|
||||
logger.error(f"函数执行失败: {str(e)}")
|
||||
if fallback:
|
||||
try:
|
||||
logger.info("执行降级函数")
|
||||
return fallback()
|
||||
except Exception as fallback_e:
|
||||
logger.error(f"降级函数也执行失败: {str(fallback_e)}")
|
||||
return None
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
"""健康检查器"""
|
||||
|
||||
def __init__(self, check_interval: int = 300):
|
||||
"""
|
||||
初始化健康检查器
|
||||
|
||||
Args:
|
||||
check_interval: 检查间隔(秒)
|
||||
"""
|
||||
self.check_interval = check_interval
|
||||
self.last_check = None
|
||||
self.is_healthy = True
|
||||
self.consecutive_failures = 0
|
||||
self.max_consecutive_failures = 3
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""
|
||||
执行健康检查
|
||||
|
||||
Returns:
|
||||
bool: 健康状态
|
||||
"""
|
||||
current_time = datetime.now()
|
||||
|
||||
# 检查是否需要执行检查
|
||||
if (self.last_check and
|
||||
(current_time - self.last_check).total_seconds() < self.check_interval):
|
||||
return self.is_healthy
|
||||
|
||||
self.last_check = current_time
|
||||
|
||||
try:
|
||||
# 执行健康检查逻辑
|
||||
self._perform_health_check()
|
||||
self.is_healthy = True
|
||||
self.consecutive_failures = 0
|
||||
logger.info("健康检查通过")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.consecutive_failures += 1
|
||||
logger.warning(f"健康检查失败 ({self.consecutive_failures}/{self.max_consecutive_failures}): {str(e)}")
|
||||
|
||||
if self.consecutive_failures >= self.max_consecutive_failures:
|
||||
self.is_healthy = False
|
||||
logger.error("连续健康检查失败,系统标记为不健康")
|
||||
|
||||
return False
|
||||
|
||||
def _perform_health_check(self):
|
||||
"""执行具体的健康检查逻辑"""
|
||||
# 这里可以添加数据库连接检查、外部服务检查等
|
||||
config = get_config()
|
||||
|
||||
# 检查数据库连接(如果启用)
|
||||
if config.database.enabled:
|
||||
# 这里应该检查数据库连接
|
||||
pass
|
||||
|
||||
# 检查网络连接
|
||||
try:
|
||||
requests.get("https://www.baidu.com", timeout=5)
|
||||
except:
|
||||
raise Exception("网络连接检查失败")
|
||||
|
||||
|
||||
# 全局实例
|
||||
_circuit_breaker = CircuitBreaker()
|
||||
_idempotency_manager = IdempotencyManager()
|
||||
_health_checker = HealthChecker()
|
||||
|
||||
|
||||
def get_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取全局熔断器实例"""
|
||||
return _circuit_breaker
|
||||
|
||||
|
||||
def get_idempotency_manager() -> IdempotencyManager:
|
||||
"""获取全局幂等性管理器实例"""
|
||||
return _idempotency_manager
|
||||
|
||||
|
||||
def get_health_checker() -> HealthChecker:
|
||||
"""获取全局健康检查器实例"""
|
||||
return _health_checker
|
||||
|
||||
|
||||
def check_system_health() -> bool:
|
||||
"""
|
||||
检查系统健康状态
|
||||
|
||||
Returns:
|
||||
bool: 系统是否健康
|
||||
"""
|
||||
return _health_checker.check_health()
|
||||
Reference in New Issue
Block a user