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)