- vk_parser: убрать bucket из MINIO_PUBLIC_URL (nginx добавляет /news-media/ сам) - admin_system: добавить endpoint POST /repair-media-urls для обновления старых URL в БД - SystemStatus: кнопка «Починить URL медиа» запускает repair endpoint - .env.example: заменить реальные адреса/пароли на плейсхолдеры Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
173 lines
6.1 KiB
Python
173 lines
6.1 KiB
Python
import os
|
|
import time
|
|
import asyncio
|
|
from urllib.parse import urlparse
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from bd.database import get_db, get_redis, DATABASE_URL, REDIS_URL
|
|
from route.deps import require_admin
|
|
|
|
router = APIRouter()
|
|
|
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
|
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
|
|
|
|
|
|
def _parse_db_url(url: str) -> dict:
|
|
"""Вытащить host/port/db/user из DATABASE_URL без пароля."""
|
|
try:
|
|
# postgresql+asyncpg://user:pass@host:port/db
|
|
p = urlparse(url.replace("+asyncpg", ""))
|
|
return {
|
|
"host": p.hostname or "?",
|
|
"port": p.port or 5432,
|
|
"database": (p.path or "/").lstrip("/") or "?",
|
|
"user": p.username or "?",
|
|
}
|
|
except Exception:
|
|
return {"host": "?", "port": 5432, "database": "?", "user": "?"}
|
|
|
|
|
|
def _parse_redis_url(url: str) -> dict:
|
|
"""Вытащить host/port из REDIS_URL без пароля."""
|
|
try:
|
|
p = urlparse(url)
|
|
return {
|
|
"host": p.hostname or "?",
|
|
"port": p.port or 6379,
|
|
"db": (p.path or "/0").lstrip("/") or "0",
|
|
}
|
|
except Exception:
|
|
return {"host": "?", "port": 6379, "db": "0"}
|
|
|
|
|
|
async def _check_postgres(db: AsyncSession) -> dict:
|
|
info = _parse_db_url(DATABASE_URL)
|
|
t0 = time.monotonic()
|
|
try:
|
|
result = await db.execute(text("SELECT version()"))
|
|
version = result.scalar() or ""
|
|
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
# Извлекаем короткую версию: "PostgreSQL 15.3 ..."
|
|
short_ver = version.split(",")[0] if version else "?"
|
|
return {"ok": True, "latency_ms": latency_ms, "version": short_ver, **info}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e), "latency_ms": None, **info}
|
|
|
|
|
|
async def _check_redis() -> dict:
|
|
info = _parse_redis_url(REDIS_URL)
|
|
t0 = time.monotonic()
|
|
try:
|
|
redis = await get_redis()
|
|
pong = await redis.ping()
|
|
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
server_info = await redis.info("server")
|
|
version = server_info.get("redis_version", "?")
|
|
return {"ok": bool(pong), "latency_ms": latency_ms, "version": f"Redis {version}", **info}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e), "latency_ms": None, **info}
|
|
|
|
|
|
async def _check_minio() -> dict:
|
|
t0 = time.monotonic()
|
|
try:
|
|
from minio import Minio
|
|
loop = asyncio.get_event_loop()
|
|
client = Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
|
|
exists = await loop.run_in_executor(None, client.bucket_exists, MINIO_BUCKET)
|
|
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
return {
|
|
"ok": True,
|
|
"latency_ms": latency_ms,
|
|
"endpoint": MINIO_ENDPOINT,
|
|
"bucket": MINIO_BUCKET,
|
|
"public_url": MINIO_PUBLIC_URL,
|
|
"bucket_exists": exists,
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"ok": False,
|
|
"error": str(e),
|
|
"latency_ms": None,
|
|
"endpoint": MINIO_ENDPOINT,
|
|
"bucket": MINIO_BUCKET,
|
|
"public_url": MINIO_PUBLIC_URL,
|
|
}
|
|
|
|
|
|
@router.get("/status")
|
|
async def system_status(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
|
pg, redis, minio = await asyncio.gather(
|
|
_check_postgres(db),
|
|
_check_redis(),
|
|
_check_minio(),
|
|
)
|
|
return {
|
|
"postgres": pg,
|
|
"redis": redis,
|
|
"minio": minio,
|
|
}
|
|
|
|
|
|
@router.post("/repair-media-urls")
|
|
async def repair_media_urls(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
|
"""Заменяет старые MinIO URL на актуальный MINIO_PUBLIC_URL во всех статьях и медиафайлах.
|
|
|
|
Системный nginx проксирует /media/ → MinIO bucket /news-media/, поэтому bucket исключается из URL.
|
|
Старый формат: http://host:port/news-media/object_path
|
|
Новый формат: MINIO_PUBLIC_URL/object_path (без bucket)
|
|
|
|
Запускать один раз после изменения MINIO_PUBLIC_URL в .env.
|
|
"""
|
|
new_base = MINIO_PUBLIC_URL.rstrip("/")
|
|
|
|
# cover_url: http://host/news-media/path → new_base/path
|
|
r_cover = await db.execute(text(r"""
|
|
UPDATE articles
|
|
SET cover_url = :new_base || '/' ||
|
|
regexp_replace(cover_url, '^https?://[^/]+(:[0-9]+)?/[^/]+/', '')
|
|
WHERE cover_url IS NOT NULL
|
|
AND cover_url LIKE '%/news-media/%'
|
|
AND cover_url NOT LIKE :prefix
|
|
RETURNING id
|
|
"""), {"new_base": new_base, "prefix": new_base + "%"})
|
|
|
|
# HTML-контент: src="http://host/news-media/path" → src="new_base/path"
|
|
r_content = await db.execute(text(r"""
|
|
UPDATE articles
|
|
SET content = regexp_replace(
|
|
content,
|
|
'https?://[^/]+(:[0-9]+)?/[^/]+/([^"'' ]+)',
|
|
:new_base || '/\2',
|
|
'g'
|
|
)
|
|
WHERE content LIKE '%/news-media/%'
|
|
AND content NOT LIKE :prefix
|
|
RETURNING id
|
|
"""), {"new_base": new_base, "prefix": "%" + new_base + "%"})
|
|
|
|
# media.url
|
|
r_media = await db.execute(text(r"""
|
|
UPDATE media
|
|
SET url = :new_base || '/' ||
|
|
regexp_replace(url, '^https?://[^/]+(:[0-9]+)?/[^/]+/', '')
|
|
WHERE url LIKE '%/news-media/%'
|
|
AND url NOT LIKE :prefix
|
|
RETURNING id
|
|
"""), {"new_base": new_base, "prefix": new_base + "%"})
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"new_base": new_base,
|
|
"cover_url_fixed": r_cover.rowcount,
|
|
"content_fixed": r_content.rowcount,
|
|
"media_fixed": r_media.rowcount,
|
|
}
|