diff --git a/backend/app/api/public/docs_page.py b/backend/app/api/public/docs_page.py index 5009620..75ec475 100644 --- a/backend/app/api/public/docs_page.py +++ b/backend/app/api/public/docs_page.py @@ -49,6 +49,7 @@ def _build_docs_html(*, base_url: str) -> str: + Random Mage Docs + + +
+
+

+ Status · Random Mage + + + {api_status.upper()} + {api_status_code} + +

+
+ 最后更新:{updated_at} · JSON:{json_url} +
+ +
+ +
+
+

图库概览

+
+
总图片数
{images_total}
images
+
总作品数
{illust_total}
DISTINCT illust_id
+
总作者数
{authors_total}
DISTINCT user_id
+
更多统计
—
+
+
+ +
+

请求概览(/random)

+
+
总请求数
{random_total}
跨重启持久化
+
实时并发
{random_in_flight}
in_flight
+
近 60 秒请求
{last_window_requests}
window
+
近 60 秒成功率
{last_window_success_rate*100:.1f}%
2xx/3xx
+
+
+
+ +
+

占比图

+
+ 选择维度: + + +
+
+
+
+
+ +
+
+ + + + +""" + + +@router.get("/status.json", include_in_schema=False) +async def status_json(request: Request) -> JSONResponse: + rid = get_or_create_request_id(request) + set_request_id_on_state(request, rid) + + engine = request.app.state.engine + api_status = "ok" + api_status_code = 200 + payload: dict[str, Any] = {"api_status": api_status, "api_status_code": api_status_code, "updated_at": iso_utc_ms()} + + stats = getattr(request.app.state, "random_request_stats", None) + if stats is not None: + payload["random"] = asdict(await stats.snapshot()) + else: + payload["random"] = { + "total_requests": 0, + "total_ok": 0, + "total_error": 0, + "in_flight": 0, + "window_seconds": 60, + "last_window_requests": 0, + "last_window_ok": 0, + "last_window_error": 0, + "last_window_success_rate": 0.0, + } + + try: + payload.update(await _query_gallery_stats(engine)) + except Exception as exc: + api_status = "degraded" + api_status_code = 503 + payload["api_status"] = api_status + payload["api_status_code"] = api_status_code + payload["error"] = {"type": type(exc).__name__, "message": str(exc)} + + resp = JSONResponse(status_code=int(api_status_code), content={"ok": api_status_code == 200, "data": payload, "request_id": rid}) + set_request_id_header(resp, rid) + return resp + + +@router.get("/status", include_in_schema=False) +async def status_page(request: Request) -> HTMLResponse: + rid = get_or_create_request_id(request) + set_request_id_on_state(request, rid) + + engine = request.app.state.engine + api_status = "ok" + api_status_code = 200 + + payload: dict[str, Any] = {"api_status": api_status, "api_status_code": api_status_code, "updated_at": iso_utc_ms()} + + stats = getattr(request.app.state, "random_request_stats", None) + if stats is not None: + payload["random"] = asdict(await stats.snapshot()) + + try: + payload.update(await _query_gallery_stats(engine)) + except Exception as exc: + api_status = "degraded" + api_status_code = 503 + payload["api_status"] = api_status + payload["api_status_code"] = api_status_code + payload["error"] = {"type": type(exc).__name__, "message": str(exc)} + + html = _build_status_html( + base_url=str(getattr(request, "base_url", "") or "").rstrip("/"), + status_code=int(api_status_code), + payload=payload, + ) + resp = HTMLResponse(content=html, status_code=int(api_status_code), headers={"Cache-Control": "no-store"}) + set_request_id_header(resp, rid) + return resp diff --git a/backend/app/api/public/wtf_page.py b/backend/app/api/public/wtf_page.py new file mode 100644 index 0000000..83cceae --- /dev/null +++ b/backend/app/api/public/wtf_page.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from app.core.request_id import get_or_create_request_id, set_request_id_header, set_request_id_on_state + +router = APIRouter() + + +def _build_wtf_html(*, base_url: str) -> str: + base = (base_url or "").rstrip("/") + if not base: + base = "" + + def u(path: str) -> str: + path_norm = (path or "").strip() + if not path_norm.startswith("/"): + path_norm = "/" + path_norm + return f"{base}{path_norm}" + + docs_url = u("/docs") + status_url = u("/status") + random_url = u("/random") + + return f""" + + + + + + WTF · 瀑布流 · Random Mage + + + +
+
+

