# -*- coding: utf-8 -*- """管理端 — 数据库健康监控 API 提供 1 个端点: - GET /api/admin/db-health — 返回 4 个数据库的健康状态 遍历 etl_feiqiu / test_etl_feiqiu / zqyy_app / test_zqyy_app, 对每个库执行诊断 SQL(连接池、大小、慢查询)。 连接失败时返回 disconnected 状态,不抛出 HTTP 错误。 需求: 6.1, 6.2, 6.3, 6.4 """ from __future__ import annotations import logging import os import psycopg2 from fastapi import APIRouter, Depends from app.auth.dependencies import CurrentUser, get_current_user from app.config import ( DB_HOST, DB_PASSWORD, DB_PORT, DB_USER, ETL_DB_HOST, ETL_DB_PASSWORD, ETL_DB_PORT, ETL_DB_USER, ) from app.schemas.admin_db_health import DbHealthItem logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/admin/db-health", tags=["系统管理"]) # 4 个数据库的连接参数:业务库正式/测试 + ETL 库正式/测试 DB_CONFIGS: list[dict] = [ { "db_name": "zqyy_app", "host": DB_HOST, "port": DB_PORT, "user": DB_USER, "password": DB_PASSWORD, "dbname": "zqyy_app", }, { "db_name": "test_zqyy_app", "host": DB_HOST, "port": DB_PORT, "user": DB_USER, "password": DB_PASSWORD, "dbname": "test_zqyy_app", }, { "db_name": "etl_feiqiu", "host": ETL_DB_HOST, "port": ETL_DB_PORT, "user": ETL_DB_USER, "password": ETL_DB_PASSWORD, "dbname": "etl_feiqiu", }, { "db_name": "test_etl_feiqiu", "host": ETL_DB_HOST, "port": ETL_DB_PORT, "user": ETL_DB_USER, "password": ETL_DB_PASSWORD, "dbname": "test_etl_feiqiu", }, ] # 诊断 SQL:连接池状态 _SQL_CONNECTIONS = """ SELECT count(*) FILTER (WHERE state = 'active') AS active_connections, count(*) FILTER (WHERE state = 'idle') AS idle_connections FROM pg_stat_activity WHERE datname = current_database(); """ # 诊断 SQL:数据库大小(MB) _SQL_DB_SIZE = """ SELECT pg_database_size(current_database()) / (1024.0 * 1024.0) AS db_size_mb; """ # 诊断 SQL:慢查询(最近 1 小时内执行时间超过 1 秒) _SQL_SLOW_QUERIES = """ SELECT count(*) AS slow_query_count FROM pg_stat_activity WHERE datname = current_database() AND state = 'active' AND query_start < now() - interval '1 second' AND query_start > now() - interval '1 hour'; """ def _check_single_db(cfg: dict) -> DbHealthItem: """对单个数据库执行诊断,连接失败时返回 disconnected。""" db_name = cfg["db_name"] try: # CHANGE 2026-03-29 | Windows GBK 环境下 psycopg2/libpq 构建连接字符串时 # 会读取系统用户名/计算机名,含中文时触发 UnicodeDecodeError(0xd6 是 GBK 首字节)。 # 用显式 DSN 字符串连接,避免 libpq 自动拼接时混入系统 locale 信息。 dsn = ( f"host={cfg['host']} port={cfg['port']} " f"dbname={cfg['dbname']} user={cfg['user']} " f"password={cfg['password']} " f"connect_timeout=5 client_encoding=UTF8 " f"application_name=neozqyy_health" ) os.environ.setdefault("PGCLIENTENCODING", "UTF8") conn = psycopg2.connect(dsn) except Exception: logger.warning("数据库 %s 连接失败", db_name, exc_info=True) return DbHealthItem(db_name=db_name, status="disconnected") try: with conn.cursor() as cur: # 连接池状态 cur.execute(_SQL_CONNECTIONS) row = cur.fetchone() active_connections = row[0] if row else 0 idle_connections = row[1] if row else 0 # 数据库大小 cur.execute(_SQL_DB_SIZE) row = cur.fetchone() db_size_mb = round(float(row[0]), 2) if row else 0.0 # 慢查询 cur.execute(_SQL_SLOW_QUERIES) row = cur.fetchone() slow_query_count = row[0] if row else 0 return DbHealthItem( db_name=db_name, status="connected", active_connections=active_connections, idle_connections=idle_connections, db_size_mb=db_size_mb, slow_query_count=slow_query_count, ) except Exception: logger.warning("数据库 %s 诊断 SQL 执行失败", db_name, exc_info=True) return DbHealthItem(db_name=db_name, status="disconnected") finally: conn.close() @router.get("", response_model=list[DbHealthItem]) async def get_db_health( user: CurrentUser = Depends(get_current_user), ) -> list[DbHealthItem]: """返回 4 个数据库的健康状态。 遍历 DB_CONFIGS 中的 4 个库,逐一执行诊断 SQL。 连接失败时返回 disconnected 状态,不抛出 HTTP 错误。 即使所有库都连接失败,仍返回 HTTP 200。 """ return [_check_single_db(cfg) for cfg in DB_CONFIGS]