Files
WonderQ-Admin/app/routers/admin.py
duanshuwen 75f5a5a48b feat: add WonderQ admin backend
Add FastAPI admin/public API service, database setup, Docker deployment files, docs, and tests.
2026-06-30 13:56:45 +08:00

306 lines
12 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import delete, func, or_, select
from sqlalchemy.orm import Session, selectinload
from ..auth import create_token, get_actor_id, require_admin, verify_password
from ..database import get_db
from ..models import (
AdminUser,
AuditLog,
Campaign,
CtaBanner,
Destination,
HeroSlide,
Lead,
MediaAsset,
Product,
ProductImage,
SiteVersion,
ThemeCard,
utc_now,
)
from ..schemas import AdminProductQuery, LeadQuery, LeadStatusIn, LoginIn, ProductCreateIn, ProductUpdateIn, SiteConfigPatchIn
from ..seed import create_media, reset_guizhou_content
from ..serializers import destination_dict, encode_value, lead_dict, model_dict, product_dict
from .shared import site_config
router = APIRouter(prefix="/api/admin")
def normalize_images(images):
return [
{"url": image.url.strip(), "alt": image.alt.strip() if image.alt else None, "sortOrder": image.sortOrder if image.sortOrder is not None else index}
for index, image in enumerate(images or [])
if image.url.strip()
]
def normalize_detail_sections(sections):
normalized = []
for section in sections or []:
blocks = []
for block in section.blocks:
if block.type == "image":
url = block.url.strip()
if url:
blocks.append({"type": "image", "url": url, "alt": block.alt.strip() if block.alt else None})
else:
text = block.text.strip()
if text:
blocks.append({"type": "text", "text": text})
key = section.key.strip()
label = section.label.strip()
if key and label and blocks:
normalized.append({"key": key, "label": label, "title": section.title.strip() if section.title else None, "blocks": blocks})
return normalized
def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id: str | None = None, after=None, before=None) -> None:
db.add(
AuditLog(
actorId=actor_id,
action=action,
entity=entity,
entityId=entity_id,
before=encode_value(before) if before is not None else None,
after=encode_value(after) if after is not None else None,
)
)
def load_product(db: Session, product_id: str) -> Product:
return db.scalars(
select(Product)
.options(selectinload(Product.destination), selectinload(Product.images))
.where(Product.id == product_id)
).one()
@router.post("/auth/login")
def login(body: LoginIn, db: Session = Depends(get_db)):
user = db.scalar(select(AdminUser).where(AdminUser.email == body.email))
if not user or not user.isActive or not verify_password(body.password, user.passwordHash):
raise HTTPException(status_code=401, detail="账号或密码错误")
return {
"token": create_token(user),
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
}
@router.get("/me")
def me(user: AdminUser = Depends(require_admin)):
return {"id": user.id, "email": user.email, "name": user.name, "role": user.role}
@router.get("/dashboard")
def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
stats = {
"productCount": db.scalar(select(func.count()).select_from(Product)),
"publishedProductCount": db.scalar(select(func.count()).select_from(Product).where(Product.status == "published")),
"destinationCount": db.scalar(select(func.count()).select_from(Destination).where(Destination.isActive.is_(True))),
"newLeadCount": db.scalar(select(func.count()).select_from(Lead).where(Lead.status == "new")),
"leadCount": db.scalar(select(func.count()).select_from(Lead)),
"campaignCount": db.scalar(select(func.count()).select_from(Campaign)),
}
recent = db.scalars(
select(Lead).options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5)
).all()
return {"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]}
@router.get("/products")
def list_products(
keyword: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
query = AdminProductQuery(keyword=keyword, status=status_value, take=take)
stmt = select(Product).options(selectinload(Product.destination), selectinload(Product.images)).order_by(Product.sortWeight.asc(), Product.updatedAt.desc()).limit(query.take)
if query.status:
stmt = stmt.where(Product.status == query.status)
if query.keyword:
pattern = f"%{query.keyword}%"
stmt = stmt.where(or_(Product.title.ilike(pattern), Product.subtitle.ilike(pattern), Product.tags.any(query.keyword)))
products = db.scalars(stmt).unique().all()
return {"items": [product_dict(product) for product in products]}
@router.post("/products", status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreateIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
images = normalize_images(body.images)
sections = normalize_detail_sections(body.detailSections)
payload = body.model_dump(exclude={"images", "detailSections"})
payload["priceUnit"] = body.priceUnit or "起/人"
payload["detailSections"] = sections or None
payload["publishedAt"] = utc_now() if body.status == "published" else None
product = Product(**payload)
db.add(product)
db.flush()
for image in images:
db.add(ProductImage(productId=product.id, **image))
create_media(db, image["url"], "product-detail", image["alt"] or product.title)
db.flush()
product = load_product(db, product.id)
audit(db, get_actor_id(request), "create", "product", product.id, product_dict(product))
db.commit()
return product_dict(product)
@router.patch("/products/{product_id}")
def update_product(product_id: str, body: ProductUpdateIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
product = db.scalars(select(Product).options(selectinload(Product.images), selectinload(Product.destination)).where(Product.id == product_id)).first()
if not product:
raise HTTPException(status_code=404, detail="线路不存在")
before = product_dict(product)
fields = body.model_fields_set
payload = body.model_dump(exclude={"images", "detailSections"}, exclude_unset=True)
for key, value in payload.items():
setattr(product, key, value)
if "detailSections" in fields:
product.detailSections = normalize_detail_sections(body.detailSections)
if "status" in fields and body.status == "published" and before.get("status") != "published":
product.publishedAt = utc_now()
if "images" in fields:
db.execute(delete(ProductImage).where(ProductImage.productId == product.id))
for image in normalize_images(body.images):
db.add(ProductImage(productId=product.id, **image))
create_media(db, image["url"], "product-detail", image["alt"] or product.title)
db.flush()
product = load_product(db, product.id)
audit(db, get_actor_id(request), "update", "product", product.id, product_dict(product), before)
db.commit()
return product_dict(product)
@router.get("/destinations")
def admin_destinations(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
destinations = db.scalars(
select(Destination).options(selectinload(Destination.aliases), selectinload(Destination.products)).order_by(Destination.sortOrder.asc())
).all()
return {"items": [destination_dict(destination, include_count=True) for destination in destinations]}
@router.get("/site-config")
def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return site_config(db, active_only=False, include_public_extras=False)
@router.patch("/site-config/{module}/{item_id}")
def update_site_config(
module: str,
item_id: str,
body: SiteConfigPatchIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
model_map = {"heroSlides": HeroSlide, "destinations": Destination, "themes": ThemeCard, "ctaBanners": CtaBanner}
if module not in model_map:
raise HTTPException(status_code=400, detail="维护模块不存在")
item = db.get(model_map[module], item_id)
if not item:
raise HTTPException(status_code=404, detail="维护项不存在")
before = model_dict(item)
fields = body.model_fields_set
def set_if_present(field: str, attr: str | None = None, ignore_none: bool = False) -> None:
if field not in fields:
return
value = getattr(body, field)
if ignore_none and value is None:
return
setattr(item, attr or field, value)
if module == "heroSlides":
set_if_present("title")
set_if_present("kicker")
set_if_present("image", ignore_none=True)
set_if_present("targetType")
set_if_present("targetValue")
set_if_present("isActive")
entity = "hero_slide"
elif module == "destinations":
set_if_present("name")
set_if_present("image", ignore_none=True)
set_if_present("isActive")
entity = "destination"
elif module == "themes":
set_if_present("label")
set_if_present("image", ignore_none=True)
set_if_present("targetType")
set_if_present("targetValue")
set_if_present("isActive")
entity = "theme_card"
else:
set_if_present("alt")
set_if_present("image", ignore_none=True)
set_if_present("targetType", ignore_none=True)
set_if_present("targetValue")
set_if_present("isActive")
entity = "cta_banner"
db.flush()
audit(db, get_actor_id(request), "update", entity, item.id, model_dict(item), before)
db.commit()
return model_dict(item)
@router.get("/leads")
def list_leads(
status_value: str | None = Query(default=None, alias="status"),
take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
query = LeadQuery(status=status_value, take=take)
stmt = (
select(Lead)
.options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser))
.order_by(Lead.createdAt.desc())
.limit(query.take)
)
if query.status:
stmt = stmt.where(Lead.status == query.status)
leads = db.scalars(stmt).all()
return {"items": [lead_dict(lead) for lead in leads]}
@router.patch("/leads/{lead_id}/status")
def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
lead = db.get(Lead, lead_id)
if not lead:
raise HTTPException(status_code=404, detail="线索不存在")
before = model_dict(lead)
lead.status = body.status
db.flush()
audit(db, get_actor_id(request), "update_status", "lead", lead.id, model_dict(lead), before)
db.commit()
return model_dict(lead)
@router.get("/media-assets")
def list_media_assets(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
assets = db.scalars(select(MediaAsset).order_by(MediaAsset.createdAt.desc()).limit(200)).all()
return {"items": [model_dict(asset) for asset in assets]}
@router.post("/reset-guizhou-content")
def reset_content(request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
result = reset_guizhou_content(db)
audit(db, get_actor_id(request), "reset_guizhou_content", "site_content", after=result)
db.commit()
return result
@router.post("/publish", status_code=status.HTTP_201_CREATED)
def publish(request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
snapshot = site_config(db, active_only=True)
version = SiteVersion(title=f"manual-{utc_now().isoformat()}", status="published", snapshot=snapshot, publishedAt=utc_now())
db.add(version)
db.flush()
audit(db, get_actor_id(request), "publish", "site_version", version.id, model_dict(version))
db.commit()
return model_dict(version)