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:
54
alembic/versions/0004_route_sections.py
Normal file
54
alembic/versions/0004_route_sections.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Add route section configuration.
|
||||
|
||||
Revision ID: 0004_route_sections
|
||||
Revises: 0003_campaign_display_fields
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "0004_route_sections"
|
||||
down_revision = "0003_campaign_display_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
existing_tables = set(inspect(bind).get_table_names())
|
||||
|
||||
if "RouteSection" not in existing_tables:
|
||||
op.create_table(
|
||||
"RouteSection",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("subtitle", sa.Text(), nullable=True),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "RouteSectionProduct" not in existing_tables:
|
||||
op.create_table(
|
||||
"RouteSectionProduct",
|
||||
sa.Column("sectionId", sa.String(), nullable=False),
|
||||
sa.Column("productId", sa.String(), nullable=False),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.ForeignKeyConstraint(["productId"], ["Product.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["sectionId"], ["RouteSection.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("sectionId", "productId"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
existing_tables = set(inspect(bind).get_table_names())
|
||||
if "RouteSectionProduct" in existing_tables:
|
||||
op.drop_table("RouteSectionProduct")
|
||||
if "RouteSection" in existing_tables:
|
||||
op.drop_table("RouteSection")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# WonderQ-Admin-UI Admin API 接口需求
|
||||
# WonderQ-Admin-UI Admin API 接口需求
|
||||
|
||||
本文档用于指导 `WonderQ-Admin` 后端按当前 `WonderQ-Admin-UI` 管理端完成 Admin API 对接。接口需求来源于前端 `src/api.ts` 与 `src/App.tsx` 的实际类型、请求封装和页面调用。
|
||||
|
||||
@@ -34,7 +34,7 @@ Content-Type: application/json
|
||||
| `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product |
|
||||
| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product |
|
||||
| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 |
|
||||
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容 |
|
||||
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容和已保存的 `routeSections` |
|
||||
| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 |
|
||||
| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 |
|
||||
| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 |
|
||||
@@ -58,7 +58,7 @@ type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "inval
|
||||
### SiteModule
|
||||
|
||||
```ts
|
||||
type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners";
|
||||
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
|
||||
```
|
||||
|
||||
## 公共数据结构
|
||||
@@ -188,6 +188,27 @@ type SiteConfig = {
|
||||
targetValue?: string | null;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
campaigns: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
coverImage?: string | null;
|
||||
priceAmount?: number | null;
|
||||
priceUnit?: string | null;
|
||||
tags: string[];
|
||||
status: "draft" | "published";
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
}>;
|
||||
routeSections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
productIds: string[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
ctaBanners: Array<{
|
||||
id: string;
|
||||
alt: string;
|
||||
@@ -204,14 +225,28 @@ type SiteConfig = {
|
||||
```ts
|
||||
type SiteItemPatch = {
|
||||
title?: string;
|
||||
subtitle?: string | null;
|
||||
kicker?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
region?: string | null;
|
||||
label?: string;
|
||||
alt?: string;
|
||||
image?: string | null;
|
||||
description?: string | null;
|
||||
coverImage?: string | null;
|
||||
priceAmount?: number | null;
|
||||
priceUnit?: string | null;
|
||||
tags?: string[];
|
||||
targetType?: string | null;
|
||||
targetValue?: string | null;
|
||||
isHot?: boolean;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
productIds?: string[];
|
||||
status?: "draft" | "published";
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -222,6 +257,8 @@ type SiteItemPatch = {
|
||||
| `heroSlides` | `title`、`kicker`、`image`、`targetType`、`targetValue`、`isActive` |
|
||||
| `destinations` | `name`、`image`、`isActive` |
|
||||
| `themes` | `label`、`image`、`targetType`、`targetValue`、`isActive` |
|
||||
| `campaigns` | `title`、`description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status` |
|
||||
| `routeSections` | `title`、`subtitle`、`productIds`、`isActive`、`sortOrder` |
|
||||
| `ctaBanners` | `alt`、`image`、`targetType`、`targetValue`、`isActive` |
|
||||
|
||||
## 接口明细
|
||||
@@ -372,9 +409,10 @@ GET /api/admin/site-config
|
||||
|
||||
要求:
|
||||
|
||||
- 返回 `heroSlides`、`destinations`、`themes`、`ctaBanners` 四个模块。
|
||||
- 返回 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`ctaBanners` 七个模块。
|
||||
- Admin API 需要返回未启用内容;Public API 才按发布/启用状态过滤。
|
||||
- 各模块按 `sortOrder` 升序。
|
||||
- `routeSections` 返回当前已保存的子分组,包含未启用分组和后台配置的全部 `productIds`;无数据时返回空数组。
|
||||
|
||||
### 更新站点配置项
|
||||
|
||||
@@ -386,7 +424,7 @@ PATCH /api/admin/site-config/{module}/{item_id}
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `module` | `SiteModule` | 只能为 `heroSlides`、`destinations`、`themes`、`ctaBanners` |
|
||||
| `module` | `SiteModule` | 只能为 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`ctaBanners` |
|
||||
| `item_id` | `string` | 对应模块内容项 ID |
|
||||
|
||||
请求体:`SiteItemPatch`
|
||||
@@ -402,6 +440,26 @@ PATCH /api/admin/site-config/{module}/{item_id}
|
||||
|
||||
当前 UI 保存后会重新调用 `GET /api/admin/site-config` 刷新页面,响应体只需保证是合法 JSON。
|
||||
|
||||
#### 精选线路 `routeSections`
|
||||
|
||||
`routeSections` 是首页“精选线路”的动态运营分组配置,不再限制为固定三组。管理端通过新增、更新、删除和排序接口维护闭环;商品本体仍由 `/api/admin/products` 维护,这里只保存首页分组、标题、副文案、启用状态、排序和关联商品 ID 顺序。
|
||||
|
||||
```http
|
||||
POST /api/admin/site-config/routeSections
|
||||
PATCH /api/admin/site-config/routeSections/{section_id}
|
||||
DELETE /api/admin/site-config/routeSections/{section_id}
|
||||
PATCH /api/admin/site-config/routeSections/reorder
|
||||
```
|
||||
|
||||
新增请求至少包含 `title`,可包含 `subtitle`、`isActive`、`sortOrder`、`productIds`;`id` 由后端生成,不再使用 `routes` / `routes-outdoor` / `routes-mix` 固定槽位。更新请求可包含 `title`、`subtitle`、`isActive`、`sortOrder`、`productIds`。`productIds` 表示该分组关联的线路商品及展示顺序。
|
||||
|
||||
后端约束:
|
||||
- `GET /api/admin/site-config` 返回全部后台分组,包含停用分组和后台配置的全部 `productIds`。
|
||||
- `productIds` 中的商品必须存在,且同一请求内不能重复。
|
||||
- 同一商品不能同时出现在多个精选线路分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`。
|
||||
- `DELETE /api/admin/site-config/routeSections/{section_id}` 只删除分组配置并解除关联,不删除商品本体;删除后后端重新整理剩余分组 `sortOrder`。
|
||||
- `PATCH /api/admin/site-config/routeSections/reorder` 的 `itemIds` 必须完整覆盖当前全部分组 ID,不能缺失、重复或包含未知 ID。
|
||||
- `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。
|
||||
### 线索列表
|
||||
|
||||
```http
|
||||
@@ -485,6 +543,7 @@ POST /api/admin/reset-guizhou-content
|
||||
destinations: number;
|
||||
themes: number;
|
||||
ctaBanners: number;
|
||||
routeSections?: number;
|
||||
products: number;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -78,14 +78,21 @@
|
||||
|
||||
### `RouteSection`
|
||||
|
||||
`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块。
|
||||
`RouteSection` 用于描述首页“精选线路”下的动态运营分组。后台可按任务新增、编辑、删除和排序分组;MiniAPP 不应依赖固定分组 ID,只按接口返回的分组顺序和 `productIds` 渲染。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | 是 | 分组 ID,例如 `routes`、`routes-outdoor`、`routes-mix` |
|
||||
| `id` | `string` | 是 | 后端生成的分组 ID,客户端只用于列表 key 和商品关联,不作为固定业务枚举 |
|
||||
| `title` | `string` | 是 | 分组标题 |
|
||||
| `productIds` | `string[]` | 是 | 该分组包含的产品 ID,产品详情来自 `/api/public/products` 的 `items` |
|
||||
| `subtitle` | `string \| null` | 否 | 分组副文案 |
|
||||
| `productIds` | `string[]` | 是 | 该分组包含的产品 ID;产品详情来自 `/api/public/products.items` |
|
||||
| `isActive` | `boolean` | 否 | 是否启用;Public API 通常只返回启用分组 |
|
||||
|
||||
Public API 输出规则:
|
||||
- `GET /api/public/site-config` 只返回启用的 `routeSections`。
|
||||
- `routeSections[].productIds` 只包含已发布商品 ID;未发布、归档或不存在的商品不得出现在 Public 响应中。
|
||||
- 动态分组按后台 `sortOrder` 升序返回,`productIds` 的顺序就是用户侧商品卡展示顺序。
|
||||
- MiniAPP 会按 `productIds` 匹配 `/api/public/products.items[].id`;接口缺失、`routeSections` 为空或没有可匹配商品时回退 `src/content.ts` 的本地精选线路兜底内容。
|
||||
### `PublicProduct`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
@@ -168,11 +175,7 @@
|
||||
| 首页模块 | 当前接口归属 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 |
|
||||
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 只返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 |
|
||||
| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 |
|
||||
| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 |
|
||||
| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 |
|
||||
|
||||
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 |
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
@@ -224,19 +227,25 @@
|
||||
],
|
||||
"routeSections": [
|
||||
{
|
||||
"id": "routes",
|
||||
"id": "route-section-001",
|
||||
"title": "经典人文打卡线路",
|
||||
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"]
|
||||
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
|
||||
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "routes-outdoor",
|
||||
"id": "route-section-002",
|
||||
"title": "极限山野户外野咖线路",
|
||||
"productIds": []
|
||||
"subtitle": "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合",
|
||||
"productIds": [],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "routes-mix",
|
||||
"id": "route-section-003",
|
||||
"title": "人文+户外综合混搭线路",
|
||||
"productIds": []
|
||||
"subtitle": "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排",
|
||||
"productIds": [],
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -414,7 +423,8 @@
|
||||
|
||||
- `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。
|
||||
- `products.items` 为空时,MiniAPP 会使用本地产品兜底数据。
|
||||
- “精选线路”由 `site-config.routeSections` 定义分组,由 `/api/public/products.items` 提供产品详情;`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 是“精选线路”的子集。
|
||||
- “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量。
|
||||
- `routeSections` 缺失、为空或无法匹配到有效商品时,MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。
|
||||
- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
|
||||
- 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。
|
||||
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。
|
||||
@@ -425,7 +435,8 @@
|
||||
|
||||
- 为 `GET /health` 增加或保留健康检查测试。
|
||||
- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`、`campaigns`、`routeSections` 数组字段。
|
||||
- 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组,并包含 `routes`、`routes-outdoor`、`routes-mix` 三个当前约定分组。
|
||||
- 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底。
|
||||
- 为 `GET /api/public/site-config` 验证 `routeSections` 只返回启用分组,且 `productIds` 不包含未发布商品。
|
||||
- 为 `GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。
|
||||
- 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
|
||||
- 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。
|
||||
|
||||
@@ -8,7 +8,7 @@ from app import serializers
|
||||
from app.auth import hash_password, require_admin
|
||||
from app.database import get_db
|
||||
from app.main import create_app
|
||||
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, ThemeCard
|
||||
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard
|
||||
from app.routers import admin as admin_router
|
||||
from app.routers.admin import normalize_detail_sections, normalize_images
|
||||
from app.routers.shared import site_config
|
||||
@@ -208,6 +208,31 @@ def make_campaign(**overrides):
|
||||
)
|
||||
|
||||
|
||||
def make_route_section(**overrides):
|
||||
section = RouteSection(
|
||||
id=overrides.get("id", "routes"),
|
||||
title=overrides.get("title", "经典人文打卡线路"),
|
||||
subtitle=overrides.get("subtitle", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联"),
|
||||
sortOrder=overrides.get("sortOrder", 0),
|
||||
isActive=overrides.get("isActive", True),
|
||||
createdAt=datetime(2026, 1, 1),
|
||||
updatedAt=datetime(2026, 1, 2),
|
||||
)
|
||||
section.products = overrides.get("products", [])
|
||||
return section
|
||||
|
||||
|
||||
def make_route_section_product(**overrides):
|
||||
product = overrides.get("product", make_product(id=overrides.get("productId", "product-test")))
|
||||
link = RouteSectionProduct(
|
||||
sectionId=overrides.get("sectionId", "routes"),
|
||||
productId=overrides.get("productId", product.id),
|
||||
sortOrder=overrides.get("sortOrder", 0),
|
||||
)
|
||||
link.product = product
|
||||
return link
|
||||
|
||||
|
||||
def make_admin_user():
|
||||
return AdminUser(
|
||||
id="admin-test",
|
||||
@@ -471,7 +496,7 @@ def test_site_config_create_requires_module_primary_field():
|
||||
|
||||
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
|
||||
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
|
||||
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], []])
|
||||
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], [], [], []])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
@@ -489,7 +514,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
|
||||
|
||||
def test_admin_site_config_includes_map_array_with_dedicated_contract():
|
||||
map_image = make_map_image()
|
||||
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], []])
|
||||
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], []])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
@@ -515,7 +540,7 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
|
||||
|
||||
def test_admin_site_config_includes_campaigns_for_special_offers():
|
||||
campaign = make_campaign(status="published")
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign]])
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
@@ -547,6 +572,90 @@ def test_admin_site_config_includes_campaigns_for_special_offers():
|
||||
assert "targetType" not in body["campaigns"][0]
|
||||
|
||||
|
||||
def test_admin_site_config_includes_route_sections_for_featured_routes():
|
||||
section = make_route_section(
|
||||
products=[
|
||||
make_route_section_product(productId="product-a", sortOrder=1),
|
||||
make_route_section_product(productId="product-b", sortOrder=0),
|
||||
],
|
||||
)
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).get("/api/admin/site-config")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
route_sections = response.json()["routeSections"]
|
||||
assert route_sections == [
|
||||
{
|
||||
"id": "routes",
|
||||
"title": "经典人文打卡线路",
|
||||
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
|
||||
"productIds": ["product-b", "product-a"],
|
||||
"isActive": True,
|
||||
"sortOrder": 0,
|
||||
"createdAt": "2026-01-01T00:00:00",
|
||||
"updatedAt": "2026-01-02T00:00:00",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_site_config_create_route_section_generates_dynamic_id_and_audits():
|
||||
fake_db = FakeDb(scalar_results=[[]])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/admin/site-config/routeSections",
|
||||
json={"title": " 新人文线路 ", "subtitle": " 新副文案 "},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["id"].startswith("routesection-")
|
||||
assert body["title"] == "新人文线路"
|
||||
assert body["subtitle"] == "新副文案"
|
||||
assert body["productIds"] == []
|
||||
assert body["isActive"] is True
|
||||
assert body["sortOrder"] == 0
|
||||
created = fake_db.added[0]
|
||||
assert isinstance(created, RouteSection)
|
||||
assert created.id.startswith("routesection-")
|
||||
assert fake_db.added[-1].entity == "route_section"
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_route_sections_module_is_not_fixed():
|
||||
assert admin_router.SITE_CONFIG_MODULES["routeSections"].get("fixed") is not True
|
||||
|
||||
|
||||
def test_site_config_create_route_section_allows_more_than_three_groups():
|
||||
items = [
|
||||
make_route_section(id="route-section-a", title="经典", sortOrder=0),
|
||||
make_route_section(id="route-section-b", title="户外", sortOrder=1),
|
||||
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
|
||||
]
|
||||
fake_db = FakeDb(scalar_results=[items])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).post("/api/admin/site-config/routeSections", json={"title": "第四组"})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["title"] == "第四组"
|
||||
assert body["sortOrder"] == 3
|
||||
assert body["productIds"] == []
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
|
||||
fake_db = FakeDb(scalar_values=[0])
|
||||
app = authenticated_app(fake_db)
|
||||
@@ -788,9 +897,67 @@ def test_site_config_patch_campaign_updates_contract_fields_only():
|
||||
assert fake_db.added[-1].entity == "campaign"
|
||||
|
||||
|
||||
def test_site_config_patch_route_section_updates_copy_and_product_order():
|
||||
section = make_route_section()
|
||||
product_a = make_product(id="product-a")
|
||||
product_b = make_product(id="product-b")
|
||||
fake_db = FakeDb(
|
||||
get_result=section,
|
||||
scalar_results=[[product_b, product_a], []],
|
||||
execute_results=[[]],
|
||||
)
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).patch(
|
||||
"/api/admin/site-config/routeSections/routes",
|
||||
json={
|
||||
"title": " 新经典线路 ",
|
||||
"subtitle": " 新副文案 ",
|
||||
"isActive": False,
|
||||
"productIds": ["product-b", "product-a"],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["title"] == "新经典线路"
|
||||
assert body["subtitle"] == "新副文案"
|
||||
assert body["isActive"] is False
|
||||
assert body["productIds"] == ["product-b", "product-a"]
|
||||
links = [item for item in fake_db.added if isinstance(item, RouteSectionProduct)]
|
||||
assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)]
|
||||
assert fake_db.added[-1].entity == "route_section"
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_patch_route_section_rejects_product_used_by_another_section():
|
||||
section = make_route_section(id="routes")
|
||||
conflict = make_route_section_product(sectionId="route-section-other", productId="product-a")
|
||||
fake_db = FakeDb(
|
||||
get_result=section,
|
||||
scalar_results=[[make_product(id="product-a")], [conflict]],
|
||||
)
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).patch(
|
||||
"/api/admin/site-config/routeSections/routes",
|
||||
json={"productIds": ["product-a"]},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == "ROUTE_SECTION_PRODUCT_CONFLICT"
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
def test_public_site_config_campaigns_include_price_and_tags():
|
||||
campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"])
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], []])
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []])
|
||||
|
||||
result = site_config(fake_db, active_only=True)
|
||||
|
||||
@@ -813,6 +980,30 @@ def test_public_site_config_campaigns_include_price_and_tags():
|
||||
]
|
||||
|
||||
|
||||
def test_public_site_config_route_sections_filter_unpublished_products():
|
||||
published = make_product(id="product-published", status="published")
|
||||
draft = make_product(id="product-draft", status="draft")
|
||||
section = make_route_section(
|
||||
products=[
|
||||
make_route_section_product(product=published, productId=published.id, sortOrder=0),
|
||||
make_route_section_product(product=draft, productId=draft.id, sortOrder=1),
|
||||
]
|
||||
)
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]])
|
||||
|
||||
result = site_config(fake_db, active_only=True)
|
||||
|
||||
assert result["routeSections"] == [
|
||||
{
|
||||
"id": "routes",
|
||||
"title": "经典人文打卡线路",
|
||||
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
|
||||
"productIds": ["product-published"],
|
||||
"isActive": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_site_config_patch_updates_destination_contract_fields():
|
||||
destination = make_destination()
|
||||
fake_db = FakeDb(get_result=destination)
|
||||
@@ -925,6 +1116,32 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids)
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item_ids",
|
||||
[
|
||||
["route-section-a", "route-section-a", "route-section-b"],
|
||||
["route-section-a", "route-section-b"],
|
||||
["route-section-a", "route-section-b", "route-section-x"],
|
||||
],
|
||||
)
|
||||
def test_site_config_reorder_route_sections_rejects_duplicate_missing_and_unknown_ids(item_ids):
|
||||
items = [
|
||||
make_route_section(id="route-section-a", title="A", sortOrder=0),
|
||||
make_route_section(id="route-section-b", title="B", sortOrder=1),
|
||||
make_route_section(id="route-section-c", title="C", sortOrder=2),
|
||||
]
|
||||
fake_db = FakeDb(scalar_results=[items])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).patch("/api/admin/site-config/routeSections/reorder", json={"itemIds": item_ids})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID"
|
||||
assert not fake_db.committed
|
||||
|
||||
def test_site_config_reorder_map_is_not_supported():
|
||||
fake_db = FakeDb()
|
||||
app = authenticated_app(fake_db)
|
||||
@@ -953,6 +1170,49 @@ def test_site_config_reorder_campaigns_is_not_supported():
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_reorder_route_sections_reassigns_sort_order():
|
||||
items = [
|
||||
make_route_section(id="route-section-a", title="经典", sortOrder=0),
|
||||
make_route_section(id="route-section-b", title="户外", sortOrder=1),
|
||||
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
|
||||
]
|
||||
fake_db = FakeDb(scalar_results=[items])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).patch(
|
||||
"/api/admin/site-config/routeSections/reorder",
|
||||
json={"itemIds": ["route-section-b", "route-section-a", "route-section-c"]},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert {item.id: item.sortOrder for item in items} == {"route-section-b": 0, "route-section-a": 1, "route-section-c": 2}
|
||||
assert [item["id"] for item in response.json()["items"]] == ["route-section-b", "route-section-a", "route-section-c"]
|
||||
assert fake_db.added[-1].entity == "route_section"
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_delete_route_section_returns_json_reorders_and_does_not_delete_products():
|
||||
section = make_route_section(id="route-section-delete", title="待删除", sortOrder=1)
|
||||
remaining_a = make_route_section(id="route-section-a", title="保留 A", sortOrder=0)
|
||||
remaining_b = make_route_section(id="route-section-b", title="保留 B", sortOrder=2)
|
||||
fake_db = FakeDb(get_result=section, scalar_results=[[remaining_a, remaining_b]])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).delete("/api/admin/site-config/routeSections/route-section-delete")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": "route-section-delete"}
|
||||
assert fake_db.deleted == [section]
|
||||
assert {item.id: item.sortOrder for item in [remaining_a, remaining_b]} == {"route-section-a": 0, "route-section-b": 1}
|
||||
assert fake_db.added[-1].action == "delete"
|
||||
assert fake_db.added[-1].entity == "route_section"
|
||||
assert fake_db.committed
|
||||
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
|
||||
map_image = make_map_image()
|
||||
fake_db = FakeDb(get_result=map_image)
|
||||
|
||||
Reference in New Issue
Block a user