refactor: 清理废弃业务模块并更新全栈配置
- 移除后端产品、目的地、活动专题等废弃模块的数据库表与业务代码,删除冗余API接口 - 删除小程序端详情页、线路组件等冗余代码,移除搜索工具与测试用例,调整导航逻辑 - 清理管理端废弃的类型定义、编辑器与测试代码 - 更新项目文档,修正模块维护说明与接口文档内容
This commit is contained in:
1 parent
72e6071bb1
commit
548f91c37f
80 files changed
+953
-12702
No files matched your search
@@ -8,7 +8,7 @@ from email.utils import formatdate
|
||||
from uuid import uuid4
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from ..auth import create_token, get_actor_id, require_admin, verify_password
|
||||
from ..config import get_settings
|
||||
@@ -16,35 +16,20 @@ from ..database import get_db
|
||||
from ..models import (
|
||||
AdminUser,
|
||||
AuditLog,
|
||||
Campaign,
|
||||
CtaBanner,
|
||||
DemandFeatureCard,
|
||||
DemandForm,
|
||||
DemandHero,
|
||||
DemandRecommendation,
|
||||
DemandRecommendationProduct,
|
||||
Destination,
|
||||
DestinationHero,
|
||||
DestinationRegion,
|
||||
HeroSlide,
|
||||
HotelGroup,
|
||||
Lead,
|
||||
MapImage,
|
||||
MediaAsset,
|
||||
Product,
|
||||
ProductImage,
|
||||
RouteSection,
|
||||
RouteSectionProduct,
|
||||
SiteVersion,
|
||||
ThemeCard,
|
||||
VehicleOption,
|
||||
utc_now,
|
||||
)
|
||||
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
|
||||
from ..schemas import LeadQuery, LeadStatus, LeadStatusIn, LoginIn, SiteConfigPatchIn, SiteConfigReorderIn
|
||||
from ..seed import create_media, reset_guizhou_content
|
||||
from ..demand_recommendations import demand_recommendation_dict, demand_recommendation_query, replace_demand_recommendation_products
|
||||
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query
|
||||
from ..serializers import admin_product_dict, destination_dict, encode_value, hotel_group_dict, lead_dict, model_dict
|
||||
from ..serializers import encode_value, lead_dict, model_dict
|
||||
from .shared import site_config
|
||||
|
||||
|
||||
@@ -68,14 +53,6 @@ SITE_CONFIG_MODULES = {
|
||||
"none_to_empty": {"image"},
|
||||
"create_defaults": {"kicker": "", "image": ""},
|
||||
},
|
||||
"destinations": {
|
||||
"model": Destination,
|
||||
"entity": "destination",
|
||||
"primary": "name",
|
||||
"fields": {"name", "slug", "region", "image", "isHot", "isActive", "sortOrder"},
|
||||
"none_to_empty": set(),
|
||||
"create_defaults": {"isHot": False},
|
||||
},
|
||||
"destinationHero": {
|
||||
"model": DestinationHero,
|
||||
"entity": "destination_hero",
|
||||
@@ -85,15 +62,6 @@ SITE_CONFIG_MODULES = {
|
||||
"empty_to_none": {"kicker", "image"},
|
||||
"create_defaults": {"kicker": None, "image": None},
|
||||
},
|
||||
"destinationRegions": {
|
||||
"model": DestinationRegion,
|
||||
"entity": "destination_region",
|
||||
"primary": "label",
|
||||
"fields": {"label", "keyword", "spots", "isActive", "sortOrder"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"keyword", "spots"},
|
||||
"create_defaults": {"keyword": None, "spots": None},
|
||||
},
|
||||
"demandHero": {
|
||||
"model": DemandHero,
|
||||
"entity": "demand_hero",
|
||||
@@ -142,61 +110,6 @@ SITE_CONFIG_MODULES = {
|
||||
"ordered": False,
|
||||
"singleton": True,
|
||||
},
|
||||
"demandRecommendations": {
|
||||
"model": DemandRecommendation,
|
||||
"entity": "demand_recommendation",
|
||||
"primary": "title",
|
||||
"fields": {"title", "subtitle", "productIds", "isActive", "sortOrder"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"subtitle"},
|
||||
"create_defaults": {"subtitle": None},
|
||||
},
|
||||
"map": {
|
||||
"model": MapImage,
|
||||
"entity": "map_image",
|
||||
"primary": "image",
|
||||
"fields": {"image", "isActive"},
|
||||
"none_to_empty": set(),
|
||||
"create_defaults": {},
|
||||
"ordered": False,
|
||||
"singleton": True,
|
||||
},
|
||||
"themes": {
|
||||
"model": ThemeCard,
|
||||
"entity": "theme_card",
|
||||
"primary": "label",
|
||||
"fields": {"label", "image", "targetType", "targetValue", "isActive", "sortOrder"},
|
||||
"none_to_empty": {"image"},
|
||||
"create_defaults": {"image": ""},
|
||||
},
|
||||
"campaigns": {
|
||||
"model": Campaign,
|
||||
"entity": "campaign",
|
||||
"primary": "title",
|
||||
"fields": {"title", "slug", "description", "coverImage", "priceAmount", "priceUnit", "tags", "status", "startsAt", "endsAt"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"description", "coverImage", "priceUnit", "startsAt", "endsAt"},
|
||||
"create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None},
|
||||
"ordered": False,
|
||||
},
|
||||
"routeSections": {
|
||||
"model": RouteSection,
|
||||
"entity": "route_section",
|
||||
"primary": "title",
|
||||
"fields": {"title", "subtitle", "isActive", "sortOrder", "productIds"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"subtitle"},
|
||||
"create_defaults": {},
|
||||
},
|
||||
"hotelGroups": {
|
||||
"model": HotelGroup,
|
||||
"entity": "hotel_group",
|
||||
"primary": "title",
|
||||
"fields": {"title", "description", "image", "coverImage", "priceAmount", "priceUnit", "tags", "status", "isActive", "sortOrder"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"description", "image", "coverImage", "priceUnit"},
|
||||
"create_defaults": {"description": None, "image": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/晚", "tags": [], "status": "published"},
|
||||
},
|
||||
"vehicleOptions": {
|
||||
"model": VehicleOption,
|
||||
"entity": "vehicle_option",
|
||||
@@ -206,19 +119,8 @@ SITE_CONFIG_MODULES = {
|
||||
"empty_to_none": {"description", "image"},
|
||||
"create_defaults": {"description": None, "image": None},
|
||||
},
|
||||
"ctaBanners": {
|
||||
"model": CtaBanner,
|
||||
"entity": "cta_banner",
|
||||
"primary": "alt",
|
||||
"fields": {"alt", "image", "targetType", "targetValue", "isActive", "sortOrder"},
|
||||
"none_to_empty": {"image", "targetType"},
|
||||
"create_defaults": {"image": "", "targetType": ""},
|
||||
},
|
||||
}
|
||||
|
||||
PUBLISH_STATUSES = {"draft", "published"}
|
||||
|
||||
|
||||
def media_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
|
||||
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
|
||||
|
||||
@@ -347,34 +249,6 @@ def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) ->
|
||||
return urlunsplit((scheme, host, f"/{quoted_key}", "", ""))
|
||||
|
||||
|
||||
def normalize_images(images):
|
||||
return [
|
||||
{"url": image.url.strip(), "alt": image.alt.strip() if image.alt else None, "sortOrder": 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(
|
||||
@@ -399,23 +273,10 @@ def site_module(module: str) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def site_slugify(value: str) -> str:
|
||||
return quote(value, safe="").replace("%", "").lower()
|
||||
|
||||
|
||||
def clean_site_value(value):
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def normalize_campaign_tags(value) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
tags = [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
||||
if len(tags) > 3:
|
||||
site_config_error(422, "tags cannot exceed 3", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "tags", "max": 3})
|
||||
return tags
|
||||
|
||||
|
||||
def normalize_string_list(value) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
@@ -423,8 +284,6 @@ def normalize_string_list(value) -> list[str]:
|
||||
|
||||
|
||||
def site_field_value(config: dict, field: str, value):
|
||||
if field == "tags":
|
||||
return normalize_campaign_tags(value)
|
||||
if field in {"steps", "chips"}:
|
||||
return normalize_string_list(value)
|
||||
value = clean_site_value(value)
|
||||
@@ -435,13 +294,6 @@ def site_field_value(config: dict, field: str, value):
|
||||
return value
|
||||
|
||||
|
||||
def validate_publish_status(value):
|
||||
status_value = clean_site_value(value)
|
||||
if status_value not in PUBLISH_STATUSES:
|
||||
site_config_error(422, "status must be draft or published", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "status"})
|
||||
return status_value
|
||||
|
||||
|
||||
def validate_site_primary(config: dict, body: SiteConfigPatchIn) -> str:
|
||||
primary = config["primary"]
|
||||
value = clean_site_value(getattr(body, primary))
|
||||
@@ -468,37 +320,15 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def map_image_admin_dict(item: MapImage) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"image": item.image or None,
|
||||
"isActive": item.isActive,
|
||||
"createdAt": encode_value(item.createdAt),
|
||||
"updatedAt": encode_value(item.updatedAt),
|
||||
}
|
||||
|
||||
|
||||
def site_item_dict(module: str, item) -> dict:
|
||||
if module == "heroSlides":
|
||||
return hero_slide_admin_dict(item)
|
||||
if module == "map":
|
||||
return map_image_admin_dict(item)
|
||||
if module == "routeSections":
|
||||
return route_section_dict(item)
|
||||
if module == "demandRecommendations":
|
||||
return demand_recommendation_dict(item)
|
||||
if module == "hotelGroups":
|
||||
return hotel_group_dict(item)
|
||||
return destination_dict(item) if module == "destinations" else model_dict(item)
|
||||
return model_dict(item)
|
||||
|
||||
|
||||
def module_items(db: Session, config: dict) -> list:
|
||||
model = config["model"]
|
||||
stmt = select(model)
|
||||
if model is RouteSection:
|
||||
stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product))
|
||||
if model is DemandRecommendation:
|
||||
stmt = stmt.options(selectinload(DemandRecommendation.products).selectinload(DemandRecommendationProduct.product))
|
||||
if config.get("ordered", True):
|
||||
stmt = stmt.order_by(model.sortOrder.asc())
|
||||
else:
|
||||
@@ -516,7 +346,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
|
||||
payload = {
|
||||
field: site_field_value(config, field, getattr(body, field))
|
||||
for field in config["fields"]
|
||||
if field in fields and field != "productIds"
|
||||
if field in fields
|
||||
}
|
||||
payload[config["primary"]] = validate_site_primary(config, body)
|
||||
for field, value in config["create_defaults"].items():
|
||||
@@ -525,29 +355,6 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
|
||||
payload["isActive"] = payload.get("isActive", True)
|
||||
if config.get("ordered", True) and payload.get("sortOrder") is None:
|
||||
payload["sortOrder"] = next_site_sort_order(db, config["model"])
|
||||
if module == "destinations":
|
||||
slug = clean_site_value(payload.get("slug"))
|
||||
payload["slug"] = slug or site_slugify(payload["name"])
|
||||
if module == "campaigns":
|
||||
slug = clean_site_value(payload.get("slug"))
|
||||
if not slug:
|
||||
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "slug"})
|
||||
payload["slug"] = slug
|
||||
if not payload.get("priceUnit"):
|
||||
payload["priceUnit"] = "起/人"
|
||||
payload["status"] = validate_publish_status(payload.get("status", "draft"))
|
||||
if module == "hotelGroups":
|
||||
if not payload.get("priceUnit"):
|
||||
payload["priceUnit"] = "起/晚"
|
||||
payload["status"] = validate_publish_status(payload.get("status", "published"))
|
||||
if not payload.get("coverImage") and payload.get("image"):
|
||||
payload["coverImage"] = payload["image"]
|
||||
if not payload.get("image") and payload.get("coverImage"):
|
||||
payload["image"] = payload["coverImage"]
|
||||
if "status" in fields and "isActive" not in fields:
|
||||
payload["isActive"] = payload["status"] == "published"
|
||||
if "isActive" in fields and "status" not in fields:
|
||||
payload["status"] = "published" if payload.get("isActive") else "draft"
|
||||
return payload
|
||||
|
||||
|
||||
@@ -556,64 +363,10 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
|
||||
for field in config["fields"]:
|
||||
if field not in fields:
|
||||
continue
|
||||
if field == "productIds":
|
||||
continue
|
||||
value = site_field_value(config, field, getattr(body, field))
|
||||
if field == config["primary"] and not value:
|
||||
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
|
||||
if module == "destinations" and field == "slug" and not value:
|
||||
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
|
||||
if module == "campaigns" and field == "slug" and not value:
|
||||
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
|
||||
if module in {"campaigns", "hotelGroups"} and field == "status":
|
||||
value = validate_publish_status(value)
|
||||
setattr(item, field, value)
|
||||
if module == "hotelGroups":
|
||||
if "coverImage" in fields and "image" not in fields:
|
||||
item.image = item.coverImage
|
||||
if "image" in fields and "coverImage" not in fields:
|
||||
item.coverImage = item.image
|
||||
if "status" in fields and "isActive" not in fields:
|
||||
item.isActive = item.status == "published"
|
||||
if "isActive" in fields and "status" not in fields:
|
||||
item.status = "published" if item.isActive else "draft"
|
||||
|
||||
|
||||
def create_route_section_config(body: SiteConfigPatchIn, request: Request, db: Session) -> dict:
|
||||
config = site_module("routeSections")
|
||||
items = module_items(db, config)
|
||||
fields = body.model_fields_set
|
||||
now = utc_now()
|
||||
section = RouteSection(
|
||||
title=validate_site_primary(config, body),
|
||||
subtitle=site_field_value(config, "subtitle", body.subtitle) if "subtitle" in fields else None,
|
||||
sortOrder=(
|
||||
site_field_value(config, "sortOrder", body.sortOrder)
|
||||
if "sortOrder" in fields and body.sortOrder is not None
|
||||
else max([item.sortOrder for item in items], default=-1) + 1
|
||||
),
|
||||
isActive=site_field_value(config, "isActive", body.isActive) if "isActive" in fields else True,
|
||||
createdAt=now,
|
||||
updatedAt=now,
|
||||
)
|
||||
section.products = []
|
||||
db.add(section)
|
||||
db.flush()
|
||||
if "productIds" in fields:
|
||||
replace_route_section_products(db, section, body.productIds or [])
|
||||
db.flush()
|
||||
after = route_section_dict(section)
|
||||
audit(db, get_actor_id(request), "create", config["entity"], section.id, after)
|
||||
db.commit()
|
||||
return after
|
||||
|
||||
|
||||
def load_product(db: Session, product_id: str) -> Product:
|
||||
return db.scalars(
|
||||
select(Product)
|
||||
.options(selectinload(Product.destination).selectinload(Destination.aliases), selectinload(Product.images))
|
||||
.where(Product.id == product_id)
|
||||
).one()
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
@@ -635,150 +388,26 @@ def me(user: AdminUser = Depends(require_admin)):
|
||||
@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)
|
||||
select(Lead).options(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: ProductStatus | 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(Destination.aliases), 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": [admin_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, admin_product_dict(product))
|
||||
db.commit()
|
||||
return admin_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).selectinload(Destination.aliases))
|
||||
.where(Product.id == product_id)
|
||||
).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="线路不存在")
|
||||
before = admin_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, admin_product_dict(product), before)
|
||||
db.commit()
|
||||
return admin_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)).order_by(Destination.sortOrder.asc())
|
||||
).all()
|
||||
counts = {}
|
||||
if destinations:
|
||||
rows = db.execute(
|
||||
select(Product.destinationId, func.count(Product.id))
|
||||
.where(Product.destinationId.in_([destination.id for destination in destinations]))
|
||||
.group_by(Product.destinationId)
|
||||
).all()
|
||||
counts = {destination_id: count for destination_id, count in rows if destination_id}
|
||||
return {"items": [destination_dict(destination, include_count=True, product_count=counts.get(destination.id, 0)) for destination in destinations]}
|
||||
|
||||
|
||||
@router.get("/site-config")
|
||||
def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
destinations = db.scalars(
|
||||
select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
|
||||
).all()
|
||||
return {
|
||||
"heroSlides": [
|
||||
hero_slide_admin_dict(item)
|
||||
for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all()
|
||||
],
|
||||
"destinations": [destination_dict(item) for item in destinations],
|
||||
"destinationHero": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all()
|
||||
],
|
||||
"destinationRegions": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(DestinationRegion).order_by(DestinationRegion.sortOrder.asc())).all()
|
||||
],
|
||||
"map": [
|
||||
map_image_admin_dict(item)
|
||||
for item in db.scalars(select(MapImage).order_by(MapImage.createdAt.asc())).all()
|
||||
],
|
||||
"themes": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(ThemeCard).order_by(ThemeCard.sortOrder.asc())).all()
|
||||
],
|
||||
"ctaBanners": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(CtaBanner).order_by(CtaBanner.sortOrder.asc())).all()
|
||||
],
|
||||
"campaigns": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all()
|
||||
],
|
||||
"routeSections": [route_section_dict(item) for item in db.scalars(route_section_query()).all()],
|
||||
"hotelGroups": [
|
||||
hotel_group_dict(item)
|
||||
for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all()
|
||||
],
|
||||
"vehicleOptions": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all()
|
||||
@@ -795,7 +424,6 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all()
|
||||
],
|
||||
"demandRecommendations": [demand_recommendation_dict(item) for item in db.scalars(demand_recommendation_query()).all()],
|
||||
}
|
||||
|
||||
|
||||
@@ -808,20 +436,13 @@ def create_site_config(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
config = site_module(module)
|
||||
if module == "routeSections":
|
||||
return create_route_section_config(body, request, db)
|
||||
if config.get("fixed"):
|
||||
site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module})
|
||||
if config.get("singleton") and module_items(db, config):
|
||||
if module == "map":
|
||||
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
|
||||
site_config_error(409, "单例配置已存在", "MODULE_CONFIG_SINGLETON_EXISTS", {"module": module})
|
||||
item = config["model"](**site_create_payload(module, config, body, db))
|
||||
db.add(item)
|
||||
db.flush()
|
||||
if module == "demandRecommendations" and "productIds" in body.model_fields_set:
|
||||
replace_demand_recommendation_products(db, item, body.productIds or [])
|
||||
db.flush()
|
||||
after = site_item_dict(module, item)
|
||||
audit(db, get_actor_id(request), "create", config["entity"], item.id, after)
|
||||
db.commit()
|
||||
@@ -874,10 +495,6 @@ def update_site_config(
|
||||
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||
before = site_item_dict(module, item)
|
||||
apply_site_patch(module, config, item, body)
|
||||
if module == "routeSections" and "productIds" in body.model_fields_set:
|
||||
replace_route_section_products(db, item, body.productIds or [])
|
||||
if module == "demandRecommendations" and "productIds" in body.model_fields_set:
|
||||
replace_demand_recommendation_products(db, item, body.productIds or [])
|
||||
db.flush()
|
||||
after = site_item_dict(module, item)
|
||||
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
|
||||
@@ -899,10 +516,6 @@ def delete_site_config(
|
||||
item = db.get(config["model"], item_id)
|
||||
if not item:
|
||||
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||
if module == "destinations":
|
||||
product_count = db.scalar(select(func.count()).select_from(Product).where(Product.destinationId == item.id))
|
||||
if product_count:
|
||||
site_config_error(409, "配置项仍被商品引用,不能删除", "MODULE_CONFIG_CONFLICT", {"module": module, "id": item_id})
|
||||
before = site_item_dict(module, item)
|
||||
db.delete(item)
|
||||
db.flush()
|
||||
@@ -935,7 +548,7 @@ def list_leads(
|
||||
)
|
||||
stmt = (
|
||||
select(Lead)
|
||||
.options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser))
|
||||
.options(selectinload(Lead.assignedUser))
|
||||
.order_by(Lead.createdAt.desc())
|
||||
.limit(query.take)
|
||||
)
|
||||
@@ -949,18 +562,13 @@ def list_leads(
|
||||
stmt = stmt.where(Lead.createdAt < query.createdTo)
|
||||
if query.keyword and query.keyword.strip():
|
||||
pattern = f"%{query.keyword.strip()}%"
|
||||
stmt = (
|
||||
stmt.outerjoin(Lead.sourceProduct)
|
||||
.where(
|
||||
or_(
|
||||
Lead.phone.ilike(pattern),
|
||||
Lead.destination.ilike(pattern),
|
||||
Lead.note.ilike(pattern),
|
||||
Product.title.ilike(pattern),
|
||||
)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Lead.phone.ilike(pattern),
|
||||
Lead.destination.ilike(pattern),
|
||||
Lead.note.ilike(pattern),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
).distinct()
|
||||
leads = db.scalars(stmt).all()
|
||||
return {"items": [lead_dict(lead) for lead in leads]}
|
||||
|
||||
|
||||
Reference in new issue
Block a user