Add FastAPI admin/public API service, database setup, Docker deployment files, docs, and tests.
306 lines
12 KiB
Python
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)
|