4310 lines
163 KiB
Python
4310 lines
163 KiB
Python
# -*- coding: UTF-8 -*-
|
||
'''
|
||
@Project :domainScanDemo
|
||
@File :database.py
|
||
@IDE :PyCharm
|
||
@Author :梦伴
|
||
@Date :2026/4/9 0:07
|
||
@explain : 数据库操作类
|
||
'''
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import socket
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
import psycopg2
|
||
import redis
|
||
from loguru import logger
|
||
from psycopg2 import extensions
|
||
from psycopg2.extras import Json
|
||
from app.config import config
|
||
from app.utils.redis_client import get_redis_client
|
||
from app.utils.status_codes import (
|
||
DETECT_STATUS_BLACKLISTED,
|
||
DETECT_STATUS_COMPLETED,
|
||
DETECT_STATUS_FAILED,
|
||
DETECT_STATUS_PENDING,
|
||
DETECT_STATUS_RUNNING,
|
||
REGISTER_STATUS_AVAILABLE,
|
||
REGISTER_STATUS_REGISTERED,
|
||
REVIEW_STATUS_PENDING,
|
||
THIRD_PARTY_STATUS_DONE,
|
||
)
|
||
|
||
|
||
_POOL_VERBOSE_LOGS = bool(int(os.getenv("DOMAINCHECK_DB_POOL_VERBOSE_LOGS", "0") or 0))
|
||
_DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE = 2000
|
||
_DETECT_JOB_ITEM_RECYCLE_MAX_BATCHES = 6
|
||
_DETECT_JOB_ITEM_RELEASE_BATCH_SIZE = 2000
|
||
_DETECT_JOB_ITEM_RELEASE_MAX_BATCHES = 6
|
||
_RUNTIME_INDEX_BUILD_LOCK_SCOPE = "detect-job-items-runtime-indexes"
|
||
_RUNTIME_REQUIRED_INDEX_DDL = {
|
||
"idx_detect_job_items_job_domain_step": """
|
||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_job_domain_step
|
||
ON detect_job_items(job_id, domain_id, step_code)
|
||
""",
|
||
"idx_detect_job_items_claim_step_ready": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_claim_step_ready
|
||
ON detect_job_items(status, step_code, lease_expires_at, create_time, id)
|
||
WHERE step_code <> '' AND status IN ('pending', 'failed')
|
||
""",
|
||
"idx_detect_job_items_claim_job_step_ready": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_claim_job_step_ready
|
||
ON detect_job_items(job_id, status, step_code, lease_expires_at, create_time, id)
|
||
WHERE step_code <> '' AND status IN ('pending', 'failed')
|
||
""",
|
||
"idx_detect_job_items_stalled_job_activity": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_stalled_job_activity
|
||
ON detect_job_items(
|
||
job_id,
|
||
status,
|
||
(COALESCE(updated_at, started_at, create_time)),
|
||
id
|
||
)
|
||
WHERE status IN ('claimed', 'running')
|
||
""",
|
||
"idx_detect_job_items_release_node_job": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_release_node_job
|
||
ON detect_job_items(claimed_by, job_id, status, id)
|
||
WHERE claimed_by <> ''
|
||
AND status IN ('claimed', 'running')
|
||
""",
|
||
"idx_detect_sync_records_scope_created": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_scope_created
|
||
ON detect_sync_records(sync_type, source_region, target_region, created_at DESC, id DESC)
|
||
""",
|
||
"idx_detect_sync_records_source_record_created": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_source_record_created
|
||
ON detect_sync_records(
|
||
sync_type,
|
||
source_region,
|
||
target_region,
|
||
((payload_json->>'source_record_id')),
|
||
created_at DESC,
|
||
id DESC
|
||
)
|
||
""",
|
||
"idx_detect_sync_records_source_record_hash_created": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_source_record_hash_created
|
||
ON detect_sync_records(
|
||
sync_type,
|
||
source_region,
|
||
target_region,
|
||
((payload_json->>'source_record_id')),
|
||
((payload_json->>'projection_hash')),
|
||
created_at DESC,
|
||
id DESC
|
||
)
|
||
""",
|
||
"idx_detect_sync_records_runtime_push_lookup": """
|
||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_runtime_push_lookup
|
||
ON detect_sync_records(
|
||
source_region,
|
||
target_region,
|
||
((payload_json->>'sync_type')),
|
||
((payload_json->>'source_record_id')),
|
||
created_at DESC,
|
||
id DESC
|
||
)
|
||
WHERE sync_type = 'runtime_push'
|
||
""",
|
||
}
|
||
_RUNTIME_REQUIRED_INDEX_TABLES = {
|
||
"idx_detect_job_items_job_domain_step": "detect_job_items",
|
||
"idx_detect_job_items_claim_step_ready": "detect_job_items",
|
||
"idx_detect_job_items_claim_job_step_ready": "detect_job_items",
|
||
"idx_detect_job_items_stalled_job_activity": "detect_job_items",
|
||
"idx_detect_job_items_release_node_job": "detect_job_items",
|
||
"idx_detect_sync_records_scope_created": "detect_sync_records",
|
||
"idx_detect_sync_records_source_record_created": "detect_sync_records",
|
||
"idx_detect_sync_records_source_record_hash_created": "detect_sync_records",
|
||
"idx_detect_sync_records_runtime_push_lookup": "detect_sync_records",
|
||
}
|
||
|
||
|
||
_STEP_CLAIM_PRIORITY = {
|
||
# Prefer deeper pipeline steps first so domains that already passed an
|
||
# earlier gate can keep advancing instead of being starved behind the
|
||
# oldest first-step backlog. This keeps later steps like 360/chinaz/aizhan
|
||
# from sitting pending forever while baidu/register continue to dominate
|
||
# the queue.
|
||
"detect_juziseo": 10,
|
||
"detect_jucha": 20,
|
||
"detect_wayback": 30,
|
||
"detect_aizhan": 40,
|
||
"detect_chinaz": 50,
|
||
"detect_360_site": 60,
|
||
"detect_baidu_site": 70,
|
||
"detect_register": 100,
|
||
}
|
||
|
||
|
||
def _select_preferred_claim_job_ids(job_rows, *, limit=8, recent_hours=24, now=None):
|
||
safe_limit = max(1, int(limit or 1))
|
||
safe_recent_hours = max(1, int(recent_hours or 1))
|
||
normalized_rows = []
|
||
for row in list(job_rows or []):
|
||
if not isinstance(row, (list, tuple)) or len(row) < 3:
|
||
continue
|
||
try:
|
||
job_id = int(row[0] or 0)
|
||
except Exception:
|
||
continue
|
||
if job_id <= 0:
|
||
continue
|
||
status = str(row[1] or "").strip().lower()
|
||
activity_at = row[2]
|
||
if activity_at is None:
|
||
continue
|
||
reference_now = now
|
||
if reference_now is None:
|
||
reference_now = (
|
||
datetime.now(activity_at.tzinfo)
|
||
if getattr(activity_at, "tzinfo", None) is not None
|
||
else datetime.now()
|
||
)
|
||
if status != "running" and activity_at < reference_now - timedelta(hours=safe_recent_hours):
|
||
continue
|
||
normalized_rows.append((job_id, status, activity_at))
|
||
|
||
normalized_rows.sort(
|
||
key=lambda item: (
|
||
0 if item[1] == "running" else 1,
|
||
-item[2].timestamp(),
|
||
-item[0],
|
||
),
|
||
)
|
||
preferred_job_ids = []
|
||
seen_job_ids = set()
|
||
for job_id, _status, _activity_at in normalized_rows:
|
||
if job_id in seen_job_ids:
|
||
continue
|
||
preferred_job_ids.append(job_id)
|
||
seen_job_ids.add(job_id)
|
||
if len(preferred_job_ids) >= safe_limit:
|
||
break
|
||
return preferred_job_ids
|
||
|
||
|
||
def _build_pg_advisory_lock_key(scope: str) -> int:
|
||
normalized_scope = str(scope or "").strip() or "domaincheck-default"
|
||
digest = hashlib.sha1(normalized_scope.encode("utf-8")).digest()
|
||
raw_value = int.from_bytes(digest[:8], "big", signed=False)
|
||
return raw_value - (1 << 64) if raw_value >= (1 << 63) else raw_value
|
||
|
||
|
||
def _build_claim_token(node_code, thread_id=None):
|
||
normalized_node_code = str(node_code or "").strip() or "unknown"
|
||
normalized_thread_id = int(
|
||
thread_id if thread_id is not None else (threading.current_thread().ident or 0)
|
||
)
|
||
node_fragment = normalized_node_code.replace(" ", "_")[:24]
|
||
thread_fragment = format(normalized_thread_id & 0xFFFFFFFF, "x")
|
||
entropy = (
|
||
f"{normalized_node_code}|{normalized_thread_id}|"
|
||
f"{time.time_ns()}|{uuid.uuid4().hex}"
|
||
)
|
||
digest = hashlib.sha1(entropy.encode("utf-8")).hexdigest()[:24]
|
||
return f"{node_fragment}-{thread_fragment}-{digest}"[:64]
|
||
|
||
|
||
def _read_local_json_config(filename, default):
|
||
for root in _local_config_search_roots():
|
||
candidate = root / str(filename or "").strip()
|
||
try:
|
||
if candidate.exists():
|
||
with candidate.open("r", encoding="utf-8") as handle:
|
||
return json.load(handle)
|
||
except Exception:
|
||
continue
|
||
return default
|
||
|
||
|
||
def _local_config_search_roots():
|
||
seen = set()
|
||
roots = []
|
||
|
||
def append_root(raw_path):
|
||
text = str(raw_path or "").strip()
|
||
if not text:
|
||
return
|
||
path = Path(text).expanduser().resolve()
|
||
key = str(path)
|
||
if key in seen:
|
||
return
|
||
seen.add(key)
|
||
roots.append(path)
|
||
|
||
explicit_root = str(os.getenv("DOMAINCHECK_CONFIG_ROOT", "") or "").strip()
|
||
append_root(explicit_root)
|
||
append_root(Path.cwd())
|
||
|
||
module_root = Path(__file__).resolve().parents[2]
|
||
append_root(module_root)
|
||
|
||
for base_root in list(roots):
|
||
normalized = str(base_root)
|
||
if f"{os.sep}releases{os.sep}" in normalized:
|
||
install_root = normalized.split(f"{os.sep}releases{os.sep}", 1)[0]
|
||
append_root(Path(install_root) / "current" / "domainCheck")
|
||
append_root(Path(install_root) / "domainCheck")
|
||
elif f"{os.sep}current{os.sep}" in normalized:
|
||
install_root = normalized.split(f"{os.sep}current{os.sep}", 1)[0]
|
||
append_root(Path(install_root) / "current" / "domainCheck")
|
||
append_root(Path(install_root) / "domainCheck")
|
||
elif base_root.name == "domainCheck":
|
||
append_root(base_root.parent / "current" / "domainCheck")
|
||
append_root(base_root.parent / "domainCheck")
|
||
|
||
return roots
|
||
|
||
|
||
def _normalize_scaling_int(value, default):
|
||
try:
|
||
normalized = int(value)
|
||
except Exception:
|
||
return int(default)
|
||
return max(1, normalized)
|
||
|
||
|
||
def _resolve_scaling_override(overrides, *candidate_codes):
|
||
if not isinstance(overrides, dict):
|
||
return None
|
||
for candidate in candidate_codes:
|
||
normalized_candidate = str(candidate or "").strip()
|
||
if not normalized_candidate:
|
||
continue
|
||
if normalized_candidate not in overrides:
|
||
continue
|
||
try:
|
||
return max(1, int(overrides.get(normalized_candidate) or 0))
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _load_local_worker_scaling_hints():
|
||
node_code = str(getattr(config, "NODE_CODE", "") or "").strip()
|
||
parent_node_code = str(os.getenv("WORKER_PARENT_NODE_CODE", "") or "").strip()
|
||
|
||
thread_count_payload = _read_local_json_config("thread_count.json", {"thread_count": "1000"})
|
||
node_thread_counts = _read_local_json_config("node_thread_counts.json", {})
|
||
process_count_payload = _read_local_json_config("process_count.json", {"process_count": "1"})
|
||
node_process_counts = _read_local_json_config("node_process_counts.json", {})
|
||
|
||
default_thread_count = _normalize_scaling_int(thread_count_payload.get("thread_count", 1000), 1000)
|
||
default_process_count = _normalize_scaling_int(process_count_payload.get("process_count", 1), 1)
|
||
|
||
effective_thread_count = _resolve_scaling_override(node_thread_counts, node_code, parent_node_code)
|
||
if effective_thread_count is None:
|
||
effective_thread_count = default_thread_count
|
||
|
||
effective_process_count = _resolve_scaling_override(node_process_counts, node_code, parent_node_code)
|
||
if effective_process_count is None:
|
||
effective_process_count = default_process_count
|
||
|
||
return {
|
||
"node_code": node_code,
|
||
"parent_node_code": parent_node_code,
|
||
"thread_count": effective_thread_count,
|
||
"process_count": effective_process_count,
|
||
}
|
||
|
||
|
||
def _is_controller_scaling_hints(scaling_hints):
|
||
normalized_hints = dict(scaling_hints or {})
|
||
node_role = str(getattr(config, "NODE_ROLE", "") or "").strip().lower()
|
||
if node_role == "control":
|
||
return True
|
||
node_code = str(normalized_hints.get("node_code") or "").strip().lower()
|
||
parent_node_code = str(normalized_hints.get("parent_node_code") or "").strip().lower()
|
||
return "controller" in node_code or "controller" in parent_node_code
|
||
|
||
|
||
def _default_pool_total_budget(process_count, scaling_hints=None):
|
||
normalized = max(1, int(process_count or 1))
|
||
if _is_controller_scaling_hints(scaling_hints):
|
||
if normalized >= 96:
|
||
return 1000
|
||
if normalized >= 48:
|
||
return 720
|
||
if normalized >= 24:
|
||
return 480
|
||
if normalized >= 48:
|
||
return 240
|
||
if normalized >= 24:
|
||
return 320
|
||
return 480
|
||
|
||
|
||
def _resolve_db_pool_limits():
|
||
scaling_hints = _load_local_worker_scaling_hints()
|
||
raw_pool_size = str(os.getenv("DB_POOL_SIZE", "") or "").strip()
|
||
raw_pool_warm_size = str(os.getenv("DB_POOL_WARM_SIZE", "") or "").strip()
|
||
raw_pool_idle_keep_max = str(os.getenv("DB_POOL_IDLE_KEEP_MAX", "") or "").strip()
|
||
|
||
effective_process_count = max(1, int(scaling_hints.get("process_count", 1) or 1))
|
||
effective_thread_count = max(1, int(scaling_hints.get("thread_count", 1000) or 1000))
|
||
|
||
if raw_pool_size:
|
||
pool_size = max(1, int(raw_pool_size))
|
||
else:
|
||
default_budget = _default_pool_total_budget(
|
||
effective_process_count,
|
||
scaling_hints=scaling_hints,
|
||
)
|
||
total_budget = max(32, int(os.getenv("DOMAINCHECK_DB_POOL_TOTAL_BUDGET", str(default_budget)) or default_budget))
|
||
per_process_budget = max(4, total_budget // effective_process_count)
|
||
thread_window = max(8, min(24, max(1, effective_thread_count // 96)))
|
||
pool_size = max(4, min(24, min(per_process_budget, thread_window)))
|
||
|
||
if raw_pool_warm_size:
|
||
pool_warm_size = max(1, min(pool_size, int(raw_pool_warm_size)))
|
||
else:
|
||
pool_warm_size = max(1, min(pool_size, max(1, min(4, pool_size // 4))))
|
||
|
||
if raw_pool_idle_keep_max:
|
||
pool_idle_keep_max = max(pool_warm_size, min(pool_size, int(raw_pool_idle_keep_max)))
|
||
else:
|
||
pool_idle_keep_max = max(pool_warm_size, min(pool_size, max(2, pool_size // 2)))
|
||
|
||
return {
|
||
"pool_size": pool_size,
|
||
"pool_warm_size": pool_warm_size,
|
||
"pool_idle_keep_max": pool_idle_keep_max,
|
||
"scaling_hints": scaling_hints,
|
||
}
|
||
|
||
|
||
def _detect_job_item_step_priority(step_code: str) -> int:
|
||
normalized = str(step_code or "").strip()
|
||
if not normalized:
|
||
return 999
|
||
return int(_STEP_CLAIM_PRIORITY.get(normalized, 10))
|
||
|
||
|
||
def _ordered_step_claim_codes() -> list[str]:
|
||
return [
|
||
step_code
|
||
for step_code, _ in sorted(
|
||
_STEP_CLAIM_PRIORITY.items(),
|
||
key=lambda item: (int(item[1] or 0), str(item[0] or "")),
|
||
)
|
||
]
|
||
|
||
|
||
def _resolve_step_claim_quota(limit: int) -> int:
|
||
configured_quota = int(os.getenv("DOMAINCHECK_STEP_CLAIM_QUOTA", "0") or 0)
|
||
if configured_quota > 0:
|
||
return max(1, configured_quota)
|
||
normalized_limit = max(1, int(limit or 1))
|
||
# Large controller pools should be allowed to claim a full-step window;
|
||
# otherwise a 2k-thread worker can get pinned near ~500 inflight items when
|
||
# a single hot step dominates the queue.
|
||
if normalized_limit >= 1024:
|
||
return normalized_limit
|
||
return max(64, min(normalized_limit, max(1, normalized_limit // 4)))
|
||
|
||
|
||
class Database:
|
||
"""
|
||
数据库操作类
|
||
"""
|
||
|
||
def __init__(self, host=None, port=None, database=None, user=None, password=None):
|
||
"""
|
||
初始化数据库连接
|
||
|
||
:param host: 数据库主机
|
||
:param port: 数据库端口
|
||
:param database: 数据库名称
|
||
:param user: 用户名
|
||
:param password: 密码
|
||
"""
|
||
self.host = host or config.DB_HOST
|
||
self.port = port or config.DB_PORT
|
||
self.database = database or config.DB_DATABASE
|
||
self.user = user or config.DB_USER
|
||
self.password = password or config.DB_PASSWORD
|
||
|
||
pool_limits = _resolve_db_pool_limits()
|
||
|
||
# 数据库连接池
|
||
self.connection_pool = []
|
||
self.pool_size = max(1, int(pool_limits["pool_size"])) # 连接池大小
|
||
self.pool_warm_size = max(1, min(self.pool_size, int(pool_limits["pool_warm_size"])))
|
||
self.pool_idle_keep_max = max(
|
||
self.pool_warm_size,
|
||
min(self.pool_size, int(pool_limits["pool_idle_keep_max"])),
|
||
)
|
||
self.pool_healthcheck_interval = max(
|
||
0.0,
|
||
float(getattr(config, 'DB_POOL_HEALTHCHECK_INTERVAL', 30) or 30),
|
||
)
|
||
self.pool_acquire_timeout = max(1.0, float(getattr(config, 'DB_POOL_ACQUIRE_TIMEOUT', 20)))
|
||
self.pool_lock = threading.Lock()
|
||
self.pool_condition = threading.Condition(self.pool_lock)
|
||
self._pool_initialized = False
|
||
self.total_connections = 0
|
||
self._connection_last_healthcheck = {}
|
||
|
||
scaling_hints = dict(pool_limits.get("scaling_hints") or {})
|
||
logger.info(
|
||
"数据库连接池限额: "
|
||
f"node={scaling_hints.get('node_code') or '-'} "
|
||
f"parent={scaling_hints.get('parent_node_code') or '-'} "
|
||
f"process_count={int(scaling_hints.get('process_count', 1) or 1)} "
|
||
f"thread_count={int(scaling_hints.get('thread_count', 0) or 0)} "
|
||
f"pool_size={self.pool_size} warm={self.pool_warm_size} idle_keep={self.pool_idle_keep_max}"
|
||
)
|
||
|
||
# 初始化连接池
|
||
self._init_connection_pool()
|
||
|
||
# 初始化 Redis 客户端
|
||
try:
|
||
self.redis_client = get_redis_client(role="standard")
|
||
# 测试连接
|
||
self.redis_client.ping()
|
||
logger.info(f"Redis 连接成功: {config.REDIS_HOST}:{config.REDIS_PORT}")
|
||
self.use_redis = True
|
||
|
||
# 初始化布隆过滤器
|
||
self._init_bloom_filter()
|
||
except Exception as e:
|
||
logger.warning(f"Redis 连接失败: {e},将使用数据库查询")
|
||
self.redis_client = None
|
||
self.use_redis = False
|
||
self.use_bloom_filter = False
|
||
self._active_detect_job_cache_lock = threading.Lock()
|
||
self._active_detect_job_cache_payload = None
|
||
self._active_detect_job_cache_fresh_until = 0.0
|
||
self._active_detect_job_cache_stale_until = 0.0
|
||
|
||
def _clone_active_detect_job_payload(self, payload):
|
||
if isinstance(payload, dict):
|
||
return dict(payload)
|
||
return payload
|
||
|
||
def _ensure_active_detect_job_cache_state(self):
|
||
if not hasattr(self, "_active_detect_job_cache_lock") or self._active_detect_job_cache_lock is None:
|
||
self._active_detect_job_cache_lock = threading.Lock()
|
||
if not hasattr(self, "_active_detect_job_cache_payload"):
|
||
self._active_detect_job_cache_payload = None
|
||
if not hasattr(self, "_active_detect_job_cache_fresh_until"):
|
||
self._active_detect_job_cache_fresh_until = 0.0
|
||
if not hasattr(self, "_active_detect_job_cache_stale_until"):
|
||
self._active_detect_job_cache_stale_until = 0.0
|
||
|
||
def _get_local_active_detect_job_cache(self, now_ts: float, *, allow_stale: bool = False):
|
||
self._ensure_active_detect_job_cache_state()
|
||
with self._active_detect_job_cache_lock:
|
||
payload = self._clone_active_detect_job_payload(self._active_detect_job_cache_payload)
|
||
fresh_until = float(getattr(self, "_active_detect_job_cache_fresh_until", 0.0) or 0.0)
|
||
stale_until = float(getattr(self, "_active_detect_job_cache_stale_until", 0.0) or 0.0)
|
||
if payload is None:
|
||
return None
|
||
if now_ts <= fresh_until:
|
||
return payload
|
||
if allow_stale and now_ts <= stale_until:
|
||
return payload
|
||
return None
|
||
|
||
def _set_local_active_detect_job_cache(self, payload, *, now_ts: float, fresh_ttl_seconds: int, stale_ttl_seconds: int):
|
||
self._ensure_active_detect_job_cache_state()
|
||
fresh_until = now_ts + max(1, int(fresh_ttl_seconds or 1))
|
||
stale_until = fresh_until + max(0, int(stale_ttl_seconds or 0))
|
||
with self._active_detect_job_cache_lock:
|
||
self._active_detect_job_cache_payload = self._clone_active_detect_job_payload(payload)
|
||
self._active_detect_job_cache_fresh_until = fresh_until
|
||
self._active_detect_job_cache_stale_until = stale_until
|
||
|
||
def _read_cached_active_detect_job_from_redis(self, cache_key: str):
|
||
if not self.redis_client:
|
||
return None
|
||
try:
|
||
cached_payload = self.redis_client.get(cache_key)
|
||
if cached_payload:
|
||
cached = json.loads(cached_payload)
|
||
if isinstance(cached, dict):
|
||
return cached
|
||
except Exception as e:
|
||
logger.debug(f"读取活动任务缓存失败: {e}")
|
||
return None
|
||
|
||
def _init_connection_pool(self):
|
||
"""
|
||
初始化数据库连接池
|
||
"""
|
||
try:
|
||
with self.pool_condition:
|
||
missing = self.pool_warm_size - self.total_connections
|
||
if missing <= 0:
|
||
return
|
||
|
||
created = []
|
||
for i in range(missing):
|
||
created.append(self._create_connection())
|
||
|
||
with self.pool_condition:
|
||
self.connection_pool.extend(created)
|
||
self.total_connections += len(created)
|
||
self._pool_initialized = True
|
||
now = time.monotonic()
|
||
for conn in created:
|
||
self._connection_last_healthcheck[id(conn)] = now
|
||
self.pool_condition.notify_all()
|
||
logger.info(
|
||
f"数据库连接池初始化成功,预热: {len(created)},池中空闲: {len(self.connection_pool)},总连接: {self.total_connections}/{self.pool_size}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"初始化数据库连接池失败: {e}")
|
||
|
||
def _create_connection(self):
|
||
return psycopg2.connect(
|
||
host=self.host,
|
||
port=self.port,
|
||
database=self.database,
|
||
user=self.user,
|
||
password=self.password,
|
||
connect_timeout=5,
|
||
application_name="domaincheck-worker",
|
||
)
|
||
|
||
def _prepare_pooled_connection(self, conn):
|
||
if not conn or conn.closed:
|
||
return False
|
||
try:
|
||
if conn.get_transaction_status() != extensions.TRANSACTION_STATUS_IDLE:
|
||
conn.rollback()
|
||
last_healthcheck = float(self._connection_last_healthcheck.get(id(conn), 0.0) or 0.0)
|
||
now = time.monotonic()
|
||
if self.pool_healthcheck_interval > 0 and now - last_healthcheck >= self.pool_healthcheck_interval:
|
||
cur = conn.cursor()
|
||
try:
|
||
cur.execute("SELECT 1")
|
||
cur.fetchone()
|
||
finally:
|
||
try:
|
||
cur.close()
|
||
except Exception:
|
||
pass
|
||
self._connection_last_healthcheck[id(conn)] = now
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _discard_connection(self, conn):
|
||
conn_id = id(conn) if conn is not None else 0
|
||
try:
|
||
if conn and not conn.closed:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
with self.pool_condition:
|
||
self._connection_last_healthcheck.pop(conn_id, None)
|
||
self.total_connections = max(0, self.total_connections - 1)
|
||
self.pool_condition.notify()
|
||
|
||
def _init_bloom_filter(self):
|
||
"""
|
||
初始化布隆过滤器
|
||
"""
|
||
try:
|
||
# 检查 Redis 是否支持布隆过滤器
|
||
# 如果不支持,将使用普通缓存
|
||
try:
|
||
# 尝试创建布隆过滤器
|
||
self.redis_client.execute_command('BF.RESERVE', 'domain_bloom', 0.001, 1073741824)
|
||
logger.info("布隆过滤器初始化成功")
|
||
self.use_bloom_filter = True
|
||
except Exception as e:
|
||
# 检查是否是因为布隆过滤器已存在
|
||
if "item exists" in str(e):
|
||
logger.info("布隆过滤器已存在,直接使用")
|
||
self.use_bloom_filter = True
|
||
else:
|
||
# 如果命令不存在,说明 Redis 没有加载布隆过滤器模块
|
||
logger.info(f"Redis 布隆过滤器不可用: {e},将使用普通缓存")
|
||
self.use_bloom_filter = False
|
||
except Exception as e:
|
||
logger.warning(f"初始化布隆过滤器失败: {e}")
|
||
self.use_bloom_filter = False
|
||
|
||
def connect(self, thread_id=None):
|
||
"""
|
||
从连接池获取数据库连接
|
||
|
||
:param thread_id: 线程ID,为None时使用当前线程ID
|
||
:return: tuple - (连接对象, 游标对象)
|
||
"""
|
||
import threading
|
||
thread_id = thread_id or threading.current_thread().ident
|
||
|
||
try:
|
||
if not self._pool_initialized:
|
||
self._init_connection_pool()
|
||
|
||
deadline = time.monotonic() + self.pool_acquire_timeout
|
||
warned_pool_empty = False
|
||
|
||
while True:
|
||
create_new = False
|
||
pooled_conn = None
|
||
with self.pool_condition:
|
||
while self.connection_pool:
|
||
pooled_conn = self.connection_pool.pop()
|
||
if pooled_conn:
|
||
break
|
||
|
||
if pooled_conn is None and self.total_connections < self.pool_size:
|
||
self.total_connections += 1
|
||
create_new = True
|
||
elif pooled_conn is None:
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
logger.warning(
|
||
f"连接池耗尽,线程 {thread_id} 等待超时 {self.pool_acquire_timeout}s,返回空连接"
|
||
)
|
||
return None, None
|
||
|
||
if not warned_pool_empty:
|
||
warned_pool_empty = True
|
||
logger.warning(
|
||
f"连接池耗尽,线程 {thread_id} 等待可复用连接,池大小 {self.pool_size}"
|
||
)
|
||
self.pool_condition.wait(timeout=min(0.5, remaining))
|
||
|
||
if pooled_conn is not None:
|
||
if self._prepare_pooled_connection(pooled_conn):
|
||
if _POOL_VERBOSE_LOGS:
|
||
logger.debug(f"线程 {thread_id} 从连接池获取连接成功")
|
||
return pooled_conn, pooled_conn.cursor()
|
||
self._discard_connection(pooled_conn)
|
||
continue
|
||
|
||
if create_new:
|
||
try:
|
||
conn = self._create_connection()
|
||
self._connection_last_healthcheck[id(conn)] = time.monotonic()
|
||
if _POOL_VERBOSE_LOGS:
|
||
logger.debug(f"线程 {thread_id} 新建数据库连接成功,总连接 {self.total_connections}/{self.pool_size}")
|
||
return conn, conn.cursor()
|
||
except Exception as e:
|
||
with self.pool_condition:
|
||
self.total_connections = max(0, self.total_connections - 1)
|
||
self.pool_condition.notify()
|
||
logger.error(f"线程 {thread_id} 新建数据库连接失败: {e}")
|
||
return None, None
|
||
except Exception as e:
|
||
logger.error(f"线程 {thread_id} 获取数据库连接失败: {e}")
|
||
return None, None
|
||
|
||
def get_connection(self):
|
||
"""
|
||
获取数据库连接(兼容方法)
|
||
|
||
:return: 连接对象
|
||
"""
|
||
conn, _ = self.connect()
|
||
return conn
|
||
|
||
def close(self, conn=None, cur=None):
|
||
"""
|
||
将数据库连接放回连接池
|
||
|
||
:param conn: 连接对象
|
||
:param cur: 游标对象
|
||
"""
|
||
try:
|
||
if cur:
|
||
try:
|
||
cur.close()
|
||
except:
|
||
pass
|
||
|
||
if conn and not conn.closed:
|
||
try:
|
||
if conn.get_transaction_status() != extensions.TRANSACTION_STATUS_IDLE:
|
||
conn.rollback()
|
||
except Exception:
|
||
self._discard_connection(conn)
|
||
logger.warning("连接归还前回滚失败,已关闭连接")
|
||
return
|
||
close_conn = False
|
||
conn_id = id(conn)
|
||
with self.pool_condition:
|
||
if len(self.connection_pool) < self.pool_idle_keep_max:
|
||
self.connection_pool.append(conn)
|
||
self.pool_condition.notify()
|
||
if _POOL_VERBOSE_LOGS:
|
||
logger.debug("连接已放回连接池")
|
||
else:
|
||
# 启动阶段只预热少量连接,高并发跑起来后允许保留更多空闲连接,
|
||
# 避免本地数据库在“建连/关连”之间来回抖动。
|
||
self._connection_last_healthcheck.pop(conn_id, None)
|
||
self.total_connections = max(0, self.total_connections - 1)
|
||
self.pool_condition.notify()
|
||
close_conn = True
|
||
if _POOL_VERBOSE_LOGS:
|
||
logger.debug(f"空闲连接超过保留阈值({self.pool_idle_keep_max}),已关闭多余连接")
|
||
if close_conn:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.error(f"关闭数据库连接失败: {e}")
|
||
try:
|
||
if conn and not conn.closed:
|
||
self._connection_last_healthcheck.pop(id(conn), None)
|
||
conn.close()
|
||
except:
|
||
pass
|
||
|
||
def get_sensitive_words(self):
|
||
"""
|
||
获取所有敏感词
|
||
|
||
:return: list - 敏感词列表
|
||
"""
|
||
try:
|
||
sql = "SELECT word, category, priority FROM sensitive_words ORDER BY priority DESC, word ASC"
|
||
result = self.fetch_all(sql)
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"获取敏感词失败: {e}")
|
||
return []
|
||
|
||
def add_sensitive_word(self, word, category='default', priority=1):
|
||
"""
|
||
添加敏感词
|
||
|
||
:param word: 敏感词
|
||
:param category: 分类
|
||
:param priority: 优先级
|
||
:return: bool - 是否成功
|
||
"""
|
||
try:
|
||
sql = "INSERT INTO sensitive_words (word, category, priority) VALUES (%s, %s, %s) ON CONFLICT (word) DO NOTHING"
|
||
return self.execute(sql, (word, category, priority))
|
||
except Exception as e:
|
||
logger.error(f"添加敏感词失败: {word}, 错误: {e}")
|
||
return False
|
||
|
||
def delete_sensitive_word(self, word):
|
||
"""
|
||
删除敏感词
|
||
|
||
:param word: 敏感词
|
||
:return: bool - 是否成功
|
||
"""
|
||
try:
|
||
sql = "DELETE FROM sensitive_words WHERE word = %s"
|
||
return self.execute(sql, (word,))
|
||
except Exception as e:
|
||
logger.error(f"删除敏感词失败: {word}, 错误: {e}")
|
||
return False
|
||
|
||
def update_sensitive_word(self, old_word, new_word, category=None, priority=None):
|
||
"""
|
||
更新敏感词
|
||
|
||
:param old_word: 旧敏感词
|
||
:param new_word: 新敏感词
|
||
:param category: 分类
|
||
:param priority: 优先级
|
||
:return: bool - 是否成功
|
||
"""
|
||
try:
|
||
if category is not None and priority is not None:
|
||
sql = "UPDATE sensitive_words SET word = %s, category = %s, priority = %s WHERE word = %s"
|
||
return self.execute(sql, (new_word, category, priority, old_word))
|
||
elif category is not None:
|
||
sql = "UPDATE sensitive_words SET word = %s, category = %s WHERE word = %s"
|
||
return self.execute(sql, (new_word, category, old_word))
|
||
elif priority is not None:
|
||
sql = "UPDATE sensitive_words SET word = %s, priority = %s WHERE word = %s"
|
||
return self.execute(sql, (new_word, priority, old_word))
|
||
else:
|
||
sql = "UPDATE sensitive_words SET word = %s WHERE word = %s"
|
||
return self.execute(sql, (new_word, old_word))
|
||
except Exception as e:
|
||
logger.error(f"更新敏感词失败: {old_word} -> {new_word}, 错误: {e}")
|
||
return False
|
||
|
||
def batch_add_sensitive_words(self, words):
|
||
"""
|
||
批量添加敏感词
|
||
|
||
:param words: 敏感词列表,每个元素是 (word, category, priority) 元组
|
||
:return: bool - 是否成功
|
||
"""
|
||
try:
|
||
if not words:
|
||
return True
|
||
|
||
sql = "INSERT INTO sensitive_words (word, category, priority) VALUES (%s, %s, %s) ON CONFLICT (word) DO NOTHING"
|
||
return self.execute_many(sql, words)
|
||
except Exception as e:
|
||
logger.error(f"批量添加敏感词失败: {e}")
|
||
return False
|
||
|
||
def execute(self, sql, params=None):
|
||
"""
|
||
执行SQL语句
|
||
|
||
:param sql: SQL语句
|
||
:param params: 参数
|
||
:return: 执行结果
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
logger.error(f"线程 {thread_id} 数据库连接失败,无法执行SQL")
|
||
return False
|
||
|
||
cur.execute(sql, params)
|
||
conn.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"执行SQL失败: {sql}, 错误: {e}")
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except:
|
||
pass
|
||
return False
|
||
finally:
|
||
# 将连接放回连接池
|
||
self.close(conn, cur)
|
||
|
||
def execute_many(self, sql, params_list):
|
||
"""
|
||
批量执行SQL语句
|
||
|
||
:param sql: SQL语句
|
||
:param params_list: 参数列表
|
||
:return: 执行结果
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
logger.error(f"线程 {thread_id} 数据库连接失败,无法执行批量SQL")
|
||
return False
|
||
|
||
cur.executemany(sql, params_list)
|
||
conn.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"执行批量SQL失败: {sql}, 错误: {e}")
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except:
|
||
pass
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def ensure_cluster_runtime_tables(self):
|
||
if self._cluster_runtime_schema_ready():
|
||
return True
|
||
if self._cluster_runtime_schema_basics_present():
|
||
if not self._runtime_index_repair_enabled():
|
||
logger.warning("多机运行库索引存在缺口/无效,默认跳过自动DDL修复")
|
||
return False
|
||
self._ensure_cluster_runtime_indexes()
|
||
return self._cluster_runtime_schema_ready()
|
||
sql = """
|
||
CREATE TABLE IF NOT EXISTS detect_worker_nodes (
|
||
node_code VARCHAR(64) PRIMARY KEY,
|
||
region VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||
role VARCHAR(32) NOT NULL DEFAULT 'worker',
|
||
hostname VARCHAR(255) NOT NULL DEFAULT '',
|
||
ip VARCHAR(64) NOT NULL DEFAULT '',
|
||
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||
worker_version VARCHAR(32) NOT NULL DEFAULT '',
|
||
current_load INTEGER NOT NULL DEFAULT 0,
|
||
metadata_json JSONB,
|
||
last_heartbeat_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS detect_jobs (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
job_code VARCHAR(64) NOT NULL UNIQUE,
|
||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||
plan_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||
task_mode VARCHAR(32) NOT NULL DEFAULT 'domain_pipeline',
|
||
step_code VARCHAR(64) NOT NULL DEFAULT '',
|
||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||
remark TEXT NOT NULL DEFAULT '',
|
||
created_by VARCHAR(64) NOT NULL DEFAULT '',
|
||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
started_at TIMESTAMP,
|
||
finished_at TIMESTAMP
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS detect_job_items (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
job_id BIGINT NOT NULL REFERENCES detect_jobs(id) ON DELETE CASCADE,
|
||
domain_id BIGINT NOT NULL,
|
||
step_code VARCHAR(64) NOT NULL DEFAULT '',
|
||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||
claimed_by VARCHAR(64) NOT NULL DEFAULT '',
|
||
claim_token VARCHAR(64) NOT NULL DEFAULT '',
|
||
lease_expires_at TIMESTAMP,
|
||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||
last_error TEXT NOT NULL DEFAULT '',
|
||
result_version VARCHAR(64) NOT NULL DEFAULT '',
|
||
step_payload_json JSONB,
|
||
result_payload_json JSONB,
|
||
started_at TIMESTAMP,
|
||
finished_at TIMESTAMP,
|
||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_status_lease
|
||
ON detect_job_items(status, lease_expires_at);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_ready
|
||
ON detect_job_items(status, create_time, id)
|
||
WHERE step_code <> '';
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_job_ready
|
||
ON detect_job_items(job_id, status, create_time, id)
|
||
WHERE step_code <> '';
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_step_ready
|
||
ON detect_job_items(status, step_code, lease_expires_at, create_time, id)
|
||
WHERE step_code <> '' AND status IN ('pending', 'failed');
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_job_step_ready
|
||
ON detect_job_items(job_id, status, step_code, lease_expires_at, create_time, id)
|
||
WHERE step_code <> '' AND status IN ('pending', 'failed');
|
||
|
||
ALTER TABLE detect_jobs
|
||
ADD COLUMN IF NOT EXISTS task_mode VARCHAR(32) NOT NULL DEFAULT 'domain_pipeline',
|
||
ADD COLUMN IF NOT EXISTS step_code VARCHAR(64) NOT NULL DEFAULT '';
|
||
|
||
ALTER TABLE detect_job_items
|
||
ADD COLUMN IF NOT EXISTS step_code VARCHAR(64) NOT NULL DEFAULT '',
|
||
ADD COLUMN IF NOT EXISTS step_payload_json JSONB,
|
||
ADD COLUMN IF NOT EXISTS result_payload_json JSONB;
|
||
|
||
ALTER TABLE detect_job_items
|
||
DROP CONSTRAINT IF EXISTS uq_detect_job_items_job_domain;
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_detect_job_items_job_domain_step
|
||
ON detect_job_items(job_id, domain_id, step_code);
|
||
|
||
CREATE TABLE IF NOT EXISTS detect_run_events (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
job_id BIGINT REFERENCES detect_jobs(id) ON DELETE SET NULL,
|
||
job_item_id BIGINT REFERENCES detect_job_items(id) ON DELETE SET NULL,
|
||
node_code VARCHAR(64) NOT NULL DEFAULT '',
|
||
event_type VARCHAR(64) NOT NULL DEFAULT '',
|
||
level VARCHAR(16) NOT NULL DEFAULT 'info',
|
||
message TEXT NOT NULL DEFAULT '',
|
||
payload_json JSONB,
|
||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS detect_sync_records (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
sync_type VARCHAR(32) NOT NULL DEFAULT '',
|
||
source_region VARCHAR(32) NOT NULL DEFAULT '',
|
||
target_region VARCHAR(32) NOT NULL DEFAULT '',
|
||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||
payload_json JSONB,
|
||
error_message TEXT NOT NULL DEFAULT '',
|
||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
"""
|
||
return self.execute(sql)
|
||
|
||
def _cluster_runtime_schema_basics_present(self):
|
||
sql = """
|
||
SELECT
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_worker_nodes'
|
||
) AS has_detect_worker_nodes,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_jobs'
|
||
) AS has_detect_jobs,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_job_items'
|
||
) AS has_detect_job_items,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_run_events'
|
||
) AS has_detect_run_events,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_sync_records'
|
||
) AS has_detect_sync_records,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_jobs'
|
||
AND column_name = 'task_mode'
|
||
) AS has_detect_jobs_task_mode,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_jobs'
|
||
AND column_name = 'step_code'
|
||
) AS has_detect_jobs_step_code,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_job_items'
|
||
AND column_name = 'step_code'
|
||
) AS has_detect_job_items_step_code,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_job_items'
|
||
AND column_name = 'step_payload_json'
|
||
) AS has_detect_job_items_step_payload,
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'detect_job_items'
|
||
AND column_name = 'result_payload_json'
|
||
) AS has_detect_job_items_result_payload
|
||
"""
|
||
try:
|
||
row = self.fetch_one(sql)
|
||
except Exception as e:
|
||
logger.debug(f"检测多机运行库 schema 状态失败: {e}")
|
||
return False
|
||
if isinstance(row, dict):
|
||
return all(bool(value) for value in row.values())
|
||
if isinstance(row, (list, tuple)):
|
||
return all(bool(value) for value in row)
|
||
return False
|
||
|
||
def _cluster_runtime_missing_indexes(self):
|
||
table_names = sorted(set(_RUNTIME_REQUIRED_INDEX_TABLES.values()))
|
||
sql = """
|
||
SELECT
|
||
idx.relname AS index_name,
|
||
pg_index.indisvalid AS is_valid
|
||
FROM pg_class AS idx
|
||
JOIN pg_index ON pg_index.indexrelid = idx.oid
|
||
JOIN pg_class AS tbl ON tbl.oid = pg_index.indrelid
|
||
JOIN pg_namespace AS ns ON ns.oid = tbl.relnamespace
|
||
WHERE ns.nspname = current_schema()
|
||
AND tbl.relname = ANY(%s)
|
||
AND idx.relname = ANY(%s)
|
||
"""
|
||
try:
|
||
rows = self.fetch_all(sql, (table_names, list(_RUNTIME_REQUIRED_INDEX_DDL.keys()),))
|
||
except Exception as e:
|
||
logger.debug(f"检测多机运行库索引状态失败: {e}")
|
||
return list(_RUNTIME_REQUIRED_INDEX_DDL.keys())
|
||
states = {
|
||
index_name: False
|
||
for index_name in _RUNTIME_REQUIRED_INDEX_DDL.keys()
|
||
}
|
||
for row in list(rows or []):
|
||
index_name = str((row or {}).get("index_name") or "").strip()
|
||
if index_name in states:
|
||
states[index_name] = bool((row or {}).get("is_valid"))
|
||
return [index_name for index_name, is_valid in states.items() if not is_valid]
|
||
|
||
def _cluster_runtime_invalid_indexes(self):
|
||
table_names = sorted(set(_RUNTIME_REQUIRED_INDEX_TABLES.values()))
|
||
sql = """
|
||
SELECT
|
||
idx.relname AS index_name
|
||
FROM pg_class AS idx
|
||
JOIN pg_index ON pg_index.indexrelid = idx.oid
|
||
JOIN pg_class AS tbl ON tbl.oid = pg_index.indrelid
|
||
JOIN pg_namespace AS ns ON ns.oid = tbl.relnamespace
|
||
WHERE ns.nspname = current_schema()
|
||
AND tbl.relname = ANY(%s)
|
||
AND idx.relname = ANY(%s)
|
||
AND NOT pg_index.indisvalid
|
||
"""
|
||
try:
|
||
rows = self.fetch_all(sql, (table_names, list(_RUNTIME_REQUIRED_INDEX_DDL.keys()),))
|
||
except Exception as e:
|
||
logger.debug(f"检测多机运行库无效索引状态失败: {e}")
|
||
return []
|
||
return [
|
||
str((row or {}).get("index_name") or "").strip()
|
||
for row in list(rows or [])
|
||
if str((row or {}).get("index_name") or "").strip() in _RUNTIME_REQUIRED_INDEX_DDL
|
||
]
|
||
|
||
def _runtime_index_repair_enabled(self):
|
||
raw = str(os.getenv("DOMAINCHECK_RUNTIME_INDEX_REPAIR_ENABLED", "") or "").strip().lower()
|
||
return raw in {"1", "true", "yes", "on"}
|
||
|
||
def _ensure_cluster_runtime_indexes(self):
|
||
missing_indexes = self._cluster_runtime_missing_indexes()
|
||
if not missing_indexes:
|
||
return True
|
||
|
||
conn = None
|
||
cur = None
|
||
lock_acquired = False
|
||
try:
|
||
conn = self._create_connection()
|
||
conn.autocommit = True
|
||
cur = conn.cursor()
|
||
cur.execute("SELECT pg_try_advisory_lock(%s)", (_build_pg_advisory_lock_key(_RUNTIME_INDEX_BUILD_LOCK_SCOPE),))
|
||
lock_row = cur.fetchone()
|
||
lock_acquired = bool((lock_row or [False])[0])
|
||
if not lock_acquired:
|
||
logger.info("多机运行库索引补齐进行中,当前进程跳过重复建索引")
|
||
return False
|
||
|
||
current_missing = set(self._cluster_runtime_missing_indexes())
|
||
current_invalid = set(self._cluster_runtime_invalid_indexes())
|
||
for index_name in missing_indexes:
|
||
if index_name not in current_missing:
|
||
continue
|
||
ddl = str(_RUNTIME_REQUIRED_INDEX_DDL.get(index_name) or "").strip()
|
||
if not ddl:
|
||
continue
|
||
if index_name in current_invalid:
|
||
if not self._runtime_index_repair_enabled():
|
||
logger.warning(f"检测到无效多机运行库索引,跳过自动重建: {index_name}")
|
||
continue
|
||
logger.warning(f"检测到无效多机运行库索引,准备重建: {index_name}")
|
||
cur.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {index_name}")
|
||
logger.info(f"补齐多机运行库索引: {index_name}")
|
||
cur.execute(ddl)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"补齐多机运行库索引失败: {e}")
|
||
return False
|
||
finally:
|
||
if cur and lock_acquired:
|
||
try:
|
||
cur.execute("SELECT pg_advisory_unlock(%s)", (_build_pg_advisory_lock_key(_RUNTIME_INDEX_BUILD_LOCK_SCOPE),))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if cur:
|
||
cur.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if conn:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _cluster_runtime_schema_ready(self):
|
||
return self._cluster_runtime_schema_basics_present() and not self._cluster_runtime_missing_indexes()
|
||
|
||
def register_cluster_node(self, node_code, region, role, status='online', current_load=0, metadata=None):
|
||
try:
|
||
ip_addr = ''
|
||
try:
|
||
ip_addr = socket.gethostbyname(socket.gethostname())
|
||
except Exception:
|
||
pass
|
||
sql = """
|
||
INSERT INTO detect_worker_nodes (
|
||
node_code, region, role, hostname, ip, status, worker_version, current_load, metadata_json, last_heartbeat_at, update_time
|
||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||
ON CONFLICT (node_code) DO UPDATE SET
|
||
region = EXCLUDED.region,
|
||
role = EXCLUDED.role,
|
||
hostname = EXCLUDED.hostname,
|
||
ip = EXCLUDED.ip,
|
||
status = EXCLUDED.status,
|
||
worker_version = EXCLUDED.worker_version,
|
||
current_load = EXCLUDED.current_load,
|
||
metadata_json = EXCLUDED.metadata_json,
|
||
last_heartbeat_at = CURRENT_TIMESTAMP,
|
||
update_time = CURRENT_TIMESTAMP
|
||
"""
|
||
return self.execute(
|
||
sql,
|
||
(
|
||
node_code,
|
||
region,
|
||
role,
|
||
socket.gethostname(),
|
||
ip_addr,
|
||
status,
|
||
'0.1.0',
|
||
int(current_load or 0),
|
||
json.dumps(metadata or {}, ensure_ascii=False),
|
||
),
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"注册检测节点失败: {e}")
|
||
return False
|
||
|
||
def claim_detect_job_items(
|
||
self,
|
||
node_code,
|
||
limit=1000,
|
||
lease_seconds=3600,
|
||
job_id=None,
|
||
*,
|
||
prefer_recent_jobs=False,
|
||
preferred_recent_job_limit=8,
|
||
preferred_recent_job_window_hours=24,
|
||
):
|
||
"""
|
||
领取一批待执行的任务项。
|
||
"""
|
||
conn = None
|
||
cur = None
|
||
claim_token = _build_claim_token(node_code)
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
logger.error("领取检测任务失败: 无法获取数据库连接")
|
||
return []
|
||
normalized_job_id = int(job_id) if job_id not in (None, "", 0, "0") else None
|
||
normalized_limit = max(1, int(limit or 1))
|
||
normalized_lease_seconds = max(60, int(lease_seconds or 3600))
|
||
step_claim_quota = _resolve_step_claim_quota(normalized_limit)
|
||
rows = []
|
||
preferred_job_ids = []
|
||
|
||
if normalized_job_id is None and bool(prefer_recent_jobs):
|
||
cur.execute(
|
||
"""
|
||
SELECT id, status, COALESCE(started_at, created_at) AS activity_at
|
||
FROM detect_jobs
|
||
WHERE status IN ('pending', 'running')
|
||
ORDER BY
|
||
COALESCE(started_at, created_at) DESC,
|
||
CASE WHEN status = 'running' THEN 0 ELSE 1 END ASC,
|
||
id DESC
|
||
LIMIT %s
|
||
""",
|
||
(max(8, int(preferred_recent_job_limit or 8) * 4),),
|
||
)
|
||
preferred_job_ids = _select_preferred_claim_job_ids(
|
||
cur.fetchall() or [],
|
||
limit=preferred_recent_job_limit,
|
||
recent_hours=preferred_recent_job_window_hours,
|
||
)
|
||
|
||
def _claim_rows(
|
||
batch_limit: int,
|
||
step_condition_sql: str = "",
|
||
step_condition_params: tuple = (),
|
||
job_scope_sql: str = "",
|
||
job_scope_params: tuple = (),
|
||
) -> list:
|
||
safe_batch_limit = max(1, int(batch_limit or 0))
|
||
if safe_batch_limit <= 0:
|
||
return []
|
||
extra_condition = f"\n AND {step_condition_sql}" if step_condition_sql else ""
|
||
job_scope_clause = f"\n AND {job_scope_sql}" if job_scope_sql else ""
|
||
cur.execute(
|
||
f"""
|
||
WITH picked AS (
|
||
SELECT id
|
||
FROM detect_job_items
|
||
WHERE status IN ('pending', 'failed')
|
||
AND step_code <> ''
|
||
{job_scope_clause}
|
||
AND (lease_expires_at IS NULL OR lease_expires_at < CURRENT_TIMESTAMP){extra_condition}
|
||
ORDER BY create_time ASC, id ASC
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT %s
|
||
),
|
||
updated AS (
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'claimed',
|
||
claimed_by = %s,
|
||
claim_token = %s,
|
||
lease_expires_at = CURRENT_TIMESTAMP + (%s || ' seconds')::interval,
|
||
attempt_count = item.attempt_count + 1,
|
||
started_at = COALESCE(item.started_at, CURRENT_TIMESTAMP),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
FROM picked
|
||
WHERE item.id = picked.id
|
||
RETURNING item.id, item.job_id, item.domain_id, item.claim_token, item.step_code, item.step_payload_json
|
||
)
|
||
SELECT
|
||
updated.id,
|
||
updated.job_id,
|
||
updated.domain_id,
|
||
updated.claim_token,
|
||
updated.step_code,
|
||
detect_jobs.task_mode,
|
||
detect_jobs.job_code,
|
||
detect_jobs.step_code,
|
||
updated.step_payload_json,
|
||
domains.domain,
|
||
domains.source_type,
|
||
domains.register_status,
|
||
domains.detect_status,
|
||
domains.use_status,
|
||
domains.expire_date,
|
||
domains.jucha_status,
|
||
domains.juziseo_status
|
||
FROM updated
|
||
JOIN detect_jobs ON detect_jobs.id = updated.job_id
|
||
JOIN domains ON domains.id = updated.domain_id
|
||
ORDER BY updated.id ASC
|
||
""",
|
||
(
|
||
*job_scope_params,
|
||
*step_condition_params,
|
||
safe_batch_limit,
|
||
node_code,
|
||
claim_token,
|
||
normalized_lease_seconds,
|
||
),
|
||
)
|
||
return cur.fetchall() or []
|
||
|
||
def _claim_with_job_scope(job_scope_sql: str = "", job_scope_params: tuple = ()) -> list:
|
||
scoped_rows = []
|
||
remaining = normalized_limit
|
||
for step_code in _ordered_step_claim_codes():
|
||
if remaining <= 0:
|
||
break
|
||
claimed_rows = _claim_rows(
|
||
min(remaining, step_claim_quota),
|
||
"COALESCE(step_code, '') = %s",
|
||
(str(step_code or "").strip(),),
|
||
job_scope_sql,
|
||
job_scope_params,
|
||
)
|
||
if claimed_rows:
|
||
scoped_rows.extend(claimed_rows)
|
||
remaining -= len(claimed_rows)
|
||
if remaining > 0:
|
||
scoped_rows.extend(_claim_rows(remaining, job_scope_sql=job_scope_sql, job_scope_params=job_scope_params))
|
||
return scoped_rows
|
||
|
||
if normalized_job_id is not None:
|
||
rows.extend(_claim_with_job_scope("job_id = %s", (normalized_job_id,)))
|
||
else:
|
||
if preferred_job_ids:
|
||
rows.extend(_claim_with_job_scope("job_id = ANY(%s)", (preferred_job_ids,)))
|
||
remaining = max(0, normalized_limit - len(rows))
|
||
if remaining > 0:
|
||
original_limit = normalized_limit
|
||
try:
|
||
normalized_limit = remaining
|
||
rows.extend(_claim_with_job_scope())
|
||
finally:
|
||
normalized_limit = original_limit
|
||
|
||
conn.commit()
|
||
return [
|
||
{
|
||
"job_item_id": row[0],
|
||
"job_id": row[1],
|
||
"id": row[2],
|
||
"claim_token": row[3],
|
||
"item_step_code": row[4],
|
||
"task_mode": row[5],
|
||
"job_code": row[6],
|
||
"step_code": row[4] or row[7],
|
||
"step_payload": row[8],
|
||
"domain": row[9],
|
||
"source_type": row[10],
|
||
"register_status": row[11],
|
||
"detect_status": row[12],
|
||
"use_status": row[13],
|
||
"expire_date": row[14],
|
||
"jucha_status": row[15],
|
||
"juziseo_status": row[16],
|
||
}
|
||
for row in rows
|
||
]
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"领取检测任务失败: {e}")
|
||
return []
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def claim_restart_released_detect_job_items(
|
||
self,
|
||
node_code,
|
||
job_id,
|
||
*,
|
||
limit=1000,
|
||
lease_seconds=3600,
|
||
):
|
||
"""
|
||
优先回收节点重启后放回 pending 的当前 job 尾批任务项。
|
||
|
||
这批条目如果长期不被重新 claim,会让旧 job 维持 running,
|
||
从而拖住下一批的自然接棒。
|
||
"""
|
||
conn = None
|
||
cur = None
|
||
claim_token = _build_claim_token(node_code)
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
logger.error("优先领取重启回收尾批任务失败: 无法获取数据库连接")
|
||
return []
|
||
normalized_job_id = int(job_id) if job_id not in (None, "", 0, "0") else None
|
||
if not normalized_job_id:
|
||
return []
|
||
normalized_limit = max(1, int(limit or 1))
|
||
normalized_lease_seconds = max(60, int(lease_seconds or 3600))
|
||
cur.execute(
|
||
"""
|
||
WITH picked AS (
|
||
SELECT id
|
||
FROM detect_job_items
|
||
WHERE job_id = %s
|
||
AND status = 'pending'
|
||
AND step_code <> ''
|
||
AND (lease_expires_at IS NULL OR lease_expires_at < CURRENT_TIMESTAMP)
|
||
AND last_error IN (
|
||
'released after worker restart',
|
||
'released before execution after worker restart'
|
||
)
|
||
ORDER BY
|
||
CASE
|
||
WHEN last_error = 'released after worker restart' THEN 0
|
||
ELSE 1
|
||
END ASC,
|
||
create_time ASC,
|
||
id ASC
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT %s
|
||
),
|
||
updated AS (
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'claimed',
|
||
claimed_by = %s,
|
||
claim_token = %s,
|
||
lease_expires_at = CURRENT_TIMESTAMP + (%s || ' seconds')::interval,
|
||
attempt_count = item.attempt_count + 1,
|
||
started_at = COALESCE(item.started_at, CURRENT_TIMESTAMP),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
FROM picked
|
||
WHERE item.id = picked.id
|
||
RETURNING item.id, item.job_id, item.domain_id, item.claim_token, item.step_code, item.step_payload_json
|
||
)
|
||
SELECT
|
||
updated.id,
|
||
updated.job_id,
|
||
updated.domain_id,
|
||
updated.claim_token,
|
||
updated.step_code,
|
||
detect_jobs.task_mode,
|
||
detect_jobs.job_code,
|
||
detect_jobs.step_code,
|
||
updated.step_payload_json,
|
||
domains.domain,
|
||
domains.source_type,
|
||
domains.register_status,
|
||
domains.detect_status,
|
||
domains.use_status,
|
||
domains.expire_date,
|
||
domains.jucha_status,
|
||
domains.juziseo_status
|
||
FROM updated
|
||
JOIN detect_jobs ON detect_jobs.id = updated.job_id
|
||
JOIN domains ON domains.id = updated.domain_id
|
||
ORDER BY updated.id ASC
|
||
""",
|
||
(
|
||
normalized_job_id,
|
||
normalized_limit,
|
||
node_code,
|
||
claim_token,
|
||
normalized_lease_seconds,
|
||
),
|
||
)
|
||
rows = cur.fetchall() or []
|
||
conn.commit()
|
||
return [
|
||
{
|
||
"job_item_id": row[0],
|
||
"job_id": row[1],
|
||
"id": row[2],
|
||
"claim_token": row[3],
|
||
"item_step_code": row[4],
|
||
"task_mode": row[5],
|
||
"job_code": row[6],
|
||
"step_code": row[4] or row[7],
|
||
"step_payload": row[8],
|
||
"domain": row[9],
|
||
"source_type": row[10],
|
||
"register_status": row[11],
|
||
"detect_status": row[12],
|
||
"use_status": row[13],
|
||
"expire_date": row[14],
|
||
"jucha_status": row[15],
|
||
"juziseo_status": row[16],
|
||
}
|
||
for row in rows
|
||
]
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"优先领取重启回收尾批任务失败: {e}")
|
||
return []
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def has_dispatchable_detect_job_items(self):
|
||
"""
|
||
是否仍存在可继续派发的标准步骤任务项。
|
||
|
||
这里显式排除空 step_code 的旧兼容链路项,避免 worker 在 single_step
|
||
任务已经大量积压时继续回退到旧 domains 链路补位。
|
||
"""
|
||
row = self.fetch_one(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM detect_job_items
|
||
WHERE status IN ('pending', 'failed')
|
||
AND COALESCE(step_code, '') <> ''
|
||
AND (lease_expires_at IS NULL OR lease_expires_at < CURRENT_TIMESTAMP)
|
||
LIMIT 1
|
||
) AS has_items
|
||
"""
|
||
)
|
||
if isinstance(row, dict):
|
||
return bool(row.get("has_items"))
|
||
if isinstance(row, (list, tuple)) and row:
|
||
return bool(row[0])
|
||
return False
|
||
|
||
def recycle_expired_detect_job_items(self):
|
||
"""
|
||
回收租约过期但未完成的任务项,重新放回 pending。
|
||
"""
|
||
conn = None
|
||
cur = None
|
||
advisory_lock_key = _build_pg_advisory_lock_key("detect-job-items-recycle-expired")
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return 0
|
||
cur.execute("SELECT pg_try_advisory_lock(%s)", (advisory_lock_key,))
|
||
lock_row = cur.fetchone()
|
||
if not bool((lock_row or [False])[0]):
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
recycled_count = 0
|
||
touched_job_ids = set()
|
||
for _ in range(_DETECT_JOB_ITEM_RECYCLE_MAX_BATCHES):
|
||
cur.execute(
|
||
"""
|
||
WITH expired_candidates AS (
|
||
SELECT id, job_id, status
|
||
FROM detect_job_items
|
||
WHERE status IN ('claimed', 'running')
|
||
AND lease_expires_at IS NOT NULL
|
||
AND lease_expires_at < CURRENT_TIMESTAMP
|
||
ORDER BY lease_expires_at ASC, id ASC
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT %s
|
||
),
|
||
recycled AS (
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'pending',
|
||
claimed_by = '',
|
||
claim_token = '',
|
||
lease_expires_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
last_error = CASE
|
||
WHEN expired_candidates.status = 'running' THEN 'lease expired while running'
|
||
WHEN expired_candidates.status = 'claimed' THEN 'lease expired before running'
|
||
ELSE item.last_error
|
||
END
|
||
FROM expired_candidates
|
||
WHERE item.id = expired_candidates.id
|
||
RETURNING expired_candidates.job_id
|
||
)
|
||
SELECT count(*), array_remove(array_agg(DISTINCT job_id), NULL)
|
||
FROM recycled
|
||
""",
|
||
(_DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE,),
|
||
)
|
||
row = cur.fetchone()
|
||
batch_count = int((row or [0])[0] or 0)
|
||
recycled_count += batch_count
|
||
touched_job_ids.update(item for item in ((row or [0, []])[1] or []) if item is not None)
|
||
if batch_count < _DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE:
|
||
break
|
||
for job_id in sorted(touched_job_ids):
|
||
self._refresh_detect_job_status_with_cursor(cur, job_id)
|
||
conn.commit()
|
||
return recycled_count
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"回收过期检测任务失败: {e}")
|
||
return 0
|
||
finally:
|
||
if cur:
|
||
try:
|
||
cur.execute("SELECT pg_advisory_unlock(%s)", (advisory_lock_key,))
|
||
if conn:
|
||
conn.commit()
|
||
except Exception:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
self.close(conn, cur)
|
||
|
||
def recycle_stalled_detect_job_items(self, job_id, *, stall_seconds=1800, batch_size=None):
|
||
"""
|
||
定向回收长时间无活动的 claimed/running 任务项。
|
||
"""
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
normalized_job_id = int(job_id or 0)
|
||
except Exception:
|
||
normalized_job_id = 0
|
||
if normalized_job_id <= 0:
|
||
return 0
|
||
safe_stall_seconds = max(300, int(stall_seconds or 1800))
|
||
safe_batch_size = max(
|
||
1,
|
||
min(
|
||
int(batch_size or _DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE),
|
||
_DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE,
|
||
),
|
||
)
|
||
advisory_lock_key = _build_pg_advisory_lock_key(
|
||
f"detect-job-items-recycle-stalled:{normalized_job_id}"
|
||
)
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return 0
|
||
cur.execute("SELECT pg_try_advisory_lock(%s)", (advisory_lock_key,))
|
||
lock_row = cur.fetchone()
|
||
if not bool((lock_row or [False])[0]):
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
cur.execute(
|
||
"""
|
||
WITH stalled_candidates AS (
|
||
SELECT id, job_id, status
|
||
FROM detect_job_items
|
||
WHERE job_id = %s
|
||
AND status IN ('claimed', 'running')
|
||
AND COALESCE(updated_at, started_at, create_time)
|
||
< CURRENT_TIMESTAMP - (%s || ' seconds')::interval
|
||
ORDER BY COALESCE(updated_at, started_at, create_time) ASC, id ASC
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT %s
|
||
),
|
||
recycled AS (
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'pending',
|
||
claimed_by = '',
|
||
claim_token = '',
|
||
lease_expires_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
last_error = CASE
|
||
WHEN stalled_candidates.status = 'running' THEN 'stalled while running'
|
||
WHEN stalled_candidates.status = 'claimed' THEN 'stalled before running'
|
||
ELSE item.last_error
|
||
END
|
||
FROM stalled_candidates
|
||
WHERE item.id = stalled_candidates.id
|
||
RETURNING stalled_candidates.job_id
|
||
)
|
||
SELECT count(*), array_remove(array_agg(DISTINCT job_id), NULL)
|
||
FROM recycled
|
||
""",
|
||
(
|
||
normalized_job_id,
|
||
safe_stall_seconds,
|
||
safe_batch_size,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
recycled_count = int((row or [0])[0] or 0)
|
||
touched_job_ids = [item for item in ((row or [0, []])[1] or []) if item is not None]
|
||
for touched_job_id in sorted(set(touched_job_ids)):
|
||
self._refresh_detect_job_status_with_cursor(cur, touched_job_id)
|
||
conn.commit()
|
||
return recycled_count
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"回收长挂检测任务失败: job_id={normalized_job_id}, error={e}")
|
||
return 0
|
||
finally:
|
||
if cur:
|
||
try:
|
||
cur.execute("SELECT pg_advisory_unlock(%s)", (advisory_lock_key,))
|
||
if conn:
|
||
conn.commit()
|
||
except Exception:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
self.close(conn, cur)
|
||
|
||
def release_detect_job_item(self, job_item_id, claim_token, reason=''):
|
||
"""
|
||
释放单个 claimed/running 任务项,供会话切换时快速回到 pending。
|
||
"""
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return False
|
||
cur.execute(
|
||
"""
|
||
UPDATE detect_job_items
|
||
SET status = 'pending',
|
||
claimed_by = '',
|
||
claim_token = '',
|
||
lease_expires_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
last_error = CASE
|
||
WHEN %s <> '' THEN %s
|
||
ELSE last_error
|
||
END
|
||
WHERE id = %s
|
||
AND claim_token = %s
|
||
AND status IN ('claimed', 'running')
|
||
RETURNING job_id
|
||
""",
|
||
(
|
||
str(reason or '')[:1000],
|
||
str(reason or '')[:1000],
|
||
int(job_item_id or 0),
|
||
str(claim_token or '').strip(),
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
return bool(row)
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"释放单个检测任务项失败: {e}")
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def release_detect_job_items_batch(self, items):
|
||
"""
|
||
批量释放 claimed/running 任务项,降低会话切换时的逐条写锁竞争。
|
||
"""
|
||
normalized_items = []
|
||
for item in list(items or []):
|
||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||
continue
|
||
try:
|
||
job_item_id = int(item[0] or 0)
|
||
except Exception:
|
||
continue
|
||
claim_token = str(item[1] or "").strip()
|
||
reason = ""
|
||
if len(item) >= 3:
|
||
reason = str(item[2] or "").strip()[:1000]
|
||
if job_item_id <= 0 or not claim_token:
|
||
continue
|
||
normalized_items.append((job_item_id, claim_token, reason))
|
||
if not normalized_items:
|
||
return 0
|
||
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return -1
|
||
values_sql = ",".join(
|
||
cur.mogrify("(%s, %s, %s)", item).decode("utf-8")
|
||
for item in normalized_items
|
||
)
|
||
cur.execute(
|
||
f"""
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'pending',
|
||
claimed_by = '',
|
||
claim_token = '',
|
||
lease_expires_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
last_error = CASE
|
||
WHEN batch.reason <> '' THEN batch.reason
|
||
ELSE item.last_error
|
||
END
|
||
FROM (VALUES {values_sql}) AS batch(id, claim_token, reason)
|
||
WHERE item.id = batch.id
|
||
AND item.claim_token = batch.claim_token
|
||
AND item.status IN ('claimed', 'running')
|
||
RETURNING item.id
|
||
"""
|
||
)
|
||
updated_rows = cur.fetchall() or []
|
||
conn.commit()
|
||
return len(updated_rows)
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"批量释放检测任务项失败: {e}")
|
||
return -1
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def release_detect_job_items_for_node(self, node_code):
|
||
"""
|
||
释放指定节点遗留的 claimed/running 任务项,供节点重启后快速恢复。
|
||
"""
|
||
normalized_flag = str(os.getenv("DOMAINCHECK_ENABLE_NODE_ITEM_RELEASE", "1") or "1").strip().lower()
|
||
if normalized_flag in {
|
||
"0",
|
||
"false",
|
||
"no",
|
||
"off",
|
||
"disable",
|
||
"disabled",
|
||
}:
|
||
return 0
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return 0
|
||
released_count = 0
|
||
touched_job_ids = set()
|
||
for _ in range(_DETECT_JOB_ITEM_RELEASE_MAX_BATCHES):
|
||
cur.execute(
|
||
"""
|
||
WITH release_candidates AS (
|
||
SELECT id, job_id, status
|
||
FROM detect_job_items
|
||
WHERE claimed_by = %s
|
||
AND status IN ('claimed', 'running')
|
||
ORDER BY id ASC
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT %s
|
||
),
|
||
released AS (
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'pending',
|
||
claimed_by = '',
|
||
claim_token = '',
|
||
lease_expires_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
last_error = CASE
|
||
WHEN release_candidates.status = 'running' THEN 'released after worker restart'
|
||
WHEN release_candidates.status = 'claimed' THEN 'released before execution after worker restart'
|
||
ELSE item.last_error
|
||
END
|
||
FROM release_candidates
|
||
WHERE item.id = release_candidates.id
|
||
RETURNING release_candidates.job_id
|
||
)
|
||
SELECT count(*), array_remove(array_agg(DISTINCT job_id), NULL)
|
||
FROM released
|
||
""",
|
||
(node_code, _DETECT_JOB_ITEM_RELEASE_BATCH_SIZE),
|
||
)
|
||
row = cur.fetchone()
|
||
batch_count = int((row or [0])[0] or 0)
|
||
released_count += batch_count
|
||
touched_job_ids.update(item for item in ((row or [0, []])[1] or []) if item is not None)
|
||
if batch_count < _DETECT_JOB_ITEM_RELEASE_BATCH_SIZE:
|
||
break
|
||
for job_id in sorted(touched_job_ids):
|
||
self._refresh_detect_job_status_with_cursor(cur, job_id)
|
||
conn.commit()
|
||
return released_count
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"释放节点遗留任务失败: {e}")
|
||
return 0
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def release_detect_job_items_for_node_job(self, node_code, job_id):
|
||
"""
|
||
只释放指定节点在某个 job 上遗留的 claimed/running 任务项,供尾批定向回收。
|
||
"""
|
||
normalized_flag = str(os.getenv("DOMAINCHECK_ENABLE_NODE_ITEM_RELEASE", "1") or "1").strip().lower()
|
||
if normalized_flag in {
|
||
"0",
|
||
"false",
|
||
"no",
|
||
"off",
|
||
"disable",
|
||
"disabled",
|
||
}:
|
||
return 0
|
||
normalized_node_code = str(node_code or "").strip()
|
||
try:
|
||
normalized_job_id = int(job_id or 0)
|
||
except Exception:
|
||
normalized_job_id = 0
|
||
if not normalized_node_code or normalized_job_id <= 0:
|
||
return 0
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return 0
|
||
released_count = 0
|
||
touched_job_ids = set()
|
||
for _ in range(_DETECT_JOB_ITEM_RELEASE_MAX_BATCHES):
|
||
cur.execute(
|
||
"""
|
||
WITH release_candidates AS (
|
||
SELECT id, job_id, status
|
||
FROM detect_job_items
|
||
WHERE claimed_by = %s
|
||
AND job_id = %s
|
||
AND status IN ('claimed', 'running')
|
||
ORDER BY id ASC
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT %s
|
||
),
|
||
released AS (
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'pending',
|
||
claimed_by = '',
|
||
claim_token = '',
|
||
lease_expires_at = NULL,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
last_error = CASE
|
||
WHEN release_candidates.status = 'running' THEN 'released after worker restart'
|
||
WHEN release_candidates.status = 'claimed' THEN 'released before execution after worker restart'
|
||
ELSE item.last_error
|
||
END
|
||
FROM release_candidates
|
||
WHERE item.id = release_candidates.id
|
||
RETURNING release_candidates.job_id
|
||
)
|
||
SELECT count(*), array_remove(array_agg(DISTINCT job_id), NULL)
|
||
FROM released
|
||
""",
|
||
(
|
||
normalized_node_code,
|
||
normalized_job_id,
|
||
_DETECT_JOB_ITEM_RELEASE_BATCH_SIZE,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
batch_count = int((row or [0])[0] or 0)
|
||
released_count += batch_count
|
||
touched_job_ids.update(item for item in ((row or [0, []])[1] or []) if item is not None)
|
||
if batch_count < _DETECT_JOB_ITEM_RELEASE_BATCH_SIZE:
|
||
break
|
||
for touched_job_id in sorted(touched_job_ids):
|
||
self._refresh_detect_job_status_with_cursor(cur, touched_job_id)
|
||
conn.commit()
|
||
return released_count
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(
|
||
f"释放节点定向任务失败: node={normalized_node_code}, job_id={normalized_job_id}, error={e}"
|
||
)
|
||
return 0
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def get_active_detect_job(self):
|
||
"""
|
||
获取当前活动中的检测任务摘要,供 Worker 重启后自动回挂。
|
||
"""
|
||
cache_key = "domaincheck:active_detect_job_summary:v1"
|
||
cache_lock_key = f"{cache_key}:refresh_lock"
|
||
now_ts = time.time()
|
||
try:
|
||
cache_ttl_seconds = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_ACTIVE_JOB_CACHE_TTL_SECONDS", "3") or 3),
|
||
)
|
||
except (TypeError, ValueError):
|
||
cache_ttl_seconds = 3
|
||
try:
|
||
local_cache_ttl_seconds = max(
|
||
1,
|
||
int(
|
||
os.getenv(
|
||
"DOMAINCHECK_ACTIVE_JOB_LOCAL_CACHE_TTL_SECONDS",
|
||
str(cache_ttl_seconds),
|
||
)
|
||
or cache_ttl_seconds
|
||
),
|
||
)
|
||
except (TypeError, ValueError):
|
||
local_cache_ttl_seconds = cache_ttl_seconds
|
||
try:
|
||
stale_cache_ttl_seconds = max(
|
||
0,
|
||
int(os.getenv("DOMAINCHECK_ACTIVE_JOB_CACHE_STALE_SECONDS", "10") or 10),
|
||
)
|
||
except (TypeError, ValueError):
|
||
stale_cache_ttl_seconds = 10
|
||
try:
|
||
cache_refresh_lock_seconds = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_ACTIVE_JOB_CACHE_LOCK_SECONDS", "2") or 2),
|
||
)
|
||
except (TypeError, ValueError):
|
||
cache_refresh_lock_seconds = 2
|
||
|
||
local_cached = self._get_local_active_detect_job_cache(now_ts)
|
||
if local_cached is not None:
|
||
return local_cached
|
||
|
||
cached = self._read_cached_active_detect_job_from_redis(cache_key)
|
||
if isinstance(cached, dict):
|
||
self._set_local_active_detect_job_cache(
|
||
cached,
|
||
now_ts=now_ts,
|
||
fresh_ttl_seconds=local_cache_ttl_seconds,
|
||
stale_ttl_seconds=stale_cache_ttl_seconds,
|
||
)
|
||
return self._clone_active_detect_job_payload(cached)
|
||
|
||
refresh_lock_token = ""
|
||
if self.redis_client:
|
||
try:
|
||
refresh_lock_token = str(uuid.uuid4())
|
||
acquired = self.redis_client.set(
|
||
cache_lock_key,
|
||
refresh_lock_token,
|
||
nx=True,
|
||
ex=cache_refresh_lock_seconds,
|
||
)
|
||
if not acquired:
|
||
stale_cached = self._get_local_active_detect_job_cache(now_ts, allow_stale=True)
|
||
if stale_cached is not None:
|
||
return stale_cached
|
||
time.sleep(0.05)
|
||
cached = self._read_cached_active_detect_job_from_redis(cache_key)
|
||
if isinstance(cached, dict):
|
||
self._set_local_active_detect_job_cache(
|
||
cached,
|
||
now_ts=time.time(),
|
||
fresh_ttl_seconds=local_cache_ttl_seconds,
|
||
stale_ttl_seconds=stale_cache_ttl_seconds,
|
||
)
|
||
return self._clone_active_detect_job_payload(cached)
|
||
refresh_lock_token = ""
|
||
except Exception as e:
|
||
logger.debug(f"获取活动任务缓存刷新锁失败: {e}")
|
||
refresh_lock_token = ""
|
||
tail_handoff_enabled = str(
|
||
os.getenv("DOMAINCHECK_TAIL_HANDOFF_ENABLED", "1") or "1"
|
||
).strip().lower() not in {"0", "false", "off", "no"}
|
||
try:
|
||
tail_handoff_active_max = max(
|
||
0,
|
||
int(
|
||
os.getenv(
|
||
"DOMAINCHECK_TAIL_HANDOFF_MAX_ACTIVE_ITEMS",
|
||
"128",
|
||
)
|
||
or 128
|
||
),
|
||
)
|
||
except (TypeError, ValueError):
|
||
tail_handoff_active_max = 128
|
||
try:
|
||
tail_handoff_pending_max = max(
|
||
0,
|
||
int(
|
||
os.getenv(
|
||
"DOMAINCHECK_TAIL_HANDOFF_MAX_PENDING_ITEMS",
|
||
"256",
|
||
)
|
||
or 256
|
||
),
|
||
)
|
||
except (TypeError, ValueError):
|
||
tail_handoff_pending_max = 256
|
||
try:
|
||
tail_handoff_min_pending = max(
|
||
1,
|
||
int(
|
||
os.getenv(
|
||
"DOMAINCHECK_TAIL_HANDOFF_MIN_PENDING_ITEMS",
|
||
"1",
|
||
)
|
||
or 1
|
||
),
|
||
)
|
||
except (TypeError, ValueError):
|
||
tail_handoff_min_pending = 1
|
||
try:
|
||
running_stall_seconds = max(
|
||
60,
|
||
int(
|
||
os.getenv(
|
||
"DOMAINCHECK_RUNNING_JOB_STALL_SECONDS",
|
||
"900",
|
||
)
|
||
or 900
|
||
),
|
||
)
|
||
except (TypeError, ValueError):
|
||
running_stall_seconds = 900
|
||
row = self.fetch_one(
|
||
"""
|
||
WITH tail_config AS (
|
||
SELECT
|
||
%s::boolean AS handoff_enabled,
|
||
%s::integer AS handoff_active_max,
|
||
%s::integer AS handoff_pending_max,
|
||
%s::integer AS handoff_min_pending,
|
||
%s::integer AS running_stall_seconds
|
||
),
|
||
candidate_jobs AS (
|
||
SELECT
|
||
job.id,
|
||
job.job_code,
|
||
job.task_mode,
|
||
job.status,
|
||
job.started_at,
|
||
job.created_at
|
||
FROM detect_jobs AS job
|
||
WHERE job.status IN ('pending', 'running')
|
||
ORDER BY
|
||
CASE WHEN job.status = 'running' THEN 0 ELSE 1 END ASC,
|
||
COALESCE(job.started_at, job.created_at) DESC,
|
||
job.id DESC
|
||
LIMIT 20
|
||
),
|
||
job_counts AS (
|
||
SELECT
|
||
job.id,
|
||
job.job_code,
|
||
job.task_mode,
|
||
job.status,
|
||
job.started_at,
|
||
job.created_at,
|
||
COALESCE(item_counts.items_pending, 0) AS items_pending,
|
||
COALESCE(item_counts.items_claimed, 0) AS items_claimed,
|
||
COALESCE(item_counts.items_running, 0) AS items_running,
|
||
COALESCE(item_counts.items_claimed, 0) + COALESCE(item_counts.items_running, 0) AS items_active,
|
||
item_counts.latest_unfinished_activity_at,
|
||
item_counts.latest_active_activity_at
|
||
FROM candidate_jobs AS job
|
||
LEFT JOIN LATERAL (
|
||
SELECT
|
||
count(*) FILTER (WHERE item.status = 'pending') AS items_pending,
|
||
count(*) FILTER (WHERE item.status = 'claimed') AS items_claimed,
|
||
count(*) FILTER (WHERE item.status = 'running') AS items_running,
|
||
max(COALESCE(item.updated_at, item.started_at, item.create_time)) AS latest_unfinished_activity_at,
|
||
max(COALESCE(item.updated_at, item.started_at, item.create_time))
|
||
FILTER (WHERE item.status IN ('claimed', 'running')) AS latest_active_activity_at
|
||
FROM detect_job_items AS item
|
||
WHERE item.job_id = job.id
|
||
AND item.status IN ('pending', 'claimed', 'running')
|
||
) AS item_counts ON TRUE
|
||
WHERE
|
||
COALESCE(item_counts.items_pending, 0) > 0
|
||
OR COALESCE(item_counts.items_claimed, 0) > 0
|
||
OR COALESCE(item_counts.items_running, 0) > 0
|
||
),
|
||
job_health AS (
|
||
SELECT
|
||
job.*,
|
||
CASE
|
||
WHEN job.status = 'running'
|
||
AND job.items_active > 0
|
||
AND cfg.running_stall_seconds > 0
|
||
AND COALESCE(
|
||
job.latest_active_activity_at,
|
||
job.latest_unfinished_activity_at,
|
||
job.started_at,
|
||
job.created_at
|
||
) <= (NOW() - (cfg.running_stall_seconds * INTERVAL '1 second'))
|
||
THEN TRUE
|
||
ELSE FALSE
|
||
END AS running_job_stalled
|
||
FROM job_counts AS job
|
||
CROSS JOIN tail_config AS cfg
|
||
),
|
||
tail_gate AS (
|
||
SELECT
|
||
COALESCE(
|
||
bool_or(
|
||
job.status = 'pending'
|
||
AND job.items_pending >= cfg.handoff_min_pending
|
||
),
|
||
FALSE
|
||
) AS has_handoff_pending,
|
||
COALESCE(
|
||
bool_or(
|
||
job.status = 'running'
|
||
AND NOT job.running_job_stalled
|
||
AND (
|
||
job.items_active > cfg.handoff_active_max
|
||
OR job.items_pending > cfg.handoff_pending_max
|
||
)
|
||
),
|
||
FALSE
|
||
) AS has_non_tail_running
|
||
FROM job_health AS job
|
||
CROSS JOIN tail_config AS cfg
|
||
)
|
||
SELECT
|
||
job.id,
|
||
job.job_code,
|
||
job.task_mode,
|
||
job.status,
|
||
job.items_pending,
|
||
job.items_claimed,
|
||
job.items_running,
|
||
0 AS items_completed,
|
||
0 AS items_failed,
|
||
job.latest_unfinished_activity_at AS latest_item_updated_at,
|
||
NULL::timestamp AS latest_item_created_at,
|
||
job.running_job_stalled,
|
||
CASE
|
||
WHEN cfg.handoff_enabled
|
||
AND gate.has_handoff_pending
|
||
AND NOT gate.has_non_tail_running
|
||
AND job.status = 'pending'
|
||
AND job.items_pending >= cfg.handoff_min_pending
|
||
THEN TRUE
|
||
ELSE FALSE
|
||
END AS tail_handoff_candidate,
|
||
CASE
|
||
WHEN cfg.handoff_enabled
|
||
AND gate.has_handoff_pending
|
||
AND NOT gate.has_non_tail_running
|
||
AND job.status = 'pending'
|
||
AND job.items_pending >= cfg.handoff_min_pending
|
||
THEN 'tail_handoff_pending'
|
||
WHEN job.running_job_stalled
|
||
THEN 'running_job_stalled'
|
||
WHEN job.items_claimed > 0 OR job.items_running > 0
|
||
THEN 'running_job_active'
|
||
WHEN job.items_pending > 0
|
||
THEN 'pending_job'
|
||
ELSE 'inactive'
|
||
END AS selection_reason
|
||
FROM job_health AS job
|
||
CROSS JOIN tail_gate AS gate
|
||
CROSS JOIN tail_config AS cfg
|
||
ORDER BY
|
||
CASE
|
||
WHEN cfg.handoff_enabled
|
||
AND gate.has_handoff_pending
|
||
AND NOT gate.has_non_tail_running
|
||
AND job.status = 'pending'
|
||
AND job.items_pending >= cfg.handoff_min_pending
|
||
THEN 0
|
||
WHEN (job.items_claimed > 0 OR job.items_running > 0)
|
||
AND NOT job.running_job_stalled
|
||
THEN 1
|
||
WHEN job.items_pending > 0 THEN 2
|
||
WHEN job.running_job_stalled THEN 3
|
||
ELSE 4
|
||
END ASC,
|
||
CASE
|
||
WHEN cfg.handoff_enabled
|
||
AND gate.has_handoff_pending
|
||
AND NOT gate.has_non_tail_running
|
||
AND job.status = 'pending'
|
||
AND job.items_pending >= cfg.handoff_min_pending
|
||
THEN job.items_pending
|
||
ELSE NULL
|
||
END DESC NULLS LAST,
|
||
CASE WHEN job.status = 'running' THEN 0 ELSE 1 END ASC,
|
||
COALESCE(job.started_at, job.created_at) DESC,
|
||
job.id DESC
|
||
LIMIT 1
|
||
""",
|
||
(
|
||
tail_handoff_enabled,
|
||
tail_handoff_active_max,
|
||
tail_handoff_pending_max,
|
||
tail_handoff_min_pending,
|
||
running_stall_seconds,
|
||
),
|
||
)
|
||
try:
|
||
if isinstance(row, dict):
|
||
cache_row = {}
|
||
for key, value in row.items():
|
||
if isinstance(value, datetime):
|
||
cache_row[key] = value.isoformat()
|
||
else:
|
||
cache_row[key] = value
|
||
self._set_local_active_detect_job_cache(
|
||
cache_row,
|
||
now_ts=time.time(),
|
||
fresh_ttl_seconds=local_cache_ttl_seconds,
|
||
stale_ttl_seconds=stale_cache_ttl_seconds,
|
||
)
|
||
if self.redis_client:
|
||
try:
|
||
self.redis_client.setex(
|
||
cache_key,
|
||
cache_ttl_seconds,
|
||
json.dumps(cache_row, ensure_ascii=True, default=str),
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"写入活动任务缓存失败: {e}")
|
||
return row
|
||
self._set_local_active_detect_job_cache(
|
||
row,
|
||
now_ts=time.time(),
|
||
fresh_ttl_seconds=local_cache_ttl_seconds,
|
||
stale_ttl_seconds=stale_cache_ttl_seconds,
|
||
)
|
||
return row
|
||
finally:
|
||
if self.redis_client and refresh_lock_token:
|
||
try:
|
||
current_owner = self.redis_client.get(cache_lock_key)
|
||
if current_owner and str(current_owner) == refresh_lock_token:
|
||
self.redis_client.delete(cache_lock_key)
|
||
except Exception:
|
||
pass
|
||
|
||
def mark_detect_job_item_running(self, job_item_id, claim_token):
|
||
return self.execute(
|
||
"""
|
||
UPDATE detect_job_items
|
||
SET status = 'running',
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = %s AND claim_token = %s AND status = 'claimed'
|
||
""",
|
||
(job_item_id, claim_token),
|
||
)
|
||
|
||
def mark_detect_job_items_running_batch(self, items):
|
||
normalized_items = []
|
||
for item in list(items or []):
|
||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||
continue
|
||
try:
|
||
job_item_id = int(item[0] or 0)
|
||
except Exception:
|
||
continue
|
||
claim_token = str(item[1] or "").strip()
|
||
if job_item_id <= 0 or not claim_token:
|
||
continue
|
||
normalized_items.append((job_item_id, claim_token))
|
||
if not normalized_items:
|
||
return 0
|
||
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return -1
|
||
values_sql = ",".join(
|
||
cur.mogrify("(%s, %s)", item).decode("utf-8")
|
||
for item in normalized_items
|
||
)
|
||
cur.execute(
|
||
f"""
|
||
UPDATE detect_job_items AS item
|
||
SET status = 'running',
|
||
updated_at = CURRENT_TIMESTAMP
|
||
FROM (VALUES {values_sql}) AS batch(id, claim_token)
|
||
WHERE item.id = batch.id
|
||
AND item.claim_token = batch.claim_token
|
||
AND item.status = 'claimed'
|
||
RETURNING item.job_id
|
||
"""
|
||
)
|
||
updated_rows = cur.fetchall() or []
|
||
updated_count = len(updated_rows)
|
||
conn.commit()
|
||
return updated_count
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"批量标记检测任务项为运行中失败: {e}")
|
||
return -1
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def renew_detect_job_item_lease(self, job_item_id, claim_token, lease_seconds=3600):
|
||
return self.execute(
|
||
"""
|
||
UPDATE detect_job_items
|
||
SET lease_expires_at = CURRENT_TIMESTAMP + (%s || ' seconds')::interval,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = %s AND claim_token = %s AND status IN ('claimed', 'running')
|
||
""",
|
||
(max(60, int(lease_seconds or 3600)), job_item_id, claim_token),
|
||
)
|
||
|
||
def append_detect_run_event(self, job_id, job_item_id, node_code, event_type, message, level='info', payload=None):
|
||
return self.execute(
|
||
"""
|
||
INSERT INTO detect_run_events (job_id, job_item_id, node_code, event_type, level, message, payload_json)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||
""",
|
||
(job_id, job_item_id, node_code, event_type, level, message, Json(payload or {})),
|
||
)
|
||
|
||
def complete_detect_job_item(self, job_item_id, claim_token, final_status='completed', result_payload=None, result_version='v1', refresh_job_status=True):
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return False
|
||
payload_json = json.dumps(result_payload, ensure_ascii=False) if result_payload is not None else None
|
||
cur.execute(
|
||
"""
|
||
UPDATE detect_job_items
|
||
SET status = %s,
|
||
result_payload_json = COALESCE(%s::jsonb, result_payload_json),
|
||
result_version = CASE
|
||
WHEN %s <> '' THEN %s
|
||
ELSE result_version
|
||
END,
|
||
finished_at = CURRENT_TIMESTAMP,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
lease_expires_at = NULL
|
||
WHERE id = %s AND claim_token = %s
|
||
RETURNING job_id
|
||
""",
|
||
(
|
||
final_status,
|
||
payload_json,
|
||
str(result_version or '').strip(),
|
||
str(result_version or '').strip(),
|
||
job_item_id,
|
||
claim_token,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
if row and refresh_job_status:
|
||
self._refresh_detect_job_status_with_cursor(cur, row[0])
|
||
conn.commit()
|
||
return bool(row)
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"完成检测任务项失败: {e}")
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def fail_detect_job_item(self, job_item_id, claim_token, reason='', result_payload=None, result_version='v1', refresh_job_status=True):
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return False
|
||
payload_json = json.dumps(result_payload, ensure_ascii=False) if result_payload is not None else None
|
||
cur.execute(
|
||
"""
|
||
UPDATE detect_job_items
|
||
SET status = 'failed',
|
||
last_error = %s,
|
||
result_payload_json = COALESCE(%s::jsonb, result_payload_json),
|
||
result_version = CASE
|
||
WHEN %s <> '' THEN %s
|
||
ELSE result_version
|
||
END,
|
||
finished_at = CURRENT_TIMESTAMP,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
lease_expires_at = NULL
|
||
WHERE id = %s AND claim_token = %s
|
||
RETURNING job_id
|
||
""",
|
||
(
|
||
str(reason or '')[:1000],
|
||
payload_json,
|
||
str(result_version or '').strip(),
|
||
str(result_version or '').strip(),
|
||
job_item_id,
|
||
claim_token,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
if row and refresh_job_status:
|
||
self._refresh_detect_job_status_with_cursor(cur, row[0])
|
||
conn.commit()
|
||
return bool(row)
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"标记检测任务项失败: {e}")
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def finalize_detect_job_items_batch(self, items):
|
||
normalized_items = []
|
||
event_rows = []
|
||
for item in list(items or []):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
try:
|
||
job_item_id = int(item.get("job_item_id") or 0)
|
||
except Exception:
|
||
continue
|
||
claim_token = str(item.get("claim_token") or "").strip()
|
||
final_status = str(item.get("final_status") or "").strip() or "failed"
|
||
message = str(item.get("message") or "").strip()
|
||
result_payload = item.get("result_payload")
|
||
result_version = str(item.get("result_version") or "v1").strip()
|
||
if job_item_id <= 0 or not claim_token:
|
||
continue
|
||
payload_json = ""
|
||
if result_payload is not None:
|
||
try:
|
||
payload_json = json.dumps(result_payload, ensure_ascii=False)
|
||
except Exception:
|
||
payload_json = ""
|
||
normalized_items.append(
|
||
(
|
||
job_item_id,
|
||
claim_token,
|
||
final_status,
|
||
message[:1000],
|
||
payload_json,
|
||
result_version,
|
||
)
|
||
)
|
||
try:
|
||
event_job_id = int(item.get("job_id") or 0)
|
||
except Exception:
|
||
event_job_id = 0
|
||
event_type = str(item.get("event_type") or "").strip()
|
||
event_message = str(item.get("event_message") or "").strip()
|
||
if event_job_id > 0 and event_type and event_message:
|
||
event_payload_json = "{}"
|
||
try:
|
||
event_payload_json = json.dumps(item.get("event_payload") or {}, ensure_ascii=False)
|
||
except Exception:
|
||
event_payload_json = "{}"
|
||
event_rows.append(
|
||
(
|
||
job_item_id,
|
||
event_job_id,
|
||
str(item.get("node_code") or "").strip(),
|
||
event_type,
|
||
str(item.get("event_level") or "info").strip() or "info",
|
||
event_message[:1000],
|
||
event_payload_json,
|
||
)
|
||
)
|
||
if not normalized_items:
|
||
return 0
|
||
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return -1
|
||
values_sql = ",".join(
|
||
cur.mogrify("(%s, %s, %s, %s, %s, %s)", item).decode("utf-8")
|
||
for item in normalized_items
|
||
)
|
||
cur.execute(
|
||
f"""
|
||
UPDATE detect_job_items AS item
|
||
SET status = batch.final_status,
|
||
last_error = CASE
|
||
WHEN batch.final_status = 'failed' AND batch.message <> '' THEN batch.message
|
||
ELSE item.last_error
|
||
END,
|
||
result_payload_json = COALESCE(NULLIF(batch.payload_json, '')::jsonb, item.result_payload_json),
|
||
result_version = CASE
|
||
WHEN batch.result_version <> '' THEN batch.result_version
|
||
ELSE item.result_version
|
||
END,
|
||
finished_at = CURRENT_TIMESTAMP,
|
||
updated_at = CURRENT_TIMESTAMP,
|
||
lease_expires_at = NULL
|
||
FROM (
|
||
VALUES {values_sql}
|
||
) AS batch(id, claim_token, final_status, message, payload_json, result_version)
|
||
WHERE item.id = batch.id
|
||
AND item.claim_token = batch.claim_token
|
||
AND item.status IN ('claimed', 'running')
|
||
RETURNING item.id, item.job_id
|
||
"""
|
||
)
|
||
updated_rows = cur.fetchall() or []
|
||
updated_item_ids = {int(row[0]) for row in updated_rows}
|
||
updated_count = len(updated_item_ids)
|
||
filtered_event_rows = [row for row in event_rows if int(row[0]) in updated_item_ids]
|
||
if filtered_event_rows:
|
||
values_sql = ",".join(
|
||
cur.mogrify("(%s, %s, %s, %s, %s, %s, %s::jsonb)", row).decode("utf-8")
|
||
for row in filtered_event_rows
|
||
)
|
||
cur.execute(
|
||
f"""
|
||
INSERT INTO detect_run_events (
|
||
job_item_id,
|
||
job_id,
|
||
node_code,
|
||
event_type,
|
||
level,
|
||
message,
|
||
payload_json
|
||
)
|
||
VALUES {values_sql}
|
||
"""
|
||
)
|
||
conn.commit()
|
||
return updated_count
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"批量完成检测任务项失败: {e}")
|
||
return -1
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def _refresh_detect_job_status_with_cursor(self, cur, job_id):
|
||
cur.execute(
|
||
"""
|
||
SELECT COALESCE(task_mode, '')
|
||
FROM detect_jobs
|
||
WHERE id = %s
|
||
""",
|
||
(job_id,),
|
||
)
|
||
task_mode_row = cur.fetchone()
|
||
task_mode = str((task_mode_row or [""])[0] or "").strip()
|
||
|
||
def _exists(query, *params):
|
||
cur.execute(query, params)
|
||
row = cur.fetchone()
|
||
return bool((row or [False])[0])
|
||
|
||
dispatch_active_exists = _exists(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM detect_job_items AS item
|
||
WHERE item.job_id = %s
|
||
AND item.status IN ('claimed', 'running')
|
||
LIMIT 1
|
||
)
|
||
""",
|
||
job_id,
|
||
)
|
||
unprocessed_terminal_exists = False
|
||
if task_mode == 'domain_pipeline':
|
||
unprocessed_terminal_exists = _exists(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM detect_job_items AS item
|
||
WHERE item.job_id = %s
|
||
AND item.status IN ('completed', 'blacklisted', 'failed')
|
||
AND COALESCE(item.step_code, '') <> ''
|
||
AND COALESCE(item.result_payload_json->>'controller_processed', 'false') <> 'true'
|
||
LIMIT 1
|
||
)
|
||
""",
|
||
job_id,
|
||
)
|
||
if dispatch_active_exists or unprocessed_terminal_exists:
|
||
cur.execute(
|
||
"""
|
||
UPDATE detect_jobs
|
||
SET status = 'running',
|
||
started_at = COALESCE(started_at, CURRENT_TIMESTAMP),
|
||
finished_at = NULL
|
||
WHERE id = %s
|
||
AND (
|
||
status <> 'running'
|
||
OR started_at IS NULL
|
||
OR finished_at IS NOT NULL
|
||
)
|
||
""",
|
||
(job_id,),
|
||
)
|
||
return
|
||
pending_exists = _exists(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM detect_job_items AS item
|
||
WHERE item.job_id = %s
|
||
AND item.status = 'pending'
|
||
LIMIT 1
|
||
)
|
||
""",
|
||
job_id,
|
||
)
|
||
if pending_exists:
|
||
cur.execute(
|
||
"""
|
||
UPDATE detect_jobs
|
||
SET status = 'pending',
|
||
finished_at = NULL
|
||
WHERE id = %s
|
||
""",
|
||
(job_id,),
|
||
)
|
||
return
|
||
failed_exists = _exists(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM detect_job_items AS item
|
||
WHERE item.job_id = %s
|
||
AND item.status = 'failed'
|
||
LIMIT 1
|
||
)
|
||
""",
|
||
job_id,
|
||
)
|
||
done_exists = _exists(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM detect_job_items AS item
|
||
WHERE item.job_id = %s
|
||
AND item.status IN ('completed', 'blacklisted')
|
||
LIMIT 1
|
||
)
|
||
""",
|
||
job_id,
|
||
)
|
||
final_status = 'completed'
|
||
if failed_exists and done_exists:
|
||
final_status = 'partial_failed'
|
||
elif failed_exists:
|
||
final_status = 'failed'
|
||
cur.execute(
|
||
"""
|
||
UPDATE detect_jobs
|
||
SET status = %s,
|
||
finished_at = CURRENT_TIMESTAMP,
|
||
started_at = COALESCE(started_at, CURRENT_TIMESTAMP)
|
||
WHERE id = %s
|
||
""",
|
||
(final_status, job_id),
|
||
)
|
||
|
||
def refresh_detect_job_status(self, job_id):
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
return False
|
||
self._refresh_detect_job_status_with_cursor(cur, job_id)
|
||
conn.commit()
|
||
return True
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"刷新检测任务状态失败: {e}")
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def fetch_one(self, sql, params=None):
|
||
"""
|
||
获取单条数据
|
||
|
||
:param sql: SQL语句
|
||
:param params: 参数
|
||
:return: dict - 数据
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
|
||
for attempt in range(2):
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
logger.warning(f"线程 {thread_id} 数据库连接失败,返回None")
|
||
return None
|
||
|
||
cur.execute(sql, params)
|
||
row = cur.fetchone()
|
||
if row:
|
||
columns = [desc[0] for desc in cur.description]
|
||
return dict(zip(columns, row))
|
||
return None
|
||
except (psycopg2.OperationalError, psycopg2.InterfaceError) as e:
|
||
logger.warning(f"查询数据连接异常,第 {attempt + 1} 次: {sql}, 错误: {e}")
|
||
try:
|
||
if conn and not conn.closed:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
if attempt == 0:
|
||
continue
|
||
logger.error(f"查询数据失败: {sql}, 错误: {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"查询数据失败: {sql}, 错误: {e}")
|
||
return None
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def fetch_all(self, sql, params=None):
|
||
"""
|
||
获取多条数据
|
||
|
||
:param sql: SQL语句
|
||
:param params: 参数
|
||
:return: list - 数据列表
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
|
||
for attempt in range(2):
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
logger.warning(f"线程 {thread_id} 数据库连接失败,返回空结果")
|
||
return []
|
||
|
||
cur.execute(sql, params)
|
||
try:
|
||
rows = cur.fetchall()
|
||
if rows and cur.description:
|
||
columns = [desc[0] for desc in cur.description]
|
||
return [dict(zip(columns, row)) for row in rows]
|
||
return []
|
||
except Exception as e:
|
||
if "no results to fetch" in str(e):
|
||
return []
|
||
raise
|
||
except (psycopg2.OperationalError, psycopg2.InterfaceError) as e:
|
||
logger.warning(f"查询数据连接异常,第 {attempt + 1} 次: {sql}, 错误: {e}")
|
||
try:
|
||
if conn and not conn.closed:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
if attempt == 0:
|
||
continue
|
||
logger.error(f"查询数据失败: {sql}, 错误: {e}")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"查询数据失败: {sql}, 错误: {e}")
|
||
return []
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def domain_exists(self, domain):
|
||
"""
|
||
检查域名是否存在
|
||
|
||
:param domain: 域名
|
||
:return: bool - 是否存在
|
||
"""
|
||
# 尝试使用布隆过滤器
|
||
if self.use_redis and self.use_bloom_filter:
|
||
try:
|
||
if not self.redis_client.execute_command('BF.EXISTS', 'domain_bloom', domain):
|
||
# 布隆过滤器判断不存在,直接返回 False
|
||
return False
|
||
except Exception as e:
|
||
logger.warning(f"布隆过滤器查询失败: {e}")
|
||
|
||
# 尝试使用 Redis 缓存
|
||
if self.use_redis:
|
||
try:
|
||
if self.redis_client.exists(f"domain:{domain}"):
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"Redis 查询失败: {e}")
|
||
|
||
# 缓存未命中或 Redis 不可用,查询数据库
|
||
sql = "SELECT id FROM domains WHERE domain = %s"
|
||
result = self.fetch_one(sql, (domain,))
|
||
|
||
# 将结果存入缓存和布隆过滤器
|
||
if self.use_redis and result:
|
||
try:
|
||
pipe = self.redis_client.pipeline()
|
||
pipe.set(f"domain:{domain}", 1, ex=2592000) # 1个月过期
|
||
if self.use_bloom_filter:
|
||
pipe.execute_command('BF.ADD', 'domain_bloom', domain)
|
||
pipe.execute()
|
||
except Exception as e:
|
||
logger.warning(f"Redis 存储失败: {e}")
|
||
|
||
return result is not None
|
||
|
||
def check_domains_exist(self, domains):
|
||
"""
|
||
批量检查域名是否存在
|
||
|
||
:param domains: 域名列表
|
||
:return: list - 存在的域名列表
|
||
"""
|
||
if not domains:
|
||
return []
|
||
|
||
# 尝试使用布隆过滤器快速过滤
|
||
if self.use_redis and self.use_bloom_filter:
|
||
try:
|
||
# 分批使用布隆过滤器过滤不存在的域名
|
||
possibly_exist = []
|
||
batch_size = 10000
|
||
|
||
for i in range(0, len(domains), batch_size):
|
||
batch = domains[i:i+batch_size]
|
||
# 使用管道批量执行布隆过滤器查询
|
||
pipe = self.redis_client.pipeline()
|
||
for domain in batch:
|
||
pipe.execute_command('BF.EXISTS', 'domain_bloom', domain)
|
||
results = pipe.execute()
|
||
# 处理结果
|
||
for domain, exists in zip(batch, results):
|
||
if exists:
|
||
possibly_exist.append(domain)
|
||
|
||
# 每处理一批,记录一次进度
|
||
if (i + len(batch)) % (batch_size * 10) == 0:
|
||
logger.info(f"布隆过滤器已过滤 {min(i+len(batch), len(domains))}/{len(domains)} 个域名")
|
||
|
||
# 如果布隆过滤器判断所有域名都不存在,直接返回空列表
|
||
if not possibly_exist:
|
||
logger.info(f"布隆过滤器快速过滤: {len(domains)} 个域名不存在")
|
||
return []
|
||
|
||
# 只查询可能存在的域名
|
||
domains = possibly_exist
|
||
logger.info(f"布隆过滤器过滤后,剩余 {len(domains)} 个域名需要查询数据库")
|
||
except Exception as e:
|
||
logger.warning(f"布隆过滤器批量查询失败: {e}")
|
||
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
|
||
try:
|
||
# 从连接池获取连接
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
# 数据库连接失败,返回空列表
|
||
logger.warning(f"线程 {thread_id} 数据库连接失败,返回空列表")
|
||
return []
|
||
|
||
# 分批查询数据库
|
||
existing_domains = []
|
||
batch_size = 10000
|
||
|
||
for i in range(0, len(domains), batch_size):
|
||
batch = domains[i:i+batch_size]
|
||
|
||
# 使用IN子句批量查询
|
||
placeholders = ','.join(['%s'] * len(batch))
|
||
sql = f"SELECT domain FROM domains WHERE domain IN ({placeholders})"
|
||
|
||
cur.execute(sql, batch)
|
||
rows = cur.fetchall()
|
||
batch_existing = [row[0] for row in rows]
|
||
existing_domains.extend(batch_existing)
|
||
|
||
# 每处理一批,记录一次进度
|
||
if (i + len(batch)) % (batch_size * 10) == 0:
|
||
logger.info(f"数据库已查询 {min(i+len(batch), len(domains))}/{len(domains)} 个域名")
|
||
|
||
# 将实际存在的域名添加到缓存,分批进行
|
||
if self.use_redis and existing_domains:
|
||
try:
|
||
batch_size = 10000
|
||
for i in range(0, len(existing_domains), batch_size):
|
||
batch = existing_domains[i:i+batch_size]
|
||
pipe = self.redis_client.pipeline()
|
||
for domain in batch:
|
||
pipe.set(f"domain:{domain}", 1, ex=3600)
|
||
pipe.execute()
|
||
except Exception as e:
|
||
logger.warning(f"Redis 批量存储失败: {e}")
|
||
|
||
logger.info(f"批量检查域名完成,发现 {len(existing_domains)} 个已存在域名")
|
||
return existing_domains
|
||
except Exception as e:
|
||
logger.error(f"批量检查域名存在失败: {e}")
|
||
return []
|
||
finally:
|
||
# 将连接放回连接池
|
||
self.close(conn, cur)
|
||
|
||
def add_domain(self, domain, tld, source_type):
|
||
"""
|
||
添加域名
|
||
|
||
:param domain: 域名
|
||
:param tld: 顶级域名
|
||
:param source_type: 来源类型
|
||
:return: int - 域名ID
|
||
"""
|
||
sql = """
|
||
INSERT INTO domains (domain, tld, source_type, use_status, detect_status, register_status, backlink_count)
|
||
VALUES (%s, %s, %s, 0, 0, 0, 0)
|
||
ON CONFLICT (domain) DO NOTHING
|
||
RETURNING id
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
|
||
try:
|
||
# 从连接池获取连接
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
# 数据库连接失败,返回None
|
||
logger.warning(f"线程 {thread_id} 数据库连接失败,返回None")
|
||
return None
|
||
|
||
cur.execute(sql, (domain, tld, source_type))
|
||
result = cur.fetchone()
|
||
conn.commit()
|
||
|
||
# 如果域名已存在,返回None
|
||
if not result:
|
||
return None
|
||
|
||
domain_id = result[0]
|
||
|
||
# 将结果存入缓存和布隆过滤器
|
||
if self.use_redis:
|
||
try:
|
||
pipe = self.redis_client.pipeline()
|
||
pipe.set(f"domain:{domain}", 1, ex=2592000) # 1个月过期
|
||
if self.use_bloom_filter:
|
||
pipe.execute_command('BF.ADD', 'domain_bloom', domain)
|
||
pipe.execute()
|
||
except Exception as e:
|
||
logger.warning(f"Redis 存储失败: {e}")
|
||
|
||
return domain_id
|
||
except Exception as e:
|
||
logger.error(f"添加域名失败: {e}")
|
||
if conn:
|
||
try:
|
||
conn.rollback()
|
||
except:
|
||
pass
|
||
return None
|
||
finally:
|
||
# 将连接放回连接池
|
||
self.close(conn, cur)
|
||
|
||
def add_domains_batch(self, domains):
|
||
"""
|
||
批量添加域名
|
||
|
||
:param domains: 域名列表,每个元素为 (domain, tld, source_type)
|
||
:return: int - 添加成功的数量
|
||
"""
|
||
if not domains:
|
||
return 0
|
||
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
|
||
try:
|
||
total_added = 0
|
||
batch_size = 1000
|
||
|
||
# 从连接池获取连接
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
# 数据库连接失败,返回0
|
||
logger.warning(f"线程 {thread_id} 数据库连接失败,返回0")
|
||
return 0
|
||
|
||
# 分批处理
|
||
for i in range(0, len(domains), batch_size):
|
||
batch = domains[i:i+batch_size]
|
||
|
||
# 使用批量插入语法
|
||
placeholders = ','.join(['(%s, %s, %s, 0, 0, 0, 0)'] * len(batch))
|
||
sql = f"""
|
||
INSERT INTO domains (domain, tld, source_type, use_status, detect_status, register_status, backlink_count)
|
||
VALUES {placeholders}
|
||
ON CONFLICT (domain) DO NOTHING
|
||
"""
|
||
|
||
# 扁平化数据
|
||
data = []
|
||
domain_names = []
|
||
for domain, tld, source_type in batch:
|
||
data.extend([domain, tld, source_type])
|
||
domain_names.append(domain)
|
||
|
||
cur.execute(sql, data)
|
||
added_count = cur.rowcount
|
||
total_added += added_count
|
||
conn.commit()
|
||
|
||
# 为新添加的域名创建检测任务
|
||
if added_count > 0:
|
||
# 获取刚添加的域名ID
|
||
placeholders = ','.join(['%s'] * len(batch))
|
||
sql = f"SELECT id, domain FROM domains WHERE domain IN ({placeholders})"
|
||
cur.execute(sql, domain_names)
|
||
rows = cur.fetchall()
|
||
domain_ids = [row[0] for row in rows]
|
||
|
||
# 将新添加的域名添加到 Redis 缓存和布隆过滤器
|
||
if self.use_redis:
|
||
try:
|
||
pipe = self.redis_client.pipeline()
|
||
for row in rows:
|
||
domain = row[1]
|
||
pipe.set(f"domain:{domain}", 1, ex=2592000) # 1个月过期
|
||
if self.use_bloom_filter:
|
||
pipe.execute_command('BF.ADD', 'domain_bloom', domain)
|
||
pipe.execute()
|
||
except Exception as e:
|
||
logger.warning(f"Redis 批量存储失败: {e}")
|
||
|
||
# 批量创建检测任务
|
||
if domain_ids:
|
||
task_placeholders = ','.join(['(%s, 1, 0, 0, 0)'] * len(domain_ids))
|
||
task_sql = f"""
|
||
INSERT INTO detect_tasks (domain_id, task_type, status, priority, retry_count)
|
||
VALUES {task_placeholders}
|
||
"""
|
||
task_data = []
|
||
for domain_id in domain_ids:
|
||
task_data.append(domain_id)
|
||
|
||
cur.execute(task_sql, task_data)
|
||
conn.commit()
|
||
|
||
# 每处理一批,记录一次进度
|
||
if (i + len(batch)) % (batch_size * 10) == 0:
|
||
logger.info(f"已添加 {i + len(batch)}/{len(domains)} 个域名")
|
||
|
||
logger.info(f"批量添加域名完成,成功添加 {total_added} 个域名")
|
||
return total_added
|
||
except Exception as e:
|
||
logger.error(f"批量添加域名失败: {e}")
|
||
if conn:
|
||
try:
|
||
conn.rollback()
|
||
except:
|
||
pass
|
||
return 0
|
||
finally:
|
||
# 将连接放回连接池
|
||
self.close(conn, cur)
|
||
|
||
def get_domain_by_id(self, domain_id):
|
||
"""
|
||
根据ID获取域名
|
||
|
||
:param domain_id: 域名ID
|
||
:return: dict - 域名信息
|
||
"""
|
||
sql = "SELECT * FROM domains WHERE id = %s"
|
||
return self.fetch_one(sql, (domain_id,))
|
||
|
||
def get_domain_by_name(self, domain):
|
||
"""
|
||
根据域名获取信息
|
||
|
||
:param domain: 域名
|
||
:return: dict - 域名信息
|
||
"""
|
||
sql = "SELECT * FROM domains WHERE domain = %s"
|
||
return self.fetch_one(sql, (domain,))
|
||
|
||
def update_domain_use_status(self, domain_id, status):
|
||
"""
|
||
更新域名使用状态
|
||
|
||
:param domain_id: 域名ID
|
||
:param status: 状态
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE domains SET use_status = %s WHERE id = %s"
|
||
return self.execute(sql, (status, domain_id))
|
||
|
||
def update_domain_detect_status(self, domain_id, status):
|
||
"""
|
||
更新域名检测状态
|
||
|
||
:param domain_id: 域名ID
|
||
:param status: 状态
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
if status == DETECT_STATUS_COMPLETED:
|
||
sql = "UPDATE domains SET detect_status = %s, detect_time = CURRENT_TIMESTAMP WHERE id = %s"
|
||
else:
|
||
sql = "UPDATE domains SET detect_status = %s WHERE id = %s"
|
||
return self.execute(sql, (status, domain_id))
|
||
|
||
def update_domain_detect_status_batch(self, items):
|
||
"""
|
||
批量更新域名检测状态,减少高并发失败风暴时的写库往返。
|
||
"""
|
||
normalized_items = []
|
||
for item in items or []:
|
||
try:
|
||
domain_id, status = item
|
||
normalized_items.append((int(domain_id), int(status)))
|
||
except Exception:
|
||
continue
|
||
if not normalized_items:
|
||
return 0
|
||
|
||
values_sql = ", ".join(["(%s, %s)"] * len(normalized_items))
|
||
params = []
|
||
for domain_id, status in normalized_items:
|
||
params.extend([domain_id, status])
|
||
|
||
sql = f"""
|
||
UPDATE domains AS d
|
||
SET detect_status = v.detect_status,
|
||
detect_time = CASE
|
||
WHEN v.detect_status = %s THEN CURRENT_TIMESTAMP
|
||
ELSE d.detect_time
|
||
END
|
||
FROM (
|
||
VALUES {values_sql}
|
||
) AS v(domain_id, detect_status)
|
||
WHERE d.id = v.domain_id
|
||
"""
|
||
params = [DETECT_STATUS_COMPLETED] + params
|
||
return len(normalized_items) if self.execute(sql, tuple(params)) else -1
|
||
|
||
def recycle_running_domains(self, target_status):
|
||
"""
|
||
回收异常中断后遗留的“检测中”状态。
|
||
|
||
:param target_status: 目标状态,通常使用 DETECT_STATUS_FAILED
|
||
:return: int - 影响行数
|
||
"""
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
logger.error("回收检测中状态失败: 无法获取数据库连接")
|
||
return 0
|
||
try:
|
||
cur.execute(
|
||
"UPDATE domains SET detect_status = %s WHERE detect_status = %s",
|
||
(target_status, DETECT_STATUS_RUNNING)
|
||
)
|
||
affected = cur.rowcount or 0
|
||
conn.commit()
|
||
return affected
|
||
except Exception as e:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"回收检测中状态失败: {e}")
|
||
return 0
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def update_domain_third_party_status(self, domain_id, field_name, status):
|
||
"""
|
||
更新第三方平台检测状态。
|
||
"""
|
||
if field_name not in {'jucha_status', 'juziseo_status'}:
|
||
logger.error(f"不支持的第三方状态字段: {field_name}")
|
||
return False
|
||
sql = f"UPDATE domains SET {field_name} = %s WHERE id = %s"
|
||
return self.execute(sql, (status, domain_id))
|
||
|
||
def mark_jucha_detected(self, domain_id):
|
||
return self.update_domain_third_party_status(domain_id, 'jucha_status', THIRD_PARTY_STATUS_DONE)
|
||
|
||
def mark_juziseo_detected(self, domain_id):
|
||
return self.update_domain_third_party_status(domain_id, 'juziseo_status', THIRD_PARTY_STATUS_DONE)
|
||
|
||
def reset_optional_detection_statuses(self, domain_id, *, jucha=False, juziseo=False):
|
||
fields = []
|
||
params = []
|
||
if jucha:
|
||
fields.append("jucha_status = 0")
|
||
if juziseo:
|
||
fields.append("juziseo_status = 0")
|
||
if not fields:
|
||
return True
|
||
sql = f"UPDATE domains SET {', '.join(fields)} WHERE id = %s"
|
||
params.append(domain_id)
|
||
return self.execute(sql, tuple(params))
|
||
|
||
def update_domain_expire_date(self, domain_id, expire_date):
|
||
"""
|
||
更新域名过期时间
|
||
|
||
:param domain_id: 域名ID
|
||
:param expire_date: 过期时间
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE domains SET expire_date = %s WHERE id = %s"
|
||
return self.execute(sql, (expire_date, domain_id))
|
||
|
||
def get_domains_to_detect(self, limit=1000, detect_options=None):
|
||
"""
|
||
获取需要检测的域名
|
||
|
||
:param limit: 限制数量
|
||
:return: list - 域名列表
|
||
"""
|
||
detect_options = detect_options or {}
|
||
conditions = [
|
||
"detect_status IN (%s, %s)",
|
||
"(use_status = 0 AND detect_status = %s AND register_status = %s AND expire_date < CURRENT_DATE)",
|
||
]
|
||
params = [
|
||
DETECT_STATUS_PENDING,
|
||
DETECT_STATUS_FAILED,
|
||
DETECT_STATUS_COMPLETED,
|
||
REGISTER_STATUS_REGISTERED,
|
||
]
|
||
|
||
if detect_options.get('detect_jucha'):
|
||
conditions.append("(detect_status <> %s AND jucha_status = 0)")
|
||
params.append(DETECT_STATUS_BLACKLISTED)
|
||
|
||
if detect_options.get('detect_juziseo'):
|
||
conditions.append("(detect_status <> %s AND juziseo_status = 0)")
|
||
params.append(DETECT_STATUS_BLACKLISTED)
|
||
|
||
sql = f"""
|
||
SELECT id, domain, source_type, register_status, detect_status, use_status, expire_date, jucha_status, juziseo_status
|
||
FROM domains
|
||
WHERE {" OR ".join(conditions)}
|
||
ORDER BY id ASC
|
||
LIMIT %s
|
||
"""
|
||
params.append(limit)
|
||
return self.fetch_all(sql, tuple(params))
|
||
|
||
def get_all_sensitive_words(self):
|
||
"""
|
||
获取所有敏感词
|
||
|
||
:return: list - 敏感词列表
|
||
"""
|
||
try:
|
||
sql = "SELECT word FROM sensitive_words"
|
||
results = self.fetch_all(sql)
|
||
sensitive_words = []
|
||
for row in results:
|
||
if isinstance(row, dict) and 'word' in row:
|
||
sensitive_words.append(row['word'])
|
||
return sensitive_words
|
||
except Exception as e:
|
||
logger.error(f"获取敏感词失败: {e}")
|
||
return []
|
||
|
||
def add_to_blacklist(self, domain, reason):
|
||
"""
|
||
将域名加入黑名单
|
||
|
||
:param domain: 域名
|
||
:param reason: 加入黑名单的原因
|
||
:return: bool - 是否操作成功
|
||
"""
|
||
sql = """
|
||
INSERT INTO blacklist (domain, reason, created_at)
|
||
VALUES (%s, %s, NOW())
|
||
ON CONFLICT (domain) DO UPDATE
|
||
SET reason = %s, updated_at = NOW()
|
||
"""
|
||
return self.execute(sql, (domain, reason, reason))
|
||
|
||
def mark_domain_blacklisted(self, domain_id, domain, reason):
|
||
"""
|
||
在一个事务中同时更新域名黑名单状态和黑名单表,减少热路径往返。
|
||
"""
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect()
|
||
if not conn or not cur:
|
||
logger.error("标记域名黑名单失败: 无法获取数据库连接")
|
||
return False
|
||
cur.execute(
|
||
"UPDATE domains SET detect_status = %s WHERE id = %s",
|
||
(DETECT_STATUS_BLACKLISTED, domain_id),
|
||
)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO blacklist (domain, reason, created_at)
|
||
VALUES (%s, %s, NOW())
|
||
ON CONFLICT (domain) DO UPDATE
|
||
SET reason = %s, updated_at = NOW()
|
||
""",
|
||
(domain, reason, reason),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
except Exception as e:
|
||
try:
|
||
if conn:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"标记域名黑名单失败: {e}")
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def update_domain_register_status(self, domain_id, status):
|
||
"""
|
||
更新域名注册状态
|
||
|
||
:param domain_id: 域名ID
|
||
:param status: 状态
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE domains SET register_status = %s WHERE id = %s"
|
||
return self.execute(sql, (status, domain_id))
|
||
|
||
def update_domain_register_result(self, domain_id, status, expire_date=None):
|
||
"""
|
||
一次性更新注册状态及过期时间,减少热路径往返次数。
|
||
|
||
:param domain_id: 域名ID
|
||
:param status: 注册状态
|
||
:param expire_date: 过期时间;为空时保持原值
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
normalized_expire_date = str(expire_date).strip() if expire_date not in (None, "") else None
|
||
sql = """
|
||
UPDATE domains
|
||
SET register_status = %s,
|
||
expire_date = COALESCE(%s, expire_date)
|
||
WHERE id = %s
|
||
"""
|
||
return self.execute(sql, (status, normalized_expire_date, domain_id))
|
||
|
||
def update_domain_beian_info(self, domain_id, company_type, website_url, has_beian, beian_year):
|
||
"""
|
||
更新域名备案信息
|
||
|
||
:param domain_id: 域名ID
|
||
:param company_type: 单位性质
|
||
:param website_url: 网站首页网址
|
||
:param has_beian: 是否备案
|
||
:param beian_year: 备案年份
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE domains SET company_type = %s, website_url = %s, has_beian = %s, beian_year = %s WHERE id = %s"
|
||
return self.execute(sql, (company_type, website_url, has_beian, beian_year, domain_id))
|
||
|
||
def update_domain_beian_info_and_mark_jucha_detected(
|
||
self,
|
||
domain_id,
|
||
company_type=None,
|
||
website_url=None,
|
||
has_beian=None,
|
||
beian_year=None,
|
||
):
|
||
"""
|
||
一次性更新备案信息并标记聚查已完成,减少热路径写库次数。
|
||
"""
|
||
sql = """
|
||
UPDATE domains
|
||
SET company_type = COALESCE(%s, company_type),
|
||
website_url = COALESCE(%s, website_url),
|
||
has_beian = COALESCE(%s, has_beian),
|
||
beian_year = COALESCE(%s, beian_year),
|
||
jucha_status = %s
|
||
WHERE id = %s
|
||
"""
|
||
return self.execute(
|
||
sql,
|
||
(company_type, website_url, has_beian, beian_year, THIRD_PARTY_STATUS_DONE, domain_id),
|
||
)
|
||
|
||
def update_domain_review_status(self, domain_id, review_status):
|
||
"""
|
||
更新域名复核状态
|
||
|
||
:param domain_id: 域名ID
|
||
:param review_status: 复核状态
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE domains SET review_status = %s WHERE id = %s"
|
||
return self.execute(sql, (review_status, domain_id))
|
||
|
||
def update_domain_review_status_batch(self, items):
|
||
"""
|
||
批量更新域名复核状态。
|
||
"""
|
||
normalized_items = []
|
||
for item in items or []:
|
||
try:
|
||
domain_id, review_status = item
|
||
normalized_items.append((int(domain_id), int(review_status)))
|
||
except Exception:
|
||
continue
|
||
if not normalized_items:
|
||
return 0
|
||
|
||
values_sql = ", ".join(["(%s, %s)"] * len(normalized_items))
|
||
params = []
|
||
for domain_id, review_status in normalized_items:
|
||
params.extend([domain_id, review_status])
|
||
|
||
sql = f"""
|
||
UPDATE domains AS d
|
||
SET review_status = v.review_status
|
||
FROM (
|
||
VALUES {values_sql}
|
||
) AS v(domain_id, review_status)
|
||
WHERE d.id = v.domain_id
|
||
"""
|
||
return len(normalized_items) if self.execute(sql, tuple(params)) else -1
|
||
|
||
def update_domain_snapshot_years(self, domain_id, years):
|
||
"""
|
||
更新域名快照年份
|
||
|
||
:param domain_id: 域名ID
|
||
:param years: 年份字符串
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE domains SET snapshot_years = %s WHERE id = %s"
|
||
return self.execute(sql, (years, domain_id))
|
||
|
||
def update_domain_wayback_summary(self, domain_id, years=None, backlink_count=None):
|
||
"""
|
||
一次性更新时光机摘要字段,减少 domains 表写入次数。
|
||
"""
|
||
sql = """
|
||
UPDATE domains
|
||
SET snapshot_years = COALESCE(%s, snapshot_years),
|
||
backlink_count = COALESCE(%s, backlink_count)
|
||
WHERE id = %s
|
||
"""
|
||
return self.execute(sql, (years, backlink_count, domain_id))
|
||
|
||
def complete_domain_detection(self, domain_id, *, register_status, use_status, expire_date):
|
||
"""
|
||
一次性完成域名完成态、待复核态、过期时间归零逻辑。
|
||
|
||
:param domain_id: 域名ID
|
||
:param register_status: 当前注册状态
|
||
:param use_status: 当前使用状态
|
||
:param expire_date: 当前过期时间
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
has_expire_date = bool(expire_date)
|
||
sql = """
|
||
UPDATE domains
|
||
SET detect_status = %s,
|
||
detect_time = CURRENT_TIMESTAMP,
|
||
expire_date = CASE
|
||
WHEN %s = %s AND %s = 0 AND %s THEN NULL
|
||
ELSE expire_date
|
||
END,
|
||
review_status = CASE
|
||
WHEN %s = %s THEN %s
|
||
ELSE review_status
|
||
END
|
||
WHERE id = %s
|
||
"""
|
||
return self.execute(
|
||
sql,
|
||
(
|
||
DETECT_STATUS_COMPLETED,
|
||
register_status,
|
||
REGISTER_STATUS_AVAILABLE,
|
||
use_status,
|
||
has_expire_date,
|
||
register_status,
|
||
REGISTER_STATUS_AVAILABLE,
|
||
REVIEW_STATUS_PENDING,
|
||
domain_id,
|
||
),
|
||
)
|
||
|
||
def complete_domain_detection_batch(self, items):
|
||
"""
|
||
批量更新域名完成态,减少 completed 尾部的单条写库往返。
|
||
"""
|
||
normalized_items = []
|
||
for item in items or []:
|
||
try:
|
||
domain_id, register_status, use_status, has_expire_date = item
|
||
normalized_items.append(
|
||
(
|
||
int(domain_id),
|
||
int(register_status),
|
||
int(use_status),
|
||
bool(has_expire_date),
|
||
)
|
||
)
|
||
except Exception:
|
||
continue
|
||
if not normalized_items:
|
||
return 0
|
||
|
||
values_sql = ", ".join(["(%s, %s, %s, %s)"] * len(normalized_items))
|
||
params = []
|
||
for domain_id, register_status, use_status, has_expire_date in normalized_items:
|
||
params.extend([domain_id, register_status, use_status, has_expire_date])
|
||
|
||
sql = f"""
|
||
UPDATE domains AS d
|
||
SET detect_status = %s,
|
||
detect_time = CURRENT_TIMESTAMP,
|
||
expire_date = CASE
|
||
WHEN v.register_status = %s AND v.use_status = 0 AND v.has_expire_date THEN NULL
|
||
ELSE d.expire_date
|
||
END,
|
||
review_status = CASE
|
||
WHEN v.register_status = %s THEN %s
|
||
ELSE d.review_status
|
||
END
|
||
FROM (
|
||
VALUES {values_sql}
|
||
) AS v(domain_id, register_status, use_status, has_expire_date)
|
||
WHERE d.id = v.domain_id
|
||
"""
|
||
params = [
|
||
DETECT_STATUS_COMPLETED,
|
||
REGISTER_STATUS_AVAILABLE,
|
||
REGISTER_STATUS_AVAILABLE,
|
||
REVIEW_STATUS_PENDING,
|
||
] + params
|
||
return len(normalized_items) if self.execute(sql, tuple(params)) else -1
|
||
|
||
def create_detect_task(self, domain_id, task_type, priority=0):
|
||
"""
|
||
创建检测任务
|
||
|
||
:param domain_id: 域名ID
|
||
:param task_type: 任务类型
|
||
:param priority: 优先级
|
||
:return: int - 任务ID
|
||
"""
|
||
sql = """
|
||
INSERT INTO detect_tasks (domain_id, task_type, status, priority, retry_count)
|
||
VALUES (%s, %s, 0, %s, 0)
|
||
RETURNING id
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
return None
|
||
cur.execute(sql, (domain_id, task_type, priority))
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
return row[0] if row else None
|
||
except Exception as e:
|
||
logger.error(f"创建检测任务失败: {e}")
|
||
if conn:
|
||
conn.rollback()
|
||
return None
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def get_pending_task(self):
|
||
"""
|
||
获取待执行的任务
|
||
|
||
:return: dict - 任务信息
|
||
"""
|
||
sql = """
|
||
SELECT * FROM detect_tasks
|
||
WHERE status = 0
|
||
ORDER BY priority DESC, create_time ASC
|
||
LIMIT 1
|
||
"""
|
||
return self.fetch_one(sql)
|
||
|
||
def get_task_by_id(self, task_id):
|
||
"""
|
||
根据ID获取任务
|
||
|
||
:param task_id: 任务ID
|
||
:return: dict - 任务信息
|
||
"""
|
||
sql = "SELECT * FROM detect_tasks WHERE id = %s"
|
||
return self.fetch_one(sql, (task_id,))
|
||
|
||
def update_task_status(self, task_id, status):
|
||
"""
|
||
更新任务状态
|
||
|
||
:param task_id: 任务ID
|
||
:param status: 状态
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE detect_tasks SET status = %s WHERE id = %s"
|
||
return self.execute(sql, (status, task_id))
|
||
|
||
def update_task_retry_count(self, task_id, retry_count):
|
||
"""
|
||
更新任务重试次数
|
||
|
||
:param task_id: 任务ID
|
||
:param retry_count: 重试次数
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
sql = "UPDATE detect_tasks SET retry_count = %s WHERE id = %s"
|
||
return self.execute(sql, (retry_count, task_id))
|
||
|
||
def get_failed_tasks(self):
|
||
"""
|
||
获取失败的任务
|
||
|
||
:return: list - 任务列表
|
||
"""
|
||
sql = "SELECT * FROM detect_tasks WHERE status = 3"
|
||
return self.fetch_all(sql)
|
||
|
||
def clear_completed_tasks(self, days):
|
||
"""
|
||
清理已完成的任务
|
||
|
||
:param days: 保留天数
|
||
:return: int - 清理的任务数量
|
||
"""
|
||
sql = "DELETE FROM detect_tasks WHERE status = 2 AND create_time < NOW() - (%s * INTERVAL '1 day')"
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
return 0
|
||
cur.execute(sql, (days,))
|
||
count = cur.rowcount
|
||
conn.commit()
|
||
return count
|
||
except Exception as e:
|
||
logger.error(f"清理已完成任务失败: {e}")
|
||
if conn:
|
||
conn.rollback()
|
||
return 0
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def add_to_blacklist(self, domain, reason):
|
||
"""
|
||
添加到黑名单
|
||
|
||
:param domain: 域名
|
||
:param reason: 原因
|
||
:return: bool - 是否添加成功
|
||
"""
|
||
sql = """
|
||
INSERT INTO domain_blacklist (domain, reason)
|
||
VALUES (%s, %s)
|
||
ON CONFLICT (domain) DO NOTHING
|
||
"""
|
||
return self.execute(sql, (domain, reason))
|
||
|
||
def is_blacklisted(self, domain):
|
||
"""
|
||
检查域名是否在黑名单中
|
||
|
||
:param domain: 域名
|
||
:return: bool - 是否在黑名单中
|
||
"""
|
||
sql = "SELECT id FROM domain_blacklist WHERE domain = %s"
|
||
result = self.fetch_one(sql, (domain,))
|
||
return result is not None
|
||
|
||
def add_detection_result(self, domain_id, baidu_history, baidu_site, qihu360_site, google_site, chinaz_info, aizhan_info, juziseo_info, jucha_info):
|
||
"""
|
||
添加检测结果
|
||
|
||
:param domain_id: 域名ID
|
||
:param baidu_history: 百度历史
|
||
:param baidu_site: 百度site
|
||
:param qihu360_site: 360 site
|
||
:param google_site: Google site
|
||
:param chinaz_info: 站长之家信息
|
||
:param aizhan_info: 爱站网信息
|
||
:param juziseo_info: 桔子SEO信息
|
||
:param jucha_info: 聚查信息
|
||
:return: bool - 是否添加成功
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
return False
|
||
payload = (
|
||
json.dumps(baidu_history) if baidu_history is not None else None,
|
||
json.dumps(baidu_site) if baidu_site is not None else None,
|
||
json.dumps(qihu360_site) if qihu360_site is not None else None,
|
||
json.dumps(google_site) if google_site is not None else None,
|
||
json.dumps(chinaz_info) if chinaz_info is not None else None,
|
||
json.dumps(aizhan_info) if aizhan_info is not None else None,
|
||
json.dumps(juziseo_info) if juziseo_info is not None else None,
|
||
json.dumps(jucha_info) if jucha_info is not None else None,
|
||
)
|
||
cur.execute("SELECT id FROM domain_detections WHERE domain_id = %s ORDER BY id ASC LIMIT 1", (domain_id,))
|
||
exists = cur.fetchone()
|
||
if exists:
|
||
sql = """
|
||
UPDATE domain_detections
|
||
SET baidu_history = %s, baidu_site = %s, qihu360_site = %s, google_site = %s,
|
||
chinaz_info = %s, aizhan_info = %s, juziseo_info = %s, jucha_info = %s,
|
||
update_time = CURRENT_TIMESTAMP
|
||
WHERE domain_id = %s
|
||
"""
|
||
cur.execute(sql, payload + (domain_id,))
|
||
else:
|
||
sql = """
|
||
INSERT INTO domain_detections (
|
||
domain_id, baidu_history, baidu_site, qihu360_site, google_site,
|
||
chinaz_info, aizhan_info, juziseo_info, jucha_info
|
||
)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
"""
|
||
cur.execute(sql, (domain_id,) + payload)
|
||
conn.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"添加检测结果失败: {e}")
|
||
if conn:
|
||
conn.rollback()
|
||
return False
|
||
finally:
|
||
self.close(conn, cur)
|
||
|
||
def get_domains_by_conditions(self, conditions, page=1, page_size=1000):
|
||
"""
|
||
根据条件获取域名
|
||
|
||
:param conditions: 条件
|
||
:param page: 页码,从1开始
|
||
:param page_size: 每页数量
|
||
:return: list - 域名列表
|
||
"""
|
||
# 构建SQL语句
|
||
sql = "SELECT * FROM domains WHERE 1=1"
|
||
params = []
|
||
|
||
if 'register_status' in conditions:
|
||
sql += " AND register_status = %s"
|
||
params.append(conditions['register_status'])
|
||
|
||
if 'use_status' in conditions:
|
||
sql += " AND use_status = %s"
|
||
params.append(conditions['use_status'])
|
||
|
||
if 'detect_status' in conditions:
|
||
sql += " AND detect_status = %s"
|
||
params.append(conditions['detect_status'])
|
||
|
||
if 'review_status' in conditions:
|
||
sql += " AND review_status = %s"
|
||
params.append(conditions['review_status'])
|
||
|
||
if 'has_beian' in conditions:
|
||
sql += " AND has_beian = %s"
|
||
params.append(conditions['has_beian'])
|
||
|
||
if conditions.get('company_type'):
|
||
sql += " AND company_type LIKE %s"
|
||
params.append(f"%{conditions['company_type']}%")
|
||
|
||
if conditions.get('beian_year'):
|
||
sql += " AND beian_year = %s"
|
||
params.append(conditions['beian_year'])
|
||
|
||
if conditions.get('snapshot_year'):
|
||
sql += " AND snapshot_years LIKE %s"
|
||
params.append(f"%{conditions['snapshot_year']}%")
|
||
|
||
# 域名搜索
|
||
if conditions.get('domain'):
|
||
sql += " AND domain LIKE %s"
|
||
params.append(f"%{conditions['domain']}%")
|
||
|
||
# 计算偏移量
|
||
offset = (page - 1) * page_size
|
||
sql += " LIMIT %s OFFSET %s"
|
||
params.extend([page_size, offset])
|
||
|
||
return self.fetch_all(sql, params)
|
||
|
||
def get_domains_count(self, conditions):
|
||
"""
|
||
获取符合条件的域名总数
|
||
|
||
:param conditions: 条件
|
||
:return: int - 域名总数
|
||
"""
|
||
# 构建SQL语句
|
||
sql = """
|
||
SELECT COUNT(DISTINCT d.id)
|
||
FROM domains d
|
||
LEFT JOIN domain_detections dd ON d.id = dd.domain_id
|
||
WHERE 1=1
|
||
"""
|
||
params = []
|
||
|
||
# 只添加非空条件
|
||
if 'register_status' in conditions and conditions['register_status'] is not None:
|
||
sql += " AND d.register_status = %s"
|
||
params.append(conditions['register_status'])
|
||
|
||
if 'use_status' in conditions and conditions['use_status'] is not None:
|
||
sql += " AND d.use_status = %s"
|
||
params.append(conditions['use_status'])
|
||
|
||
if 'detect_status' in conditions and conditions['detect_status'] is not None:
|
||
sql += " AND d.detect_status = %s"
|
||
params.append(conditions['detect_status'])
|
||
|
||
if 'review_status' in conditions and conditions['review_status'] is not None:
|
||
sql += " AND d.review_status = %s"
|
||
params.append(conditions['review_status'])
|
||
|
||
if 'has_beian' in conditions and conditions['has_beian'] is not None:
|
||
sql += " AND d.has_beian = %s"
|
||
params.append(conditions['has_beian'])
|
||
|
||
# 其他条件保持不变
|
||
if conditions.get('beian_year'):
|
||
sql += " AND d.beian_year = %s"
|
||
params.append(conditions['beian_year'])
|
||
|
||
if conditions.get('snapshot_year'):
|
||
sql += " AND d.snapshot_years LIKE %s"
|
||
params.append(f"%{conditions['snapshot_year']}%")
|
||
|
||
# 域名搜索
|
||
if conditions.get('search_keyword'):
|
||
sql += " AND d.domain LIKE %s"
|
||
params.append(f"%{conditions['search_keyword']}%")
|
||
|
||
# 首页网址搜索
|
||
if conditions.get('website_url'):
|
||
sql += " AND d.website_url LIKE %s"
|
||
params.append(f"%{conditions['website_url']}%")
|
||
|
||
if conditions.get('backlink_gt_10') is True:
|
||
sql += " AND COALESCE(dd.backlink_count_gt_10, FALSE) = TRUE"
|
||
|
||
# 打印查询信息
|
||
logger.info(f"执行计数SQL: {sql}")
|
||
logger.info(f"计数参数: {params}")
|
||
|
||
result = self.fetch_one(sql, params)
|
||
if result:
|
||
count = result.get('count', 0)
|
||
logger.info(f"符合条件的域名总数: {count}")
|
||
return count
|
||
return 0
|
||
|
||
def get_domains_with_details(self, conditions, page=1, page_size=1000):
|
||
"""
|
||
获取域名及其详细信息
|
||
|
||
:param conditions: 条件
|
||
:param page: 页码,从1开始
|
||
:param page_size: 每页数量
|
||
:return: list - 域名列表
|
||
"""
|
||
# 构建SQL语句
|
||
sql = """
|
||
SELECT DISTINCT d.*, dd.baidu_site, dd.google_site, dd.qihu360_site, dd.baidu_history,
|
||
dd.chinaz_info, dd.aizhan_info, dd.juziseo_info, dd.jucha_info,
|
||
dd.is_chinese_title, dd.same_url, dd.backlink_count_gt_10
|
||
FROM domains d
|
||
LEFT JOIN domain_detections dd ON d.id = dd.domain_id
|
||
WHERE 1=1
|
||
"""
|
||
params = []
|
||
|
||
# 只添加非空条件
|
||
if 'register_status' in conditions and conditions['register_status'] is not None:
|
||
sql += " AND d.register_status = %s"
|
||
params.append(conditions['register_status'])
|
||
|
||
if 'use_status' in conditions and conditions['use_status'] is not None:
|
||
sql += " AND d.use_status = %s"
|
||
params.append(conditions['use_status'])
|
||
|
||
if 'detect_status' in conditions and conditions['detect_status'] is not None:
|
||
sql += " AND d.detect_status = %s"
|
||
params.append(conditions['detect_status'])
|
||
|
||
if 'review_status' in conditions and conditions['review_status'] is not None:
|
||
sql += " AND d.review_status = %s"
|
||
params.append(conditions['review_status'])
|
||
|
||
if 'has_beian' in conditions and conditions['has_beian'] is not None:
|
||
sql += " AND d.has_beian = %s"
|
||
params.append(conditions['has_beian'])
|
||
|
||
# 其他条件保持不变
|
||
if conditions.get('beian_year'):
|
||
sql += " AND d.beian_year = %s"
|
||
params.append(conditions['beian_year'])
|
||
|
||
if conditions.get('snapshot_year'):
|
||
sql += " AND d.snapshot_years LIKE %s"
|
||
params.append(f"%{conditions['snapshot_year']}%")
|
||
|
||
# 域名搜索
|
||
if conditions.get('search_keyword'):
|
||
sql += " AND d.domain LIKE %s"
|
||
params.append(f"%{conditions['search_keyword']}%")
|
||
|
||
# 首页网址搜索
|
||
if conditions.get('website_url'):
|
||
sql += " AND d.website_url LIKE %s"
|
||
params.append(f"%{conditions['website_url']}%")
|
||
|
||
if conditions.get('backlink_gt_10') is True:
|
||
sql += " AND COALESCE(dd.backlink_count_gt_10, FALSE) = TRUE"
|
||
|
||
sql += " ORDER BY d.id ASC"
|
||
|
||
# 计算偏移量
|
||
offset = (page - 1) * page_size
|
||
sql += " LIMIT %s OFFSET %s"
|
||
params.extend([page_size, offset])
|
||
|
||
# 打印查询信息
|
||
logger.info(f"执行查询SQL: {sql}")
|
||
logger.info(f"查询参数: {params}")
|
||
|
||
result = self.fetch_all(sql, params)
|
||
logger.info(f"查询结果数量: {len(result)}")
|
||
|
||
# 如果没有结果,尝试执行一个简单的查询来检查数据库是否有数据
|
||
if not result:
|
||
simple_sql = "SELECT COUNT(*) FROM domains"
|
||
count_result = self.fetch_one(simple_sql)
|
||
if count_result:
|
||
logger.info(f"数据库中总域名数量: {count_result.get('count', 0)}")
|
||
else:
|
||
logger.warning("无法获取数据库中域名数量")
|
||
|
||
return result
|
||
|
||
def get_domain_statistics(self):
|
||
"""
|
||
获取域名统计信息
|
||
|
||
:return: dict - 统计信息
|
||
"""
|
||
sql = """
|
||
SELECT
|
||
COUNT(*) AS total,
|
||
SUM(CASE WHEN register_status = %s THEN 1 ELSE 0 END) AS available,
|
||
SUM(CASE WHEN register_status = %s THEN 1 ELSE 0 END) AS registered,
|
||
SUM(CASE WHEN detect_status = %s THEN 1 ELSE 0 END) AS blacklisted
|
||
FROM domains
|
||
"""
|
||
result = self.fetch_one(sql, (REGISTER_STATUS_AVAILABLE, REGISTER_STATUS_REGISTERED, DETECT_STATUS_BLACKLISTED))
|
||
if result:
|
||
return {
|
||
'total': result.get('total', 0),
|
||
'available': result.get('available', 0),
|
||
'registered': result.get('registered', 0),
|
||
'blacklisted': result.get('blacklisted', 0)
|
||
}
|
||
return {}
|
||
|
||
def get_task_statistics(self):
|
||
"""
|
||
获取任务统计信息
|
||
|
||
:return: dict - 统计信息
|
||
"""
|
||
sql = """
|
||
SELECT
|
||
COUNT(*) AS total,
|
||
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS pending,
|
||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS running,
|
||
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS completed,
|
||
SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS failed
|
||
FROM detect_tasks
|
||
"""
|
||
result = self.fetch_one(sql)
|
||
if result:
|
||
return {
|
||
'total': result.get('total', 0),
|
||
'pending': result.get('pending', 0),
|
||
'running': result.get('running', 0),
|
||
'completed': result.get('completed', 0),
|
||
'failed': result.get('failed', 0)
|
||
}
|
||
return {}
|
||
|
||
def update_domain_status(self, domain_id, status_type, status_value):
|
||
"""
|
||
更新域名状态
|
||
|
||
:param domain_id: 域名ID
|
||
:param status_type: 状态类型
|
||
:param status_value: 状态值
|
||
:return: bool - 是否更新成功
|
||
"""
|
||
if status_type == 'use_status':
|
||
return self.update_domain_use_status(domain_id, status_value)
|
||
elif status_type == 'detect_status':
|
||
return self.update_domain_detect_status(domain_id, status_value)
|
||
elif status_type == 'register_status':
|
||
return self.update_domain_register_status(domain_id, status_value)
|
||
else:
|
||
logger.error(f"未知的状态类型: {status_type}")
|
||
return False
|
||
|
||
def is_ykj_domain(self, domain_id):
|
||
"""
|
||
检查域名是否为一口价域名
|
||
|
||
:param domain_id: 域名ID
|
||
:return: bool - 是否为一口价域名
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
conn = None
|
||
cur = None
|
||
|
||
try:
|
||
conn, cur = self.connect(thread_id)
|
||
if not conn or not cur:
|
||
logger.warning(f"线程 {thread_id} 数据库连接失败,返回默认值")
|
||
return False
|
||
|
||
sql = "SELECT source_type FROM domains WHERE id = %s"
|
||
cur.execute(sql, (domain_id,))
|
||
try:
|
||
result = cur.fetchone()
|
||
if result:
|
||
# 1 表示聚名一口价
|
||
return result[0] == 1
|
||
return False
|
||
except Exception as e:
|
||
# 处理查询结果为空的情况
|
||
if "no results to fetch" in str(e):
|
||
return False
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"检查一口价域名出错: {e}")
|
||
return False
|
||
finally:
|
||
# 将连接放回连接池
|
||
self.close(conn, cur)
|