Files
2026-02-22 18:58:55 +08:00

344 lines
12 KiB
Python

from __future__ import annotations
from typing import Any
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query, Request
from app.api.admin.deps import get_admin_claims
from app.core.errors import ApiError, ErrorCode
from app.core.request_id import get_or_create_request_id
from app.db.models.image_tags import ImageTag
from app.db.models.images import Image
from app.db.models.tags import Tag
from app.db.session import create_sessionmaker, with_sqlite_busy_retry
router = APIRouter()
_ALLOWED_MISSING = {"tags", "geometry", "r18", "ai", "illust_type", "user", "title", "created_at", "popularity"}
def _parse_missing(values: list[str] | None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in values or []:
for part in str(raw or "").replace(",", "|").split("|"):
key = part.strip().lower()
if not key or key in seen:
continue
if key not in _ALLOWED_MISSING:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported missing", status_code=400)
seen.add(key)
out.append(key)
return out
@router.get("/images")
async def list_admin_images(
request: Request,
limit: int = 50,
cursor: str | None = None,
missing: list[str] | None = Query(default=None),
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if limit < 1 or limit > 200:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported limit", status_code=400)
cursor_i: int | None = None
cursor_raw = (cursor or "").strip()
if cursor_raw:
if not cursor_raw.isdigit():
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported cursor", status_code=400)
cursor_i = int(cursor_raw)
if cursor_i <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported cursor", status_code=400)
missing_keys = _parse_missing(missing)
rid = get_or_create_request_id(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
tag_counts = (
sa.select(ImageTag.image_id.label("image_id"), sa.func.count().label("tag_count"))
.group_by(ImageTag.image_id)
.subquery()
)
tag_count_col = sa.func.coalesce(tag_counts.c.tag_count, 0).label("tag_count")
stmt = (
sa.select(Image, tag_count_col)
.outerjoin(tag_counts, tag_counts.c.image_id == Image.id)
.where(Image.status == 1)
.order_by(Image.id.desc())
.limit(int(limit) + 1)
)
if cursor_i is not None:
stmt = stmt.where(Image.id < int(cursor_i))
for key in missing_keys:
if key == "tags":
stmt = stmt.where(tag_count_col == 0)
elif key == "geometry":
stmt = stmt.where((Image.width.is_(None)) | (Image.height.is_(None)))
elif key == "r18":
stmt = stmt.where(Image.x_restrict.is_(None))
elif key == "ai":
stmt = stmt.where(Image.ai_type.is_(None))
elif key == "illust_type":
stmt = stmt.where(Image.illust_type.is_(None))
elif key == "user":
stmt = stmt.where(Image.user_id.is_(None))
elif key == "title":
stmt = stmt.where((Image.title.is_(None)) | (sa.func.trim(Image.title) == ""))
elif key == "created_at":
stmt = stmt.where((Image.created_at_pixiv.is_(None)) | (sa.func.trim(Image.created_at_pixiv) == ""))
elif key == "popularity":
stmt = stmt.where(
(Image.bookmark_count.is_(None)) | (Image.view_count.is_(None)) | (Image.comment_count.is_(None))
)
async with Session() as session:
rows = (await session.execute(stmt)).all()
rows_page = rows[: int(limit)]
next_cursor = int(rows_page[-1][0].id) if len(rows) > int(limit) and rows_page else None
items: list[dict[str, Any]] = []
for img, tag_count in rows_page:
tag_count_i = int(tag_count or 0)
missing_list: list[str] = []
if tag_count_i <= 0:
missing_list.append("tags")
if img.width is None or img.height is None:
missing_list.append("geometry")
if img.x_restrict is None:
missing_list.append("r18")
if img.ai_type is None:
missing_list.append("ai")
if getattr(img, "illust_type", None) is None:
missing_list.append("illust_type")
if img.user_id is None:
missing_list.append("user")
if img.title is None or not str(img.title).strip():
missing_list.append("title")
if img.created_at_pixiv is None or not str(img.created_at_pixiv).strip():
missing_list.append("created_at")
if img.bookmark_count is None or img.view_count is None or img.comment_count is None:
missing_list.append("popularity")
items.append(
{
"id": str(img.id),
"illust_id": str(img.illust_id),
"page_index": int(img.page_index),
"ext": img.ext,
"status": int(img.status),
"width": img.width,
"height": img.height,
"orientation": img.orientation,
"x_restrict": img.x_restrict,
"ai_type": img.ai_type,
"illust_type": getattr(img, "illust_type", None),
"bookmark_count": img.bookmark_count,
"view_count": img.view_count,
"comment_count": img.comment_count,
"user": {
"id": str(img.user_id) if img.user_id is not None else None,
"name": img.user_name,
},
"title": img.title,
"created_at_pixiv": img.created_at_pixiv,
"original_url": img.original_url,
"proxy_path": img.proxy_path,
"tag_count": tag_count_i,
"missing": missing_list,
}
)
return {
"ok": True,
"items": items,
"next_cursor": str(next_cursor) if next_cursor is not None else "",
"request_id": rid,
}
async def _load_bulk_delete_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
raw_ids = data.get("image_ids", None)
if raw_ids is None:
raw_ids = data.get("ids", None)
if raw_ids is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Missing image_ids", status_code=400)
if not isinstance(raw_ids, list):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported image_ids", status_code=400)
ids: list[int] = []
seen: set[int] = set()
for raw in raw_ids:
try:
i = int(raw)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported image_ids", status_code=400) from exc
if i <= 0 or i in seen:
continue
seen.add(i)
ids.append(i)
if not ids:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Empty image_ids", status_code=400)
if len(ids) > 20_000:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many image_ids", status_code=400)
return {"image_ids": ids}
def _chunks(values: list[int], *, chunk_size: int) -> list[list[int]]:
if chunk_size <= 0:
return [values]
out: list[list[int]] = []
for i in range(0, len(values), chunk_size):
out.append(values[i : i + chunk_size])
return out
def _safe_rowcount(result: Any) -> int:
try:
rc = int(getattr(result, "rowcount", 0) or 0)
except Exception:
return 0
return rc if rc > 0 else 0
@router.delete("/images/{image_id}")
async def delete_admin_image(
image_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if int(image_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image id", status_code=400)
rid = get_or_create_request_id(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> dict[str, Any]:
async with Session() as session:
row = await session.get(Image, int(image_id))
if row is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404)
await session.execute(sa.delete(ImageTag).where(ImageTag.image_id == int(image_id)))
await session.delete(row)
await session.commit()
return {"ok": True, "image_id": str(int(image_id)), "request_id": rid}
return await with_sqlite_busy_retry(_op)
@router.post("/images/bulk-delete")
async def bulk_delete_admin_images(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
body = await _load_bulk_delete_json(request)
ids = list(body["image_ids"])
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> dict[str, Any]:
deleted = 0
found = 0
async with Session() as session:
for chunk in _chunks(ids, chunk_size=900):
rows = (await session.execute(sa.select(Image.id).where(Image.id.in_(chunk)))).scalars().all()
found += len(rows)
for chunk in _chunks(ids, chunk_size=900):
await session.execute(sa.delete(ImageTag).where(ImageTag.image_id.in_(chunk)))
result = await session.execute(sa.delete(Image).where(Image.id.in_(chunk)))
deleted += _safe_rowcount(result)
await session.commit()
missing = max(0, int(len(ids)) - int(found))
return {
"ok": True,
"requested": int(len(ids)),
"deleted": int(deleted),
"missing": int(missing),
"request_id": rid,
}
return await with_sqlite_busy_retry(_op)
async def _load_clear_images_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
confirm = data.get("confirm", False)
if confirm not in {True, 1, "1", "true", "yes", "y", "on"}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Missing confirm", status_code=400)
delete_tags = data.get("delete_tags", True)
delete_tags_bool = (
bool(delete_tags)
if isinstance(delete_tags, (bool, int))
else str(delete_tags).strip().lower() in {"1", "true", "yes", "y", "on"}
)
return {"delete_tags": bool(delete_tags_bool)}
@router.post("/images/clear")
async def clear_admin_images(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
body = await _load_clear_images_json(request)
delete_tags = bool(body["delete_tags"])
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> dict[str, Any]:
async with Session() as session:
result_links = await session.execute(sa.delete(ImageTag))
result_images = await session.execute(sa.delete(Image))
result_tags = None
if delete_tags:
result_tags = await session.execute(sa.delete(Tag))
await session.commit()
return {
"ok": True,
"deleted_image_tags": _safe_rowcount(result_links),
"deleted_images": _safe_rowcount(result_images),
"deleted_tags": _safe_rowcount(result_tags) if result_tags is not None else 0,
"request_id": rid,
}
return await with_sqlite_busy_retry(_op)