Files
dy/backend/link_cards_router.py
2026-07-23 17:56:25 +08:00

211 lines
7.0 KiB
Python

"""链接卡片 API 与公开落地页。"""
from __future__ import annotations
import os
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import get_current_user, require_write
from link_cards import (
absolute_media_url,
build_keywords,
favicon_path_for_cover,
generate_slug,
is_valid_slug,
media_path_to_disk,
normalize_target_url,
process_card_upload,
public_base_url,
render_link_card_page,
)
from models.database import get_db
from models.models import LinkCardPage, User
router = APIRouter(tags=["link-cards"])
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads", "link-cards")
os.makedirs(UPLOAD_DIR, exist_ok=True)
class LinkCardUpsert(BaseModel):
id: Optional[int] = None
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(default="", max_length=2000)
target_url: str = Field(..., min_length=1, max_length=2000)
image_path: str = Field(..., min_length=1, max_length=512)
class LinkCardResponse(BaseModel):
id: int
slug: str
title: str
content: str
target_url: str
image_path: str
page_url: str
cover_url: str
class Config:
from_attributes = True
class UploadImageResponse(BaseModel):
image_path: str
cover_url: str
async def _get_owned_card(
db: AsyncSession, user: User, card_id: int, *, write: bool = False
) -> LinkCardPage:
stmt = select(LinkCardPage).where(LinkCardPage.id == card_id)
card = (await db.execute(stmt)).scalar_one_or_none()
if not card or card.owner_id != user.id:
raise HTTPException(status_code=404, detail="卡片不存在")
if write and user.role == "viewer":
raise HTTPException(status_code=403, detail="无写入权限")
return card
def _card_response(card: LinkCardPage, request: Request) -> LinkCardResponse:
base = public_base_url(request)
page_url = f"{base}/p/{card.slug}" if base else f"/p/{card.slug}"
cover_url = absolute_media_url(card.image_path, request)
return LinkCardResponse(
id=card.id,
slug=card.slug,
title=card.title,
content=card.content or "",
target_url=card.target_url,
image_path=card.image_path,
page_url=page_url,
cover_url=cover_url,
)
@router.post("/api/link-cards/upload-image", response_model=UploadImageResponse)
async def upload_link_card_image(
request: Request,
file: UploadFile = File(...),
user: User = Depends(require_write),
):
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
if file.content_type == "image/svg+xml":
raise HTTPException(status_code=400, detail="请上传 JPG/PNG 等位图,系统将自动转为 favicon PNG")
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="图片为空")
if len(raw) > 4 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片大小不能超过 4MB")
try:
cover_bytes, favicon_bytes = process_card_upload(raw)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
owner_dir = os.path.join(UPLOAD_DIR, str(user.id))
os.makedirs(owner_dir, exist_ok=True)
file_id = uuid.uuid4().hex
cover_filename = f"{file_id}.png"
favicon_filename = f"{file_id}.favicon.png"
cover_path = os.path.join(owner_dir, cover_filename)
favicon_path = os.path.join(owner_dir, favicon_filename)
with open(cover_path, "wb") as f:
f.write(cover_bytes)
with open(favicon_path, "wb") as f:
f.write(favicon_bytes)
image_path = f"/api/media/link-cards/{user.id}/{cover_filename}"
return UploadImageResponse(
image_path=image_path,
cover_url=absolute_media_url(image_path, request),
)
@router.post("/api/link-cards", response_model=LinkCardResponse)
async def upsert_link_card(
body: LinkCardUpsert,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
title = body.title.strip()
content = (body.content or "").strip()
target_url = normalize_target_url(body.target_url)
image_path = (body.image_path or "").strip()
if not title:
raise HTTPException(status_code=400, detail="请填写卡片标题")
if not target_url:
raise HTTPException(status_code=400, detail="请填写跳转链接")
if not image_path.startswith("/api/media/link-cards/"):
raise HTTPException(status_code=400, detail="请先上传卡片封面图")
if body.id:
card = await _get_owned_card(db, user, body.id, write=True)
card.title = title
card.content = content
card.target_url = target_url
card.image_path = image_path
card.updated_at = datetime.utcnow()
else:
slug = generate_slug()
for _ in range(5):
exists = (
await db.execute(select(LinkCardPage.id).where(LinkCardPage.slug == slug))
).scalar_one_or_none()
if not exists:
break
slug = generate_slug()
else:
raise HTTPException(status_code=500, detail="生成页面标识失败,请重试")
card = LinkCardPage(
owner_id=user.id,
slug=slug,
title=title,
content=content,
target_url=target_url,
image_path=image_path,
)
db.add(card)
await db.commit()
await db.refresh(card)
return _card_response(card, request)
@router.get("/p/{slug}", response_class=HTMLResponse, include_in_schema=False)
async def serve_link_card_page(slug: str, request: Request, db: AsyncSession = Depends(get_db)):
if not is_valid_slug(slug):
raise HTTPException(status_code=404, detail="页面不存在")
stmt = select(LinkCardPage).where(LinkCardPage.slug == slug)
card = (await db.execute(stmt)).scalar_one_or_none()
if not card:
raise HTTPException(status_code=404, detail="页面不存在")
cover_url = absolute_media_url(card.image_path, request)
favicon_path = favicon_path_for_cover(card.image_path)
favicon_disk = media_path_to_disk(UPLOAD_DIR, favicon_path)
favicon_url = absolute_media_url(favicon_path, request) if favicon_disk and os.path.isfile(favicon_disk) else cover_url
html = render_link_card_page(
title=card.title,
content=card.content or "",
keywords=build_keywords(card.title, card.content or ""),
cover_url=cover_url,
favicon_url=favicon_url,
target_url=card.target_url,
)
return HTMLResponse(content=html, media_type="text/html; charset=utf-8")