feat: add WonderQ admin backend
Add FastAPI admin/public API service, database setup, Docker deployment files, docs, and tests.
This commit is contained in:
305
app/routers/admin.py
Normal file
305
app/routers/admin.py
Normal file
@@ -0,0 +1,305 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user