Files
getDomain/domainCheck/app/utils/database.py

4310 lines
163 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)