feat(route-sections): add dynamic route sections
Replace hardcoded homepage featured route groups with a fully managed dynamic system: - add database models RouteSection and RouteSectionProduct for storing route groups and their associated products - create Alembic migration 0004_route_sections for the new tables - extend SiteConfigPatchIn schema with subtitle and productIds fields - refactor shared site_config utility to load dynamic route sections instead of fixed groups - implement admin CRUD API with validation for duplicate/conflicting product associations - update public and admin API documentation to reflect the new system - add default route section seed data and comprehensive test coverage
This commit is contained in:
@@ -149,6 +149,7 @@ class Product(Base):
|
||||
destination: Mapped[Destination | None] = relationship(back_populates="products")
|
||||
images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan")
|
||||
campaignLinks: Mapped[list["CampaignProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
|
||||
routeSectionLinks: Mapped[list["RouteSectionProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
|
||||
leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct")
|
||||
orders: Mapped[list["Order"]] = relationship(back_populates="product")
|
||||
|
||||
@@ -196,6 +197,31 @@ class CampaignProduct(Base):
|
||||
product: Mapped[Product] = relationship(back_populates="campaignLinks")
|
||||
|
||||
|
||||
class RouteSection(Base):
|
||||
__tablename__ = "RouteSection"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||
subtitle: Mapped[str | None] = mapped_column(Text)
|
||||
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
|
||||
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
|
||||
products: Mapped[list["RouteSectionProduct"]] = relationship(back_populates="section", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RouteSectionProduct(Base):
|
||||
__tablename__ = "RouteSectionProduct"
|
||||
|
||||
sectionId: Mapped[str] = mapped_column(String, ForeignKey("RouteSection.id", ondelete="CASCADE"), primary_key=True)
|
||||
productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), primary_key=True)
|
||||
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
section: Mapped[RouteSection] = relationship(back_populates="products")
|
||||
product: Mapped[Product] = relationship(back_populates="routeSectionLinks")
|
||||
|
||||
|
||||
class Lead(Base):
|
||||
__tablename__ = "Lead"
|
||||
|
||||
|
||||
91
app/route_sections.py
Normal file
91
app/route_sections.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .models import Product, RouteSection, RouteSectionProduct
|
||||
from .serializers import model_dict
|
||||
|
||||
|
||||
ROUTE_SECTION_DEFAULTS = [
|
||||
("routes", "经典人文打卡线路", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联", 0, 8),
|
||||
("routes-outdoor", "极限山野户外野咖线路", "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合", 8, 16),
|
||||
("routes-mix", "人文+户外综合混搭线路", "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排", 16, 24),
|
||||
]
|
||||
|
||||
|
||||
def route_section_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 {}})
|
||||
|
||||
|
||||
def route_section_dict(item: RouteSection, *, public: bool = False) -> dict:
|
||||
links = sorted(item.products or [], key=lambda link: (link.sortOrder, link.productId))
|
||||
if public:
|
||||
links = [link for link in links if link.product and link.product.status == "published"]
|
||||
|
||||
data = {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"subtitle": item.subtitle,
|
||||
"productIds": [link.productId for link in links],
|
||||
"isActive": item.isActive,
|
||||
}
|
||||
if not public:
|
||||
base = model_dict(item)
|
||||
data.update(
|
||||
{
|
||||
"sortOrder": item.sortOrder,
|
||||
"createdAt": base["createdAt"],
|
||||
"updatedAt": base["updatedAt"],
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def route_section_query():
|
||||
return (
|
||||
select(RouteSection)
|
||||
.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product))
|
||||
.order_by(RouteSection.sortOrder.asc())
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def validate_route_section_product_ids(db: Session, section_id: str, product_ids: list[str]) -> None:
|
||||
if len(set(product_ids)) != len(product_ids):
|
||||
route_section_error(422, "线路商品不能重复", "ROUTE_SECTION_PRODUCT_DUPLICATE", {"sectionId": section_id})
|
||||
if not product_ids:
|
||||
return
|
||||
|
||||
products = db.scalars(select(Product).where(Product.id.in_(product_ids))).all()
|
||||
found_ids = {product.id for product in products}
|
||||
missing_ids = [product_id for product_id in product_ids if product_id not in found_ids]
|
||||
if missing_ids:
|
||||
route_section_error(422, "线路商品不存在", "ROUTE_SECTION_PRODUCT_NOT_FOUND", {"productIds": missing_ids})
|
||||
|
||||
conflicts = db.scalars(
|
||||
select(RouteSectionProduct).where(
|
||||
RouteSectionProduct.productId.in_(product_ids),
|
||||
RouteSectionProduct.sectionId != section_id,
|
||||
)
|
||||
).all()
|
||||
if conflicts:
|
||||
conflict = conflicts[0]
|
||||
route_section_error(
|
||||
409,
|
||||
"同一线路商品不能同时属于多个精选线路子分组",
|
||||
"ROUTE_SECTION_PRODUCT_CONFLICT",
|
||||
{"productId": conflict.productId, "sectionId": conflict.sectionId},
|
||||
)
|
||||
|
||||
|
||||
def replace_route_section_products(db: Session, section: RouteSection, product_ids: list[str]) -> None:
|
||||
validate_route_section_product_ids(db, section.id, product_ids)
|
||||
db.execute(delete(RouteSectionProduct).where(RouteSectionProduct.sectionId == section.id))
|
||||
db.flush()
|
||||
section.products = []
|
||||
for index, product_id in enumerate(product_ids):
|
||||
link = RouteSectionProduct(sectionId=section.id, productId=product_id, sortOrder=index)
|
||||
section.products.append(link)
|
||||
db.add(link)
|
||||
|
||||
@@ -25,12 +25,15 @@ from ..models import (
|
||||
MediaAsset,
|
||||
Product,
|
||||
ProductImage,
|
||||
RouteSection,
|
||||
RouteSectionProduct,
|
||||
SiteVersion,
|
||||
ThemeCard,
|
||||
utc_now,
|
||||
)
|
||||
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
|
||||
from ..seed import create_media, reset_guizhou_content
|
||||
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query
|
||||
from ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict
|
||||
from .shared import site_config
|
||||
|
||||
@@ -91,6 +94,15 @@ SITE_CONFIG_MODULES = {
|
||||
"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": {},
|
||||
},
|
||||
"ctaBanners": {
|
||||
"model": CtaBanner,
|
||||
"entity": "cta_banner",
|
||||
@@ -360,12 +372,16 @@ def site_item_dict(module: str, item) -> dict:
|
||||
return hero_slide_admin_dict(item)
|
||||
if module == "map":
|
||||
return map_image_admin_dict(item)
|
||||
if module == "routeSections":
|
||||
return route_section_dict(item)
|
||||
return destination_dict(item) if module == "destinations" else 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 config.get("ordered", True):
|
||||
stmt = stmt.order_by(model.sortOrder.asc())
|
||||
else:
|
||||
@@ -383,7 +399,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
|
||||
if field in fields and field != "productIds"
|
||||
}
|
||||
payload[config["primary"]] = validate_site_primary(config, body)
|
||||
for field, value in config["create_defaults"].items():
|
||||
@@ -411,6 +427,8 @@ 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})
|
||||
@@ -423,6 +441,35 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
|
||||
setattr(item, field, value)
|
||||
|
||||
|
||||
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)
|
||||
@@ -581,6 +628,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
|
||||
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()],
|
||||
}
|
||||
|
||||
|
||||
@@ -593,6 +641,10 @@ 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):
|
||||
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
|
||||
item = config["model"](**site_create_payload(module, config, body, db))
|
||||
@@ -650,6 +702,8 @@ 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 [])
|
||||
db.flush()
|
||||
after = site_item_dict(module, item)
|
||||
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
|
||||
@@ -666,6 +720,8 @@ def delete_site_config(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
config = site_module(module)
|
||||
if config.get("fixed"):
|
||||
site_config_error(405, "固定模块不支持删除", "MODULE_CONFIG_DELETE_UNSUPPORTED", {"module": module})
|
||||
item = db.get(config["model"], item_id)
|
||||
if not item:
|
||||
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, Product, ThemeCard
|
||||
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, ThemeCard
|
||||
from ..route_sections import route_section_dict, route_section_query
|
||||
from ..serializers import destination_dict, model_dict
|
||||
|
||||
|
||||
ROUTE_SECTION_LABELS = [
|
||||
("routes", "经典人文打卡线路", 0, 8),
|
||||
("routes-outdoor", "极限山野户外野咖线路", 8, 16),
|
||||
("routes-mix", "人文+户外综合混搭线路", 16, 24),
|
||||
]
|
||||
|
||||
|
||||
def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict:
|
||||
hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc())
|
||||
destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
|
||||
@@ -38,12 +32,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
|
||||
}
|
||||
if include_public_extras:
|
||||
campaigns = db.scalars(select(Campaign).where(Campaign.status == "published").order_by(Campaign.updatedAt.desc())).all()
|
||||
products = db.scalars(
|
||||
select(Product).where(Product.status == "published").order_by(Product.sortWeight.asc(), Product.createdAt.asc()).limit(48)
|
||||
).all()
|
||||
route_sections = db.scalars(route_section_query()).all()
|
||||
if active_only:
|
||||
route_sections = [section for section in route_sections if section.isActive]
|
||||
result["campaigns"] = [model_dict(item) for item in campaigns]
|
||||
result["routeSections"] = [
|
||||
{"id": section_id, "title": title, "productIds": [item.id for item in products[start:end]]}
|
||||
for section_id, title, start, end in ROUTE_SECTION_LABELS
|
||||
]
|
||||
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections]
|
||||
return result
|
||||
|
||||
@@ -121,6 +121,7 @@ class LeadQuery(BaseModel):
|
||||
class SiteConfigPatchIn(BaseModel):
|
||||
title: str | None = None
|
||||
kicker: str | None = None
|
||||
subtitle: str | None = None
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
region: str | None = None
|
||||
@@ -132,6 +133,7 @@ class SiteConfigPatchIn(BaseModel):
|
||||
priceAmount: int | None = Field(default=None, ge=0)
|
||||
priceUnit: str | None = None
|
||||
tags: list[str] | None = None
|
||||
productIds: list[str] | None = None
|
||||
status: str | None = None
|
||||
startsAt: datetime | None = None
|
||||
endsAt: datetime | None = None
|
||||
|
||||
19
app/seed.py
19
app/seed.py
@@ -21,10 +21,13 @@ from .models import (
|
||||
MediaAsset,
|
||||
Product,
|
||||
ProductImage,
|
||||
RouteSection,
|
||||
RouteSectionProduct,
|
||||
SiteVersion,
|
||||
ThemeCard,
|
||||
utc_now,
|
||||
)
|
||||
from .route_sections import ROUTE_SECTION_DEFAULTS
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -97,7 +100,7 @@ def load_products() -> list[dict]:
|
||||
|
||||
def reset_guizhou_content(db: Session) -> dict:
|
||||
products = load_products()
|
||||
for model in [SiteVersion, CampaignProduct, Campaign, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
|
||||
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
|
||||
db.execute(delete(model))
|
||||
db.flush()
|
||||
|
||||
@@ -178,6 +181,18 @@ def reset_guizhou_content(db: Session) -> dict:
|
||||
for index, product in enumerate(linked_products):
|
||||
db.add(CampaignProduct(campaignId=campaign.id, productId=product.id, sortOrder=index))
|
||||
|
||||
for sort_order, (section_id, title, subtitle, start, end) in enumerate(ROUTE_SECTION_DEFAULTS):
|
||||
section = RouteSection(id=section_id, title=title, subtitle=subtitle, sortOrder=sort_order)
|
||||
db.add(section)
|
||||
db.flush()
|
||||
section_products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.sourceId >= start + 1, Product.sourceId <= end)
|
||||
.order_by(Product.sourceId.asc())
|
||||
).all()
|
||||
for index, product in enumerate(section_products):
|
||||
db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index))
|
||||
|
||||
snapshot = SiteVersion(
|
||||
title="guizhou-content-reset",
|
||||
status="published",
|
||||
@@ -187,6 +202,7 @@ def reset_guizhou_content(db: Session) -> dict:
|
||||
"destinations": len(DESTINATIONS),
|
||||
"themeCards": len(THEMES),
|
||||
"products": len(products),
|
||||
"routeSections": len(ROUTE_SECTION_DEFAULTS),
|
||||
},
|
||||
)
|
||||
db.add(snapshot)
|
||||
@@ -197,6 +213,7 @@ def reset_guizhou_content(db: Session) -> dict:
|
||||
"themes": len(THEMES),
|
||||
"ctaBanners": len(CTAS),
|
||||
"products": len(products),
|
||||
"routeSections": len(ROUTE_SECTION_DEFAULTS),
|
||||
"siteVersionId": snapshot.id,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user