Files

539 lines
17 KiB
Python

from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from typing import Any, Literal, cast
from uuid import uuid4
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from pydantic import ValidationError
from starlette.datastructures import UploadFile
from app.api.admin.deps import get_admin_claims
from app.core.data_files import get_sqlite_db_dir, make_file_ref
from app.core.errors import ApiError, ErrorCode
from app.core.request_id import get_or_create_request_id
from app.core.time import iso_utc_ms
from app.core.pixiv_urls import parse_pixiv_original_url
from app.db.models.images import Image
from app.db.models.imports import Import
from app.db.models.jobs import JobRow
from app.db.session import create_sessionmaker, with_sqlite_busy_retry
from app.jobs.dispatch import JobDispatcher
from app.jobs.executor import execute_claimed_job
from app.jobs.handlers.import_images import build_import_images_handler
router = APIRouter()
class ImportCreateRequest(BaseModel):
text: str = Field(min_length=1)
dry_run: bool = False
hydrate_on_import: bool = False
source: str = "manual"
class ImportRollbackRequest(BaseModel):
mode: Literal["disable", "delete"]
@dataclass(frozen=True, slots=True)
class ImportUpload:
body: ImportCreateRequest
payload_bytes: bytes
input_format: Literal["text", "pixiv_batch_downloader_json"]
@dataclass(frozen=True, slots=True)
class ImportErrorItem:
line: int
url: str
code: str
message: str
def _max_import_text_bytes() -> int:
default = 200 * 1024 * 1024
cap = 600 * 1024 * 1024
raw = (os.environ.get("IMPORT_MAX_BYTES") or "").strip()
if not raw:
return default
try:
value = int(raw)
except Exception:
return default
return max(1024, min(int(value), int(cap)))
def _import_inline_max_accepted() -> int:
default = 200
raw = (os.environ.get("IMPORT_INLINE_MAX_ACCEPTED") or "").strip()
if not raw:
return default
try:
value = int(raw)
except Exception:
return default
return max(0, min(int(value), 10_000))
async def _claim_job_by_id(engine, *, job_id: int, worker_id: str, now: str) -> dict[str, Any] | None:
sql = """
UPDATE jobs
SET status='running',
locked_by=:worker_id,
locked_at=:now,
updated_at=:now
WHERE id=:id AND status='pending'
RETURNING *;
""".strip()
async def _op() -> dict[str, Any] | None:
async with engine.begin() as conn:
result = await conn.exec_driver_sql(sql, {"id": int(job_id), "worker_id": worker_id, "now": now})
row = result.mappings().first()
return dict(row) if row else None
return await with_sqlite_busy_retry(_op)
def _parse_import_text(
text: str, *, preview_limit: int = 20
) -> tuple[int, int, int, int, list[ImportErrorItem], list[dict[str, Any]]]:
total = 0
accepted = 0
deduped = 0
error_total = 0
errors: list[ImportErrorItem] = []
seen: set[tuple[int, int]] = set()
preview: list[dict[str, Any]] = []
for line_no, raw in enumerate(text.splitlines(), start=1):
url = raw.strip()
if not url:
continue
total += 1
try:
parsed = parse_pixiv_original_url(url)
except Exception as exc:
error_total += 1
if len(errors) < 200:
errors.append(
ImportErrorItem(
line=line_no,
url=url,
code=ErrorCode.UNSUPPORTED_URL.value,
message=str(exc) or ErrorCode.UNSUPPORTED_URL.value,
)
)
continue
key = (parsed.illust_id, parsed.page_index)
if key in seen:
deduped += 1
continue
seen.add(key)
accepted += 1
if len(preview) < int(preview_limit):
preview.append(
{
"illust_id": parsed.illust_id,
"page_index": parsed.page_index,
"ext": parsed.ext,
"url": url,
}
)
return total, accepted, deduped, error_total, errors, preview
def _parse_bool(value: Any, *, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
v = value.strip().lower()
if v in {"1", "true", "yes", "y", "on"}:
return True
if v in {"0", "false", "no", "n", "off"}:
return False
return default
def _validate_import_create(data: dict[str, Any]) -> ImportCreateRequest:
try:
return ImportCreateRequest(**data)
except ValidationError as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="导入请求体无效", status_code=400) from exc
def _parse_pixiv_batch_downloader_json(
raw: bytes, *, preview_limit: int = 20
) -> tuple[int, int, int, int, list[ImportErrorItem], list[dict[str, Any]]]:
try:
text = raw.decode("utf-8-sig", errors="replace")
except Exception:
text = raw.decode("utf-8", errors="replace")
try:
parsed = json.loads(text)
except json.JSONDecodeError as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="JSON 文件解析失败", status_code=400) from exc
items: list[Any] | None = None
if isinstance(parsed, list):
items = parsed
elif isinstance(parsed, dict):
if isinstance(parsed.get("result"), list):
items = cast(list[Any], parsed.get("result"))
elif isinstance(parsed.get("data"), list):
items = cast(list[Any], parsed.get("data"))
if items is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="不支持的 JSON 格式(需要数组)", status_code=400)
total = 0
accepted = 0
deduped = 0
error_total = 0
errors: list[ImportErrorItem] = []
seen: set[tuple[int, int]] = set()
preview: list[dict[str, Any]] = []
for idx, raw_item in enumerate(items, start=1):
if not isinstance(raw_item, dict):
continue
url = raw_item.get("original")
if not isinstance(url, str):
continue
url = url.strip()
if not url:
continue
total += 1
try:
parsed_url = parse_pixiv_original_url(url)
except Exception as exc:
error_total += 1
if len(errors) < 200:
errors.append(
ImportErrorItem(
line=idx,
url=url,
code=ErrorCode.UNSUPPORTED_URL.value,
message=str(exc) or ErrorCode.UNSUPPORTED_URL.value,
)
)
continue
key = (parsed_url.illust_id, parsed_url.page_index)
if key in seen:
deduped += 1
continue
seen.add(key)
accepted += 1
if len(preview) < int(preview_limit):
preview.append(
{
"illust_id": parsed_url.illust_id,
"page_index": parsed_url.page_index,
"ext": parsed_url.ext,
"url": url,
}
)
return total, accepted, deduped, error_total, errors, preview
async def _load_import_request(request: Request) -> ImportUpload:
content_type = (request.headers.get("content-type") or "").lower()
max_bytes = _max_import_text_bytes()
if content_type.startswith("application/json"):
data = await request.json()
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
body = _validate_import_create(data)
payload_bytes = body.text.encode("utf-8", errors="ignore")
if len(payload_bytes) > max_bytes:
raise ApiError(code=ErrorCode.PAYLOAD_TOO_LARGE, message="Payload too large", status_code=413)
return ImportUpload(body=body, payload_bytes=payload_bytes, input_format="text")
if content_type.startswith("multipart/form-data"):
form = await request.form()
file_obj = form.get("file")
if not isinstance(file_obj, UploadFile):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Missing file", status_code=400)
filename = (file_obj.filename or "").strip().lower()
input_format: Literal["text", "pixiv_batch_downloader_json"] = "text"
if filename.endswith(".json"):
input_format = "pixiv_batch_downloader_json"
if filename and not (filename.endswith(".txt") or filename.endswith(".json")):
raise ApiError(code=ErrorCode.INVALID_UPLOAD_TYPE, message="Unsupported upload type", status_code=400)
raw = await file_obj.read()
if len(raw) > max_bytes:
raise ApiError(code=ErrorCode.PAYLOAD_TOO_LARGE, message="Payload too large", status_code=413)
body = _validate_import_create(
{
"text": "pixiv_batch_downloader_json" if input_format == "pixiv_batch_downloader_json" else raw.decode("utf-8", errors="replace"),
"dry_run": _parse_bool(form.get("dry_run"), default=False),
"hydrate_on_import": _parse_bool(form.get("hydrate_on_import"), default=False),
"source": str(form.get("source") or "manual"),
}
)
return ImportUpload(body=body, payload_bytes=raw, input_format=input_format)
raise ApiError(code=ErrorCode.INVALID_UPLOAD_TYPE, message="Unsupported content type", status_code=400)
@router.post("/imports")
async def create_import(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
upload = await _load_import_request(request)
body = upload.body
rid = get_or_create_request_id(request)
if upload.input_format == "pixiv_batch_downloader_json":
total, accepted, deduped, error_total, errors, preview = _parse_pixiv_batch_downloader_json(
upload.payload_bytes, preview_limit=20
)
else:
total, accepted, deduped, error_total, errors, preview = _parse_import_text(body.text, preview_limit=20)
if body.dry_run:
return {
"ok": True,
"import_id": "",
"job_id": "",
"accepted": accepted,
"deduped": deduped,
"errors": [asdict(e) for e in errors[:200]],
"preview": preview,
"request_id": rid,
}
engine = request.app.state.engine
Session = create_sessionmaker(engine)
hydrate_on_import = bool(body.hydrate_on_import)
if upload.input_format == "pixiv_batch_downloader_json":
hydrate_on_import = False
settings = request.app.state.settings
db_dir = get_sqlite_db_dir(settings.database_url)
payload_dir = db_dir / "imports_payloads"
payload_dir.mkdir(parents=True, exist_ok=True)
ext = ".json" if upload.input_format == "pixiv_batch_downloader_json" else ".txt"
payload_path = payload_dir / f"import_payload_{uuid4().hex}{ext}"
try:
payload_path.write_bytes(upload.payload_bytes)
except OSError as exc:
raise ApiError(code=ErrorCode.INTERNAL_ERROR, message="写入导入内容失败", status_code=500) from exc
file_ref = make_file_ref(payload_path, base_dir=db_dir)
async def _op() -> tuple[int, int]:
async with Session() as session:
imp = Import(created_by=str(_claims.get("sub") or ""), source=body.source)
session.add(imp)
await session.flush()
imp.total = int(total)
imp.accepted = int(accepted)
imp.success = 0
imp.failed = int(error_total)
imp.detail_json = json.dumps(
{
"deduped": int(deduped),
"errors": [asdict(e) for e in errors[:200]],
},
ensure_ascii=False,
)
job = JobRow(
type="import_images",
status="pending",
payload_json=json.dumps(
{
"import_id": int(imp.id),
"file_ref": file_ref,
"hydrate_on_import": bool(hydrate_on_import),
"input_format": upload.input_format,
},
ensure_ascii=False,
separators=(",", ":"),
),
ref_type="import",
ref_id=str(imp.id),
)
session.add(job)
await session.flush()
await session.commit()
return int(imp.id), int(job.id)
try:
import_id, job_id = await with_sqlite_busy_retry(_op)
except Exception:
try:
payload_path.unlink(missing_ok=True)
except Exception:
pass
raise
inline_max = _import_inline_max_accepted()
executed_inline = False
if accepted > 0 and accepted <= inline_max:
now = iso_utc_ms()
actor = str(_claims.get("sub") or "admin").strip() or "admin"
worker_id = f"inline-import:{actor}"
claimed = await _claim_job_by_id(engine, job_id=int(job_id), worker_id=worker_id, now=now)
if claimed is not None:
dispatcher = JobDispatcher()
dispatcher.register("import_images", build_import_images_handler(engine))
await execute_claimed_job(engine, dispatcher, job_row=claimed, worker_id=worker_id)
executed_inline = True
return {
"ok": True,
"import_id": str(import_id),
"job_id": str(job_id),
"executed_inline": executed_inline,
"accepted": accepted,
"deduped": deduped,
"errors": [asdict(e) for e in errors[:200]],
"preview": preview,
"request_id": rid,
}
@router.post("/imports/{import_id}/rollback")
async def rollback_import(
import_id: int,
body: ImportRollbackRequest,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if import_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid import id", status_code=400)
rid = get_or_create_request_id(request)
target_status = 2 if body.mode == "disable" else 4
now_expr = sa.text("(strftime('%Y-%m-%dT%H:%M:%fZ','now'))")
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> int:
async with Session() as session:
imp = await session.get(Import, import_id)
if imp is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Import not found", status_code=404)
result = await session.execute(
sa.update(Image)
.where(Image.created_import_id == import_id)
.values(status=target_status, updated_at=now_expr)
)
updated = int(result.rowcount or 0)
await session.commit()
return int(updated)
updated = await with_sqlite_busy_retry(_op)
return {
"ok": True,
"mode": body.mode,
"updated": updated,
"request_id": rid,
}
@router.get("/imports/{import_id}")
async def get_import(
import_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if import_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid import id", status_code=400)
rid = get_or_create_request_id(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
imp = await session.get(Import, import_id)
if imp is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Import not found", status_code=404)
job = (
(
await session.execute(
sa.select(JobRow)
.where(JobRow.ref_type == "import", JobRow.ref_id == str(import_id))
.order_by(JobRow.id.desc())
.limit(1)
)
)
.scalars()
.first()
)
detail: dict[str, Any] = {}
if imp.detail_json:
try:
parsed = json.loads(imp.detail_json)
if isinstance(parsed, dict):
detail = parsed
except Exception:
detail = {}
return {
"ok": True,
"item": {
"import": {
"id": str(imp.id),
"created_at": imp.created_at,
"created_by": imp.created_by,
"source": imp.source,
"total": int(imp.total or 0),
"accepted": int(imp.accepted or 0),
"success": int(imp.success or 0),
"failed": int(imp.failed or 0),
},
"job": (
{
"id": str(job.id),
"type": job.type,
"status": job.status,
"attempt": job.attempt,
"max_attempts": job.max_attempts,
"last_error": job.last_error,
}
if job is not None
else None
),
"detail": detail,
},
"request_id": rid,
}