WTF · 瀑布流随机

+
+ 进入即自动加载,往下滑继续出图。默认开启 adaptive=1(移动端偏竖图/更低像素门槛,PC 偏横图/更高门槛;不覆盖你显式传入的过滤参数)。 +
+ +
+ + +
+
+ +
+
加载中…
+
+ + + + +""" + + +@router.get("/wtf", include_in_schema=False) +async def wtf_page(request: Request) -> HTMLResponse: + rid = get_or_create_request_id(request) + set_request_id_on_state(request, rid) + html = _build_wtf_html(base_url=str(getattr(request, "base_url", "") or "").rstrip("/")) + resp = HTMLResponse(content=html, status_code=200, headers={"Cache-Control": "no-store"}) + set_request_id_header(resp, rid) + return resp + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 67c1052..2769f99 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -37,6 +37,7 @@ class Settings: public_api_key_required: bool public_api_key_rpm: int public_api_key_burst: int + random_totals_persist_interval_seconds: int @property def is_prod(self) -> bool: @@ -168,6 +169,14 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings: public_api_key_burst = 0 public_api_key_burst = max(0, min(int(public_api_key_burst), 10_000_000)) + try: + random_totals_persist_interval_seconds = int( + _get(env, "RANDOM_TOTALS_PERSIST_INTERVAL_SECONDS", "15") or "15" + ) + except Exception: + random_totals_persist_interval_seconds = 15 + random_totals_persist_interval_seconds = max(2, min(int(random_totals_persist_interval_seconds), 300)) + settings = Settings( app_env=app_env, database_url=database_url, @@ -187,6 +196,7 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings: public_api_key_required=public_api_key_required, public_api_key_rpm=public_api_key_rpm, public_api_key_burst=public_api_key_burst, + random_totals_persist_interval_seconds=random_totals_persist_interval_seconds, ) if settings.is_prod: diff --git a/backend/app/core/random_request_persistence.py b/backend/app/core/random_request_persistence.py new file mode 100644 index 0000000..433f429 --- /dev/null +++ b/backend/app/core/random_request_persistence.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.core.logging import get_logger +from app.core.time import iso_utc_ms +from app.core.runtime_settings import set_runtime_setting +from app.db.session import with_sqlite_busy_retry + +log = get_logger(__name__) + +RANDOM_TOTALS_KEY = "stats.random.total" + + +def _as_nonneg_int(value: Any) -> int: + try: + i = int(value) + except Exception: + return 0 + return i if i > 0 else 0 + + +async def load_persisted_random_totals(engine: AsyncEngine) -> dict[str, int]: + sql = "SELECT value_json FROM runtime_settings WHERE key = ?;" + + async def _op() -> str | None: + async with engine.connect() as conn: + row = (await conn.exec_driver_sql(sql, (RANDOM_TOTALS_KEY,))).fetchone() + return str(row[0]) if row is not None and row[0] is not None else None + + try: + raw = await with_sqlite_busy_retry(_op) + except Exception: + return {"total_requests": 0, "total_ok": 0, "total_error": 0} + + if raw is None: + return {"total_requests": 0, "total_ok": 0, "total_error": 0} + + try: + data = json.loads(str(raw)) + except Exception: + return {"total_requests": 0, "total_ok": 0, "total_error": 0} + + if not isinstance(data, dict): + return {"total_requests": 0, "total_ok": 0, "total_error": 0} + + return { + "total_requests": _as_nonneg_int(data.get("total_requests")), + "total_ok": _as_nonneg_int(data.get("total_ok")), + "total_error": _as_nonneg_int(data.get("total_error")), + } + + +async def persist_random_totals( + engine: AsyncEngine, + *, + total_requests: int, + total_ok: int, + total_error: int, + source: str, +) -> None: + value = { + "total_requests": max(0, int(total_requests)), + "total_ok": max(0, int(total_ok)), + "total_error": max(0, int(total_error)), + "updated_at": iso_utc_ms(), + "source": str(source or "unknown"), + } + try: + await set_runtime_setting( + engine, + key=RANDOM_TOTALS_KEY, + value=value, + description="persisted /random totals", + updated_by=f"system:{source}", + ) + except Exception as exc: + try: + log.warning("persist_random_totals_failed err=%s", f"{type(exc).__name__}: {exc}") + except Exception: + pass + diff --git a/backend/app/core/random_request_stats.py b/backend/app/core/random_request_stats.py index 4e64b64..eb13073 100644 --- a/backend/app/core/random_request_stats.py +++ b/backend/app/core/random_request_stats.py @@ -32,6 +32,16 @@ class RandomRequestStats: # (monotonic_seconds, ok) self._events: deque[tuple[float, bool]] = deque() + async def set_totals(self, *, total_requests: int, total_ok: int, total_error: int) -> None: + tr = max(0, int(total_requests)) + tok = max(0, int(total_ok)) + terr = max(0, int(total_error)) + async with self._lock: + self._total_requests = tr + self._total_ok = tok + self._total_error = terr + self._in_flight = max(0, int(self._in_flight)) + def _purge(self, *, now_m: float) -> None: cutoff = float(now_m) - float(self._window_s) while self._events: @@ -80,4 +90,3 @@ class RandomRequestStats: last_window_error=int(last_error), last_window_success_rate=float(rate), ) - diff --git a/backend/app/db/random_pick.py b/backend/app/db/random_pick.py index 0394fe2..fd3d917 100644 --- a/backend/app/db/random_pick.py +++ b/backend/app/db/random_pick.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Sequence -from sqlalchemy import distinct, func, or_, select +from sqlalchemy import and_, distinct, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models.image_tags import ImageTag @@ -36,45 +36,73 @@ def _orientation_where_clause(*, orientation: int | None) -> object | None: return Image.orientation == orientation_i -def _clean_tag_names(values: Sequence[str] | None) -> list[str]: +def _clean_tag_groups(values: Sequence[str] | None) -> list[list[str]]: if not values: return [] - out: list[str] = [] - seen: set[str] = set() - for v in values: - name = str(v or "").strip() - if not name: + + groups: list[list[str]] = [] + seen_groups: set[str] = set() + for raw in values: + expr = str(raw or "").strip() + if not expr: continue - if name in seen: + + group: list[str] = [] + seen_in_group: set[str] = set() + for part in expr.split("|"): + name = part.strip() + if not name or name in seen_in_group: + continue + seen_in_group.add(name) + group.append(name) + + if not group: continue - seen.add(name) - out.append(name) - return out + + # Group identity ignores order (girl|boy == boy|girl). + key = "\x1f".join(sorted(group)) + if key in seen_groups: + continue + seen_groups.add(key) + groups.append(group) + + return groups def _included_tags_where_clause(*, tag_names: Sequence[str]) -> object | None: - names = _clean_tag_names(tag_names) - if not names: + groups = _clean_tag_groups(tag_names) + if not groups: return None - subq = ( - select(ImageTag.image_id) - .join(Tag, Tag.id == ImageTag.tag_id) - .where(Tag.name.in_(names)) - .group_by(ImageTag.image_id) - .having(func.count(distinct(Tag.name)) == len(names)) - ) - return Image.id.in_(subq) + + clauses: list[object] = [] + for group in groups: + tag_ids = select(Tag.id).where(Tag.name.in_(group)) + subq = select(distinct(ImageTag.image_id)).where(ImageTag.tag_id.in_(tag_ids)) + clauses.append(Image.id.in_(subq)) + + if not clauses: + return None + return clauses[0] if len(clauses) == 1 else and_(*clauses) def _excluded_tags_where_clause(*, tag_names: Sequence[str]) -> object | None: - names = _clean_tag_names(tag_names) + groups = _clean_tag_groups(tag_names) + if not groups: + return None + + names: list[str] = [] + seen: set[str] = set() + for group in groups: + for name in group: + if name in seen: + continue + seen.add(name) + names.append(name) + if not names: return None - subq = ( - select(ImageTag.image_id) - .join(Tag, Tag.id == ImageTag.tag_id) - .where(Tag.name.in_(names)) - ) + tag_ids = select(Tag.id).where(Tag.name.in_(names)) + subq = select(distinct(ImageTag.image_id)).where(ImageTag.tag_id.in_(tag_ids)) return Image.id.not_in(subq) diff --git a/backend/app/main.py b/backend/app/main.py index 5c49153..9aa10fe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import time @@ -10,6 +11,8 @@ from app.api.admin.router import router as admin_router from app.api.metrics import router as metrics_router from app.api.public.healthz import router as healthz_router from app.api.public.docs_page import router as docs_page_router +from app.api.public.status_page import router as status_page_router +from app.api.public.wtf_page import router as wtf_page_router from app.api.public.authors import router as authors_router from app.api.public.images import router as images_router from app.api.public.legacy import router as legacy_router @@ -21,6 +24,7 @@ from app.core.api_keys import ApiKeyAuthConfig, ApiKeyAuthenticator, ApiKeyRateL from app.core.errors import ApiError, ErrorCode, json_error_response from app.core.logging import configure_logging, get_logger from app.core.metrics import observe_random_result +from app.core.random_request_persistence import load_persisted_random_totals, persist_random_totals from app.core.random_request_stats import RandomRequestStats from app.core.request_id import build_request_id_middleware, get_or_create_request_id, set_request_id_on_state from app.core.security import decode_jwt, parse_bearer_token @@ -81,6 +85,48 @@ def create_app() -> FastAPI: app.state.api_key_limiter = ApiKeyRateLimiter(rpm=int(api_key_cfg.rpm), burst=int(api_key_cfg.burst)) app.state.random_request_stats = RandomRequestStats(window_seconds=60) + @app.on_event("startup") + async def _startup() -> None: # type: ignore[no-redef] + engine = getattr(app.state, "engine", None) + stats = getattr(app.state, "random_request_stats", None) + if engine is None or stats is None: + return + + try: + totals = await load_persisted_random_totals(engine) + await stats.set_totals( + total_requests=int(totals.get("total_requests", 0) or 0), + total_ok=int(totals.get("total_ok", 0) or 0), + total_error=int(totals.get("total_error", 0) or 0), + ) + except Exception: + pass + + try: + interval_s = float((settings.random_totals_persist_interval_seconds or 0) or 15) + except Exception: + interval_s = 15.0 + interval_s = max(2.0, min(float(interval_s), 300.0)) + + async def _loop() -> None: + while True: + await asyncio.sleep(float(interval_s)) + try: + snap = await stats.snapshot() + await persist_random_totals( + engine, + total_requests=int(snap.total_requests), + total_ok=int(snap.total_ok), + total_error=int(snap.total_error), + source="api", + ) + except asyncio.CancelledError: + raise + except Exception: + continue + + app.state.random_totals_persist_task = asyncio.create_task(_loop()) + @app.middleware("http") async def _public_api_key_middleware(request: Request, call_next): # type: ignore[no-redef] if not bool(settings.public_api_key_required): @@ -89,7 +135,7 @@ def create_app() -> FastAPI: path = request.url.path if path.startswith("/admin") or path.startswith("/metrics"): return await call_next(request) - if path in {"/healthz", "/version", "/openapi.json", "/docs", "/api/docs", "/api/redoc"}: + if path in {"/healthz", "/version", "/openapi.json", "/docs", "/status", "/status.json", "/wtf", "/api/docs", "/api/redoc"}: return await call_next(request) rid = get_or_create_request_id(request) @@ -231,6 +277,36 @@ def create_app() -> FastAPI: @app.on_event("shutdown") async def _shutdown() -> None: # type: ignore[no-redef] engine = getattr(app.state, "engine", None) + stats = getattr(app.state, "random_request_stats", None) + if engine is not None and stats is not None: + try: + snap = await asyncio.wait_for(stats.snapshot(), timeout=1.0) + await asyncio.wait_for( + persist_random_totals( + engine, + total_requests=int(snap.total_requests), + total_ok=int(snap.total_ok), + total_error=int(snap.total_error), + source="shutdown", + ), + timeout=2.0, + ) + except Exception: + pass + + task = getattr(app.state, "random_totals_persist_task", None) + if task is not None: + try: + task.cancel() + except Exception: + pass + try: + await asyncio.wait_for(task, timeout=2.0) + except asyncio.CancelledError: + pass + except Exception: + pass + if engine is not None: await engine.dispose() @@ -240,6 +316,8 @@ def create_app() -> FastAPI: app.include_router(healthz_router) app.include_router(docs_page_router) + app.include_router(status_page_router) + app.include_router(wtf_page_router) app.include_router(authors_router) app.include_router(images_router) app.include_router(legacy_router) diff --git a/backend/tests/test_random_tag_filters.py b/backend/tests/test_random_tag_filters.py index 8065e0a..1de120a 100644 --- a/backend/tests/test_random_tag_filters.py +++ b/backend/tests/test_random_tag_filters.py @@ -119,6 +119,59 @@ def test_included_tags_requires_all(tmp_path: Path) -> None: asyncio.run(_run()) +def test_included_tags_supports_or_groups(tmp_path: Path) -> None: + db_path = tmp_path / "included_or.db" + engine = create_engine("sqlite+aiosqlite:///" + db_path.as_posix()) + + async def _run() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + Session = create_sessionmaker(engine) + async with Session() as session: + cat = Tag(name="cat") + dog = Tag(name="dog") + session.add_all([cat, dog]) + + img_cat = Image( + illust_id=30, + page_index=0, + ext="jpg", + original_url="https://example.test/cat.jpg", + proxy_path="/i/1.jpg", + random_key=0.1, + x_restrict=0, + ) + img_dog = Image( + illust_id=31, + page_index=0, + ext="jpg", + original_url="https://example.test/dog.jpg", + proxy_path="/i/2.jpg", + random_key=0.2, + x_restrict=0, + ) + session.add_all([img_cat, img_dog]) + await session.flush() + + session.add_all( + [ + ImageTag(image_id=img_cat.id, tag_id=cat.id), + ImageTag(image_id=img_dog.id, tag_id=dog.id), + ] + ) + await session.commit() + + async with Session() as session: + picked = await pick_random_image(session, r=0.0, included_tags=["cat|dog"]) + assert picked is not None + assert int(picked.illust_id) in {30, 31} + + await engine.dispose() + + asyncio.run(_run()) + + def test_excluded_tags_filters_images(tmp_path: Path) -> None: db_path = tmp_path / "excluded_tags.db" engine = create_engine("sqlite+aiosqlite:///" + db_path.as_posix()) @@ -170,4 +223,3 @@ def test_excluded_tags_filters_images(tmp_path: Path) -> None: await engine.dispose() asyncio.run(_run()) - diff --git a/frontend/src/app/AdminLayout.tsx b/frontend/src/app/AdminLayout.tsx index b20acd3..5258bbd 100644 --- a/frontend/src/app/AdminLayout.tsx +++ b/frontend/src/app/AdminLayout.tsx @@ -10,6 +10,8 @@ type NavItem = { key: string; label: string; external?: boolean; href?: string } const NAV_ITEMS: NavItem[] = [ { key: "/admin", label: "主页" }, { key: "docs", label: "使用文档", external: true, href: "/docs" }, + { key: "status", label: "运行状态", external: true, href: "/status" }, + { key: "wtf", label: "瀑布流", external: true, href: "/wtf" }, { key: "/admin/import", label: "导入链接" }, { key: "/admin/images", label: "图片管理" }, { key: "/admin/hydration", label: "补全管理" },